Reduce encrypted event retries and GitHub run-query transfer (#17251)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Its Cloud connector delivers encrypted GitHub events to an instance.
> - One failed inner event currently rejects the whole authenticated
batch.
> - Healthy events then remain unacknowledged and are downloaded again.
> - GitHub response and check reconciliation also reads full run records
when it needs only ownership and status.
> - This pull request isolates inner failures and narrows those run
projections.
> - Valid events can finish while failed events remain available for
recovery.

## Linked Issues or Issue Description

**What happened?**

An inner App event encrypted with an unavailable key causes
`leaseEvents` to reject the complete batch. No sibling can finish or
acknowledge. Repeated retries download the same encrypted payloads.
Separately, GitHub response and check reads load prompts, runtime
profiles, results, and log excerpts that their callers do not use.

**Expected behavior**

Authenticate the outer batch and lease before handling individual
events. Process valid siblings independently. Retain failed events
without acknowledging them. Read only the current run fields needed for
response ownership and status checks.

**Steps to reproduce**

1. Seal one event for an old instance key and another for the current
key.
2. Put both events in a correctly authenticated outer lease.
3. Observe that the old client rejects the whole lease.
4. With this change, the valid event processes, the failed ID is
reported locally, and only completed events acknowledge.

No public issue exists. The change does not add a provider integration
or duplicate a roadmap feature.

## What Changed

- Keep outer authentication, environment checks, and lease binding
strict.
- Isolate inner decryption and route-binding failures. Return their
authenticated IDs only to the local poller and count them as failures.
- Leave failed events unacknowledged in the broker. Continue processing
and acknowledging healthy and duplicate siblings.
- Read only run identity, status, and required request context for
GitHub responses. Read only run ID and status for check reconciliation.
- Cover stale keys, swapped routes, invalid outer bindings, all-failed
batches, large unused run payloads, and cross-company/task refusals.

## Verification

- Connector cryptography suite: 47 tests passed.
- Final connector suite: 50 tests passed, including cryptography and
three real PostgreSQL connection-event tests. These cover acknowledgment
isolation, all-failed batches, and the exact safe warning fields.
- GitHub workflow regression suite after the master refresh: 307 tests
passed, including the large-payload/company/task test (1,084 unrelated
tests skipped by the selection).
- Server typecheck passed after the final source changes.
- `pnpm -r typecheck` and `pnpm build` passed after the latest
conflict-free master refresh.
- The broad local `pnpm test:run` was stopped when the branch was
refreshed; it has no complete passing result. All CI gates passed on the
final commit, including general and serialized server suites, runner
verification, build, typecheck, and end-to-end tests. Apex is 5/5 with
all review comments resolved. The last change removes source comments
and expands docs; both edited TypeScript files emit identical JavaScript
before and after that change.
- A read-only sample compared full run records with the new field sets:
response data decreased by about 96%, and check data by over 99%. This
is serialized query output, not a claim of realized billing savings or
CPU reduction.
- An initial database test setup failed because the local dependency
install omitted native-library symlinks. The package's standard setup
script repaired them. The unchanged test timeout then passed.
- Added lines checked for credentials, customer data, private links, and
internal issue references.

## Risks

Failed inner events remain pending and retry under the broker's existing
lease and retention policy. This change does not recover a lost original
private key or introduce a dead-letter deletion path. Outer
authentication failures still reject the complete batch.

Run projections must retain request provenance and task binding fields.
Existing predicates, ordering, identity validation, and publication
authority remain unchanged. The change has no schema migration and adds
no background timer or new provider call.

## Model Used

OpenAI GPT-6 through Codex, with reasoning and shell/GitHub CLI 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
- [x] I have described the issue in-PR following the bug report template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket ID
- [x] Focused local regressions and all CI gates pass
- [x] I have added and updated tests
- [x] I have updated relevant documentation
- [x] I have documented risks
- [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:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-22 17:40:29 -07:00
1 parent 6204239cfc
commit 35bd7f6059
8 files changed
+136 -23

No files matched your search

+18
View File
@@ -178,6 +178,24 @@ It retains GitHub's destructive classification. Read tools include
Provider references: [MCP toolset configuration](https://github.com/github/github-mcp-server/blob/main/docs/server-configuration.md)
and [workflow dispatch permissions](https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event).
GitHub response reconciliation reads run status and request identity without
loading prompts, runtime profiles, results, or log excerpts. Request ownership
checks still use current company, agent, task, and accepted message provenance.
The run context projection retains `issueId`, `taskId`, `wakeCommentId`, and
`wakeCommentIds`; larger runtime data stays in its owning reads.
## Webhook delivery recovery
The Cloud connector authenticates the outer event batch before it processes
individual encrypted App deliveries. If an inner delivery cannot be decrypted
or fails its route binding check, the instance reports a failed delivery and
leaves it unacknowledged in Cloud. A local warning includes the authenticated
delivery ID and a fixed failure reason, without event contents or raw errors.
Other valid events in the batch can still finish and acknowledge independently.
Failed ciphertext never reaches the
GitHub webhook handler. The broker retains failed deliveries for retry under
its normal lease and retention policy; this does not recover a lost old key.
## Webhooks
For the shared GitHub account connector, Paperclip Cloud verifies
@@ -2564,6 +2564,23 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const session = { companyId: f.companyId, agentId: f.assignedAgentId, issueId: conversation.issueId, runId: run.id };
return { ...f, conversation, run, session };
}
it("keeps GitHub response ownership checks independent of large run payloads", async () => {
const f = await toolReplyFixture();
const responses = githubResponseCommentService(db, f.providerFetch);
const input = { ...f.session, issueId: f.conversation.issueId!, endpointId: f.endpoint.id };
expect(await responses.hasWorkingResponseForRun(input)).toBe(true);
const unused = "unused-run-payload".repeat(32_768);
await db.update(heartbeatRuns).set({
contextSnapshot: { ...f.run.contextSnapshot, prompt: unused, nativeExecutionInput: { prompt: unused } },
runnerProfileJson: { unused }, resultJson: { unused }, stdoutExcerpt: unused,
}).where(eq(heartbeatRuns.id, f.run.id));
expect(await responses.hasWorkingResponseForRun(input)).toBe(true);
await expect(responses.hasWorkingResponseForRun({ ...input, companyId: randomUUID() }))
.rejects.toMatchObject({ status: 403 });
await expect(responses.hasWorkingResponseForRun({ ...input, issueId: randomUUID() }))
.rejects.toMatchObject({ status: 403 });
});
async function publicationRetryFixture() {
const f = await toolReplyFixture("github:paperclipai/paperclip:418", true);
const head = "b".repeat(40);
@@ -13,6 +13,7 @@ import {
import { eq } from "drizzle-orm";
import { githubConnectionEventService } from "../services/github-connection-events.js";
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
import { logger } from "../middleware/logger.js";
import type { PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js";
import {
getEmbeddedPostgresTestSupport,
@@ -45,7 +46,7 @@ describeEmbeddedPostgres("GitHub connection event delivery", () => {
await tempDb?.cleanup();
});
it("applies a normalized merged PR once, updates the snapshot, and acknowledges Cloud", async () => {
it("applies a normalized merged PR once while retaining undecryptable siblings for retry", async () => {
const companyId = randomUUID();
const userId = `github-owner-${randomUUID()}`;
const applicationId = randomUUID();
@@ -144,13 +145,18 @@ describeEmbeddedPostgres("GitHub connection event delivery", () => {
refresh: vi.fn(),
revoke: vi.fn(),
setWebhookBinding: vi.fn(async () => undefined),
leaseEvents: vi.fn(async () => ({ leaseId: `lease-${++poll}`, events: [leasedEvent] })),
leaseEvents: vi.fn(async () => ({ leaseId: `lease-${++poll}`, events: [leasedEvent], failedEventIds: ["undecryptable-delivery"] })),
acknowledgeEvents: vi.fn(async () => 1),
} as unknown as PaperclipCloudConnector;
let currentTime = new Date("2026-09-04T12:00:05.000Z");
const service = githubConnectionEventService(db, { connector, now: () => currentTime });
await expect(service.pollOnce()).resolves.toMatchObject({ leased: 1, processed: 1, duplicate: 0, failed: 0 });
const warning = vi.spyOn(logger, "warn").mockImplementation(() => undefined);
await expect(service.pollOnce()).resolves.toMatchObject({ leased: 2, processed: 1, duplicate: 0, failed: 1 });
expect(warning.mock.calls).toEqual([[{
deliveryId: "undecryptable-delivery", reason: "inner_event_authentication_failed",
}, "GitHub connector event retained for retry"]]);
warning.mockRestore();
const [snapshot] = await db.select().from(externalObjects).where(eq(externalObjects.id, externalObjectId));
expect(snapshot).toMatchObject({
statusKey: "merged",
@@ -196,15 +202,27 @@ describeEmbeddedPostgres("GitHub connection event delivery", () => {
repositoryId: "99",
});
expect(connector.acknowledgeEvents).toHaveBeenCalledTimes(1);
expect(connector.acknowledgeEvents).toHaveBeenLastCalledWith(expect.objectContaining({
deliveryIds: [leasedEvent.id],
}));
expect(await db.select().from(connectionEventDeliveries).where(eq(
connectionEventDeliveries.providerDeliveryId, "undecryptable-delivery",
))).toEqual([]);
currentTime = new Date(currentTime.getTime() + 6_000);
await expect(service.pollOnce()).resolves.toMatchObject({ leased: 1, processed: 0, duplicate: 1, failed: 0 });
await expect(service.pollOnce()).resolves.toMatchObject({ leased: 2, processed: 0, duplicate: 1, failed: 1 });
const [duplicateReceipt] = await db.select().from(connectionEventDeliveries).where(eq(
connectionEventDeliveries.providerDeliveryId,
leasedEvent.id,
));
expect(duplicateReceipt?.attempts).toBe(1);
expect(connector.acknowledgeEvents).toHaveBeenCalledTimes(2);
vi.mocked(connector.leaseEvents).mockResolvedValue({
leaseId: "failed-only", events: [], failedEventIds: ["undecryptable-delivery"],
});
currentTime = new Date(currentTime.getTime() + 6_000);
await expect(service.pollOnce()).resolves.toMatchObject({ leased: 1, processed: 0, failed: 1 });
expect(connector.acknowledgeEvents).toHaveBeenCalledTimes(2);
});
it.each([false, true])("applies installation events once without discarding newer verified access (refreshed: %s)", async (refreshed) => {
+2 -2
View File
@@ -171,14 +171,14 @@ export function githubReviewCheckService(db: Db, fetchImpl = fetch) {
let run = review?.runId
? (
await db
.select()
.select({ id: heartbeatRuns.id, status: heartbeatRuns.status })
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, review.runId))
)[0]
: undefined;
if (review && !run) {
const [sourceRun] = await db
.select({ run: heartbeatRuns })
.select({ run: { id: heartbeatRuns.id, status: heartbeatRuns.status } })
.from(chatDeliveries)
.innerJoin(
chatMessageLinks,
@@ -24,6 +24,23 @@ type Api = { request<T>(path: string, options?: { method?: string; body?: unknow
type Lease = { commit<T>(write: (tx: Transaction) => Promise<T>): Promise<T> };
type Source = GitHubResponseSource;
type Comment = { id: number; body?: string; html_url: string; user?: { login?: string } };
const responseRunColumns = {
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
responsibleUserId: heartbeatRuns.responsibleUserId,
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
nativeIssueId: heartbeatRuns.nativeIssueId,
issueId: heartbeatRuns.issueId,
status: heartbeatRuns.status,
updatedAt: heartbeatRuns.updatedAt,
contextSnapshot: sql<Record<string, unknown>>`jsonb_build_object(
'issueId', ${heartbeatRuns.contextSnapshot}->'issueId',
'taskId', ${heartbeatRuns.contextSnapshot}->'taskId',
'wakeCommentId', ${heartbeatRuns.contextSnapshot}->'wakeCommentId',
'wakeCommentIds', ${heartbeatRuns.contextSnapshot}->'wakeCommentIds'
)`,
};
const generation = (endpoint: Source["endpoint"]) => (endpoint.setup as { runtimeGeneration?: number }).runtimeGeneration ?? 0;
const actionKey = (deliveryId: string) => `github_response:${deliveryId}`;
const responseMarker = (source: Source) => isMainPullResponse(source) ? pullResponseMarker(source) : requestResponseMarker(source);
@@ -189,7 +206,7 @@ export function githubResponseCommentService(db: Db, fetchImpl = fetch) {
let preserveFinal = false;
if (finalBody === undefined) {
const existing = await stageResponse(db, source);
const [requestRun] = await db.select().from(heartbeatRuns).innerJoin(chatMessageLinks, and(
const [requestRun] = await db.select({ runId: heartbeatRuns.id }).from(heartbeatRuns).innerJoin(chatMessageLinks, and(
eq(chatMessageLinks.companyId, source.endpoint.companyId), eq(chatMessageLinks.endpointId, source.endpoint.id),
eq(chatMessageLinks.deliveryId, deliveryId), eq(chatMessageLinks.direction, "inbound"),
or(sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot}->>'wakeCommentId'`,
@@ -197,13 +214,13 @@ export function githubResponseCommentService(db: Db, fetchImpl = fetch) {
)).where(and(eq(heartbeatRuns.companyId, source.endpoint.companyId), eq(heartbeatRuns.agentId, source.endpoint.assignedAgentId),
sql`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${source.conversation.issueId}`))
.orderBy(desc(heartbeatRuns.createdAt)).limit(1);
const [latestRun] = await db.select().from(heartbeatRuns).where(and(
const [latestRun] = await db.select(responseRunColumns).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, source.endpoint.companyId), eq(heartbeatRuns.agentId, source.endpoint.assignedAgentId),
sql`coalesce(${heartbeatRuns.nativeIssueId}::text, ${heartbeatRuns.contextSnapshot}->>'issueId') = ${source.conversation.issueId}`,
)).orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id)).limit(1);
const originMessageId = latestRun?.activeIdentityContextId ? await chatRunSourceMessageId(db, latestRun,
{ allowLegacyWakeComment: true, provider: "github" }) : null;
const ownsLatestRequest = !!latestRun && (requestRun?.heartbeat_runs.id === latestRun.id ||
const ownsLatestRequest = !!latestRun && (requestRun?.runId === latestRun.id ||
(!!source.sourceMessageId && originMessageId === source.sourceMessageId));
requestRunning = ownsLatestRequest && latestRun?.status === "running";
const terminal = latestRun && ["succeeded", "failed", "interrupted", "timed_out", "cancelled"].includes(latestRun.status);
@@ -272,7 +289,7 @@ export function githubResponseCommentService(db: Db, fetchImpl = fetch) {
});
}
async function requestsForRun(input: { companyId: string; endpointId: string; issueId: string; runId: string }) {
const [run] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
const [run] = await db.select(responseRunColumns).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
if (!run || boundRunIssueId(run) !== input.issueId) throw forbidden("GitHub run has no request binding");
const originMessageId = await chatRunSourceMessageId(db, run, { allowLegacyWakeComment: true, provider: "github" });
const ids = [...new Set([originMessageId, run.contextSnapshot?.wakeCommentId, ...(Array.isArray(run.contextSnapshot?.wakeCommentIds) ? run.contextSnapshot.wakeCommentIds : [])].filter((id): id is string => typeof id === "string"))];
@@ -345,7 +362,7 @@ export function githubResponseCommentService(db: Db, fetchImpl = fetch) {
if ((await responseForRequest())?.result?.final === true) continue;
const assertRun = async () => {
const source = await context(link.deliveryId!);
const [current] = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, input.companyId)));
const [current] = await db.select(responseRunColumns).from(heartbeatRuns).where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.companyId, input.companyId)));
if (!current || current.agentId !== source.endpoint.assignedAgentId ||
!["succeeded", "failed", "interrupted", "timed_out", "cancelled"].includes(current.status) ||
source.endpoint.id !== input.endpointId || source.conversation.issueId !== input.issueId ||
@@ -485,11 +485,11 @@ export function githubConnectionEventService(
emptyPolls = 0;
nextPollAt = now().getTime() + 5_000;
const result: GitHubConnectionEventPollResult = {
leased: lease.events.length,
leased: lease.events.length + (lease.failedEventIds?.length ?? 0),
processed: 0,
duplicate: 0,
ignored: 0,
failed: 0,
failed: lease.failedEventIds?.length ?? 0,
};
const acknowledge: string[] = [];
for (const event of lease.events) {
@@ -535,6 +535,10 @@ export function githubConnectionEventService(
deliveryIds: acknowledge,
});
}
for (const deliveryId of lease.failedEventIds ?? []) {
logger.warn({ deliveryId, reason: "inner_event_authentication_failed" },
"GitHub connector event retained for retry");
}
return result;
},
};
@@ -735,11 +735,42 @@ describe("dedicated App gateway delivery", () => {
expect((await connector.leaseEvents({subject,companyId}))?.events[0].payload.githubApp).toEqual(packet);
expect(JSON.stringify(inner)).not.toContain("private comment");
});
it("rejects an inner envelope swapped from another opaque route", async () => {
it("retains an inner envelope swapped from another opaque route for retry", async () => {
const keys = config();
const inner = seal({v:1,instanceId,environment:"staging",registrationId:"route-b"},keys.sealPublicKey,"events",keys.config,"github.bot");
const outer = seal({v:1,instanceId,environment:"staging",leaseId:"lease-a",events:[{id:"transport-a",provider:"github",event:"gateway_delivery",payload:{githubApp:{registrationId:"route-a",sealed:inner}},bindingIds:["github-app:route-a"]}]},keys.sealPublicKey,"events",keys.config,"github.code");
const connector=createPaperclipCloudConnector({config:keys.config,request:vi.fn(async()=>Response.json({leaseId:"lease-a",sealed:outer})) as typeof fetch});
await expect(connector.leaseEvents({subject,companyId})).rejects.toBeInstanceOf(PaperclipCloudConnectorError);
await expect(connector.leaseEvents({subject,companyId})).resolves.toMatchObject({
leaseId: "lease-a", events: [], failedEventIds: ["transport-a"],
});
});
it.each(["ciphertext", "lease"])("still rejects an invalid outer %s binding", async failure => {
const keys = config();
const outer = seal({ v: 1, instanceId, environment: "staging", leaseId: "lease-a", events: [] },
failure === "ciphertext" ? config().sealPublicKey : keys.sealPublicKey, "events", keys.config, "github.code");
const connector = createPaperclipCloudConnector({ config: keys.config,
request: vi.fn(async () => Response.json({ leaseId: failure === "lease" ? "other-lease" : "lease-a", sealed: outer })) as typeof fetch });
await expect(connector.leaseEvents({ subject, companyId })).rejects.toBeInstanceOf(PaperclipCloudConnectorError);
});
it("isolates a stale-key event while decrypting healthy siblings and legacy events", async () => {
const keys = config();
const staleKeys = config();
const packet = { v: 1, instanceId, environment: "staging", registrationId: "route-a", rawBody: "e30=" };
const event = (id: string, inner: unknown) => ({ id, provider: "github", event: "gateway_delivery",
payload: { githubApp: { registrationId: "route-a", sealed: inner } }, bindingIds: ["github-app:route-a"] });
const outer = seal({ v: 1, instanceId, environment: "staging", leaseId: "lease-a", events: [
event("stale", seal(packet, staleKeys.sealPublicKey, "events", keys.config, "github.bot")),
event("healthy", seal(packet, keys.sealPublicKey, "events", keys.config, "github.bot")),
{ id: "legacy", provider: "github", event: "installation", payload: {}, bindingIds: [] },
] }, keys.sealPublicKey, "events", keys.config, "github.code");
const connector = createPaperclipCloudConnector({ config: keys.config,
request: vi.fn(async () => Response.json({ leaseId: "lease-a", sealed: outer })) as typeof fetch });
const result = await connector.leaseEvents({ subject, companyId });
expect(result?.failedEventIds).toEqual(["stale"]);
expect(result?.events.map(event => event.id)).toEqual(["healthy", "legacy"]);
expect(result?.events[0]?.payload.githubApp).toEqual(packet);
expect(JSON.stringify(result)).not.toContain('"ct"');
});
});
@@ -520,7 +520,7 @@ export function createPaperclipCloudConnector(input: {
});
await call("webhook-bind", { ...values, profile: "github.code" }, { field: "binding", value: binding });
},
async leaseEvents(values: { subject: string; companyId: string }): Promise<{ leaseId: string; events: SealedConnectorEvents["events"] } | null> {
async leaseEvents(values: { subject: string; companyId: string }): Promise<{ leaseId: string; events: SealedConnectorEvents["events"]; failedEventIds?: string[] } | null> {
const response = await call("event-lease", values);
if (Array.isArray(response.events) && response.events.length === 0) return null;
if (typeof response.leaseId !== "string") {
@@ -531,16 +531,24 @@ export function createPaperclipCloudConnector(input: {
if (opened.leaseId !== response.leaseId) {
throw new PaperclipCloudConnectorError("Paperclip Cloud connector event lease did not match", "CONNECTOR_BINDING_MISMATCH");
}
const events: SealedConnectorEvents["events"] = [];
const failedEventIds: string[] = [];
for (const event of opened.events) {
const packet = event.payload.githubApp;
if (!isRecord(packet) || packet.sealed === undefined) continue;
const sealed = parseEnvelope(packet.sealed, "events", "github", "github.bot");
const delivery = decryptEnvelope(sealed, sealKey, config.instanceId, config.environment, "github", "github.bot", []) as Record<string, unknown>;
if (delivery.v !== 1 || delivery.instanceId !== config.instanceId || delivery.environment !== config.environment
|| delivery.registrationId !== packet.registrationId) throw badEnvelope();
event.payload = { githubApp: delivery };
try {
if (isRecord(packet) && packet.sealed !== undefined) {
const sealed = parseEnvelope(packet.sealed, "events", "github", "github.bot");
const delivery = decryptEnvelope(sealed, sealKey, config.instanceId, config.environment, "github", "github.bot", []) as Record<string, unknown>;
if (!isRecord(delivery) || delivery.v !== 1 || delivery.instanceId !== config.instanceId || delivery.environment !== config.environment
|| delivery.registrationId !== packet.registrationId) throw badEnvelope();
event.payload = { githubApp: delivery };
}
events.push(event);
} catch {
failedEventIds.push(event.id);
}
}
return { leaseId: opened.leaseId, events: opened.events };
return { leaseId: opened.leaseId, events, ...(failedEventIds.length ? { failedEventIds } : {}) };
},
async acknowledgeEvents(values: { subject: string; companyId: string; leaseId: string; deliveryIds: string[] }): Promise<number> {
const acknowledgement = JSON.stringify({ leaseId: values.leaseId, deliveryIds: values.deliveryIds });