From 3edba115764d7cdb178302c691808c6d5ec8a70a Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 26 Oct 2026 21:16:40 -0700 Subject: [PATCH] fix(github): stop counting suspended installations as repository access (#17343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents get GitHub access through a managed GitHub App connection. **Refresh access** reads the installations and repositories that the user token can reach. > - GitHub keeps listing a suspended App installation and all of its repositories for the user token. But the token then loses write access and cannot read private repositories. > - `loadGitHubGrantMetadata` did not read `suspended_at`. It counted a suspended installation as live access ("all repos") and set the connection to healthy. > - So the board showed "connected, all repositories" while agents got a 403 on `git push` and a 404 on private repositories. The suspend webhook marks the connection unhealthy, but the next refresh or reconnect reverted that. > - This pull request skips suspended installations. When all installations are suspended, Refresh access fails with a clear error and a link to the installation settings page, where an owner can click **Unsuspend**. > - The benefit is that the board shows the real access state, and operators get a direct path to fix it. ## Linked Issues or Issue Description **What happened?** A GitHub App installation on an organization was suspended. The Paperclip GitHub connection still showed "connected" with access to all repositories after **Refresh access**. Agents that used the managed token got `Permission to /.git denied` (403) on push, `Resource not accessible by integration` on branch creation, and 404 on private repositories. **Expected behavior** Refresh access must not count a suspended installation as repository access. When every installation is suspended, Refresh access must fail with a message that says the installation is suspended, and the page must link to the installation settings page. **Steps to reproduce** 1. Connect GitHub to Paperclip through the GitHub App, with an installation on an organization. 2. On GitHub, suspend that installation (Organization settings → GitHub Apps → Configure → Suspend). 3. In Paperclip, click **Refresh access** on the GitHub connection. 4. See that the connection stays healthy and reports all repositories, while agent pushes fail with 403. **Paperclip version or commit** `master` at a708a1333. **Additional context** `GET /user/installations` returns the suspended installation with `suspended_at` set and `repository_selection: all`, and `GET /user/installations/{id}/repositories` still lists its repositories. Unsuspending the installation restored push access and private reads for the same token. ## What Changed - `server/src/services/tool-access/github-access-discovery.ts`: `loadGitHubGrantMetadata` skips installations that have `suspended_at` set. It does not add their IDs, owners, repository selection, or repositories. When there is no usable installation and at least one is suspended, it throws `github_installation_required` with a "suspended" message. `managementUrl` points at the suspended installation's settings page (or the generic installations page when more than one is suspended). - `server/src/services/tool-access/catalog/health.ts`: `checkConnectionHealth` (the Refresh access path) keeps `installationUrl` and `managementUrl` when it rebuilds a `github_installation_required` error. Before, the links were dropped. - `server/src/services/tool-access/catalog/failures.ts`: `github_installation_required` keeps its own code and returns 422. Before, it was folded into a generic `paperclip_error` with HTTP 502. - `ui/src/pages/apps/app-detail/AccessRetryBanner.tsx` (new): the attention banner with the saved health message and **Retry access**. After a failed Refresh access with `github_installation_required`, it shows an **Open installation settings on GitHub** link (https `github.com` URLs only). - `ui/src/pages/apps/AppDetail.tsx`: uses `AccessRetryBanner`, refetches the connection when a refresh fails so the banner shows the saved message, and keys the page on `connectionId` so a recovery link never carries over to another connection. The file stays under 1,000 lines. - Tests: - `server/src/__tests__/github-grant-metadata.test.ts`: a mix of suspended and active installations, and an account where all installations are suspended. - `server/src/__tests__/github-health-recovery.test.ts`: drives `checkConnectionHealth` with a suspended-installation GitHub response and checks the 422, code, and recovery links. - `ui/src/pages/apps/AppDetail.test.tsx`: a failed Retry access renders the installation settings link, and navigating to another cached connection does not show it. ## Verification - Red/green: with the source changes reverted and the tests kept, the new tests fail (suspended installation counted as `all`; recovery link missing from the health-check error; link not rendered; link leaking to another connection). With the changes, they pass. - `server`: `github-grant-metadata`, `github-health-recovery`, `tool-access-service`, `github-operation-credentials`, `tool-access-oauth-protocol.integration`, `tool-connection-removal`, `tool-connection-visibility`, and the other `github-*` / `tool-access-*` suites pass locally (630 tests). - `ui`: `AppDetail.test.tsx` passes (78 tests). `ui` `tsc --noEmit` shows no errors in changed files. - Not run locally: the server `tsc`. The sandbox has a 4 GB memory limit, and the server typecheck is killed there even on clean `master`. CI ran the full typecheck and build, and both pass. - CI: all gates green. One unrelated `workspace-recovery.test.ts` poll timeout in a server shard passed on re-run. - Live check: after the suspended installation was unsuspended, a managed-token `git push --dry-run` succeeded and a private repository returned 200. ## Risks - Low risk. The change only affects installations that GitHub marks as suspended. Active installations behave as before. - A connection that has only suspended installations now fails Refresh access with `github_installation_required` (422) and does not report fake access. This is the intended behavior. - The recovery link only renders for https `github.com` URLs. - Keying the app page on `connectionId` remounts it when moving between connections, which resets per-page state (for example an open rename field). That reset is intended. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through the Claude Code agent SDK with tool use (shell, file edit, code execution). ## 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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../__tests__/github-grant-metadata.test.ts | 54 ++++++++++++ .../__tests__/github-health-recovery.test.ts | 84 +++++++++++++++++++ .../services/tool-access/catalog/failures.ts | 3 +- .../services/tool-access/catalog/health.ts | 10 +++ .../tool-access/github-access-discovery.ts | 26 +++++- ui/src/pages/apps/AppDetail.test.tsx | 47 +++++++++++ ui/src/pages/apps/AppDetail.tsx | 32 ++++--- .../apps/app-detail/AccessRetryBanner.tsx | 42 ++++++++++ 8 files changed, 284 insertions(+), 14 deletions(-) create mode 100644 server/src/__tests__/github-health-recovery.test.ts create mode 100644 ui/src/pages/apps/app-detail/AccessRetryBanner.tsx diff --git a/server/src/__tests__/github-grant-metadata.test.ts b/server/src/__tests__/github-grant-metadata.test.ts index 16f04a2c9fc..58ccb7080de 100644 --- a/server/src/__tests__/github-grant-metadata.test.ts +++ b/server/src/__tests__/github-grant-metadata.test.ts @@ -82,6 +82,60 @@ describe("GitHub grant metadata", () => { }); }); + it("does not count a suspended installation as repository access", async () => { + // GitHub still lists a suspended installation and its repositories for the + // user token, but the token cannot write to them or read private ones. + const request = vi.fn(async (input) => { + const url = new URL(String(input)); + if (url.pathname === "/user") return json({ id: 42, login: "octocat" }); + if (url.pathname === "/user/installations") { + return json({ installations: [ + { id: 101, repository_selection: "all", suspended_at: "2026-10-22T01:33:41Z", html_url: "https://github.com/organizations/paperclipai/settings/installations/101", account: { login: "paperclipai" } }, + { id: 102, repository_selection: "selected", suspended_at: null, account: { login: "octocat" } }, + ] }); + } + if (url.pathname === "/user/installations/101/repositories") { + return json({ repositories: [{ id: 1, full_name: "paperclipai/a" }] }); + } + if (url.pathname === "/user/installations/102/repositories") { + return json({ repositories: [{ id: 2, full_name: "octocat/b" }] }); + } + throw new Error(`Unexpected GitHub path: ${url.pathname}`); + }); + await expect(loadGitHubGrantMetadata("ghu_secret", request)).resolves.toMatchObject({ + installationCount: 1, + repositoryCount: 1, + repositorySelection: "selected", + installationIds: ["102"], + installationOwnerLogins: ["octocat"], + repositories: [{ id: "2", fullName: "octocat/b", installationId: "102" }], + }); + }); + + it("points an all-suspended account at the suspended installation to restore access", async () => { + const request = vi.fn(async (input) => { + const url = new URL(String(input)); + if (url.pathname === "/user") return json({ id: 42, login: "octocat" }); + if (url.pathname === "/user/installations") { + return json({ installations: [ + { id: 101, app_slug: "paperclip-staging", repository_selection: "all", suspended_at: "2026-10-22T01:33:41Z", html_url: "https://github.com/organizations/paperclipai/settings/installations/101" }, + ] }); + } + if (url.pathname === "/user/installations/101/repositories") { + return json({ repositories: [{ id: 1, full_name: "paperclipai/a" }] }); + } + throw new Error(`Unexpected GitHub path: ${url.pathname}`); + }); + await expect(loadGitHubGrantMetadata("ghu_secret", request)).rejects.toMatchObject({ + message: expect.stringContaining("suspended"), + details: { + code: "github_installation_required", + installationUrl: "https://github.com/apps/paperclip-staging/installations/new", + managementUrl: "https://github.com/organizations/paperclipai/settings/installations/101", + }, + }); + }); + it("does not report a partial repository list when a later page fails", async () => { const request = vi.fn() .mockResolvedValueOnce(json({ id: 42, login: "octocat" })) diff --git a/server/src/__tests__/github-health-recovery.test.ts b/server/src/__tests__/github-health-recovery.test.ts new file mode 100644 index 00000000000..27555b23251 --- /dev/null +++ b/server/src/__tests__/github-health-recovery.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { loadGitHubGrantMetadata } from "../services/tool-access.js"; +import { createConnectionHealth } from "../services/tool-access/catalog/health.js"; + +vi.mock("../services/tool-access/connections/projection.js", () => ({ + toConnection: (row: { id: string; healthStatus: string; healthMessage: string | null }) => ({ + id: row.id, + healthStatus: row.healthStatus, + healthMessage: row.healthMessage, + }), +})); + +const SUSPENDED_INSTALLATION_URL = "https://github.com/organizations/paperclipai/settings/installations/101"; + +function json(value: unknown): Response { + return new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } }); +} + +// GitHub's view of an account whose only installation is suspended. +const suspendedGitHub = vi.fn(async (input) => { + const url = new URL(String(input)); + if (url.pathname === "/user") return json({ id: 42, login: "octocat" }); + if (url.pathname === "/user/installations") { + return json({ installations: [ + { id: 101, app_slug: "paperclip-staging", repository_selection: "all", suspended_at: "2026-10-22T01:33:41Z", html_url: SUSPENDED_INSTALLATION_URL }, + ] }); + } + if (url.pathname === "/user/installations/101/repositories") { + return json({ repositories: [{ id: 1, full_name: "paperclipai/a" }] }); + } + throw new Error(`Unexpected GitHub path: ${url.pathname}`); +}); + +function githubConnectionHealth() { + const connection = { + id: "conn-1", + companyId: "company-1", + connectionPurpose: "tools", + transport: "mcp_remote", + credentialPolicy: "shared", + config: { sourceTemplateKey: "github", oauth: { connectorProfile: "github.code" } }, + healthStatus: "ok", + healthMessage: null, + }; + const db = { + select: () => ({ from: () => ({ where: async () => [{ id: "grant-1", kind: "organization", status: "active" }] }) }), + update: () => ({ + set: (values: Record) => ({ + where: () => ({ returning: async () => [{ ...connection, ...values }] }), + }), + }), + } as unknown as Db; + return createConnectionHealth(db, { + now: () => new Date(), + audit: vi.fn(async () => undefined), + getConnectionRow: async () => connection as never, + discovery: {} as never, + setupPolicy: { assertConnectorConnectionSetupEnabled: async () => undefined } as never, + resolveCredentialHeaders: vi.fn() as never, + refreshManagedGitHubGrantAccess: (async () => { + await loadGitHubGrantMetadata("ghu_secret", suspendedGitHub); + }) as never, + ensureRuntimeSlot: vi.fn() as never, + stdioTemplateId: vi.fn() as never, + }); +} + +describe("GitHub Refresh access recovery", () => { + it("keeps the suspended installation's settings link on the health-check failure", async () => { + const { checkConnectionHealth } = githubConnectionHealth(); + + await expect(checkConnectionHealth("conn-1", { actorType: "user", actorId: "user-1" } as never)).rejects.toMatchObject({ + status: 422, + message: expect.stringContaining("suspended"), + details: { + code: "github_installation_required", + managementUrl: SUSPENDED_INSTALLATION_URL, + installationUrl: "https://github.com/apps/paperclip-staging/installations/new", + connection: { id: "conn-1", healthStatus: "error", healthMessage: expect.stringContaining("suspended") }, + }, + }); + }); +}); diff --git a/server/src/services/tool-access/catalog/failures.ts b/server/src/services/tool-access/catalog/failures.ts index 8a5eb3b3525..ff1fe8a14bf 100644 --- a/server/src/services/tool-access/catalog/failures.ts +++ b/server/src/services/tool-access/catalog/failures.ts @@ -29,6 +29,7 @@ export function healthFailureHttpStatus(failure: { if (failure.code === "oauth_insufficient_scope") return 422; if (failure.code === "slack_mcp_access_disabled") return 422; if (failure.code === "user_authorization_required") return 422; + if (failure.code === "github_installation_required") return 422; if (failure.code === "composio_broker_retired") return 422; if (failure.code === "tool_connection_transport_unsupported") return 422; if (failure.code === "cognee_access_unverified" || failure.code === "memory_api_key_rejected") return 422; @@ -59,7 +60,7 @@ export function sanitizeHttpFailure(error: unknown): { if (code === "slack_mcp_access_disabled" || code === "google_workspace_services_unavailable") { return { status: "error", message: error.message, code }; } - if (code === "user_authorization_required") { + if (code === "user_authorization_required" || code === "github_installation_required") { return { status: "error", message: error.message, code }; } if (code === "tool_connection_transport_unsupported") { diff --git a/server/src/services/tool-access/catalog/health.ts b/server/src/services/tool-access/catalog/health.ts index c008f20853e..2cc4df48431 100644 --- a/server/src/services/tool-access/catalog/health.ts +++ b/server/src/services/tool-access/catalog/health.ts @@ -205,12 +205,22 @@ export function createConnectionHealth(db: Db, { now, audit, getConnectionRow, d actor, details: { status: failure.status, transport: connection.transport }, }); + // Keep GitHub's recovery links so Refresh access can point at the exact + // installation page, such as the one with the Unsuspend button. + const githubRecovery = + failure.code === "github_installation_required" && error instanceof HttpError + ? { + installationUrl: asRecord(error.details).installationUrl, + managementUrl: asRecord(error.details).managementUrl, + } + : {}; throw retainMcpConnectionFailure(error, new HttpError(healthFailureHttpStatus(failure), failure.message, { code: failure.code, connection: toConnection(updated), runtimeSlot, setupUrl: connectionSetupUrl(connection), reconnectUrl: connectionReconnectUrl(connection), + ...githubRecovery, })); } } diff --git a/server/src/services/tool-access/github-access-discovery.ts b/server/src/services/tool-access/github-access-discovery.ts index 05083337fd2..4f0912b000e 100644 --- a/server/src/services/tool-access/github-access-discovery.ts +++ b/server/src/services/tool-access/github-access-discovery.ts @@ -158,6 +158,8 @@ export async function loadGitHubGrantMetadata( const owners = new Set(); const selections = new Set<"all" | "selected">(); const managementUrls = new Set(); + const suspendedManagementUrls = new Set(); + let suspendedCount = 0; const repositories = new Map< string, { id: string; fullName: string; installationId: string; ownerType?: "personal" | "organization"; private?: boolean } @@ -165,7 +167,6 @@ export async function loadGitHubGrantMetadata( for (const installation of installations) { const installationId = githubId(installation.id); if (!installationId) continue; - installationIds.push(installationId); // Older grants predate the broker's appSlug field. GitHub's installation // response identifies this token's app without choosing an environment. if ( @@ -175,6 +176,16 @@ export async function loadGitHubGrantMetadata( ) { resolvedAppSlug = installation.app_slug; } + // GitHub keeps listing a suspended installation and its repositories, but + // user tokens lose write access and private-repository reads until an + // owner unsuspends it, so it grants no usable access. + if (installation.suspended_at) { + const suspendedUrl = githubInstallationManagementUrl(installation.html_url); + if (suspendedUrl) suspendedManagementUrls.add(suspendedUrl); + suspendedCount += 1; + continue; + } + installationIds.push(installationId); if ( installation.repository_selection === "all" || installation.repository_selection === "selected" @@ -222,6 +233,19 @@ export async function loadGitHubGrantMetadata( const installationUrl = resolvedAppSlug ? `https://github.com/apps/${resolvedAppSlug}/installations/new` : "https://github.com/settings/installations"; + if (installationIds.length === 0 && suspendedCount > 0) { + throw unprocessable( + "The Paperclip GitHub installation is suspended. Unsuspend it on GitHub before refreshing access.", + { + code: "github_installation_required", + installationUrl, + managementUrl: + suspendedManagementUrls.size === 1 + ? suspendedManagementUrls.values().next().value! + : "https://github.com/settings/installations", + }, + ); + } throw unprocessable( "GitHub access is required. Install Paperclip and grant at least one repository before refreshing access.", { diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index 236685cc319..3650f68d88d 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getAppStoreDefinition } from "@paperclipai/shared"; import type { AggregatorAppSnapshot, AggregatorAppsResponse } from "@paperclipai/shared/aggregator-apps"; +import { ApiError } from "@/api/client"; import { queryKeys } from "@/lib/queryKeys"; import { rememberSkillSourceReturn, skillSourceReturnPath } from "@/lib/skill-source-connect-return"; import { AppDetail } from "./AppDetail"; @@ -1429,6 +1430,52 @@ describe("AppDetail", () => { expect(container.textContent).not.toContain("Reconnect required"); }); + it("links a failed GitHub refresh to the suspended installation's settings", async () => { + mockParams.tab = "permissions"; + const message = "The Paperclip GitHub installation is suspended. Unsuspend it on GitHub before refreshing access."; + getConnectionMock.mockResolvedValue(connection({ + authKind: "oauth", healthStatus: "error", requiresReauthorization: false, healthMessage: message, + })); + checkConnectionHealthMock.mockRejectedValue(new ApiError(message, 422, { + error: message, + code: "github_installation_required", + details: { + code: "github_installation_required", + managementUrl: "https://github.com/organizations/paperclipai/settings/installations/101", + }, + })); + const client = await renderAppDetail(); + await act(async () => { + Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.trim() === "Retry access") + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(checkConnectionHealthMock).toHaveBeenCalledWith("conn-1"); + expect(container.querySelector('a[href="https://github.com/organizations/paperclipai/settings/installations/101"]')?.textContent) + .toBe("Open installation settings on GitHub"); + + // An already-cached connection that also needs attention must not inherit that link. + const other = connection({ + id: "conn-2", authKind: "oauth", healthStatus: "error", requiresReauthorization: false, healthMessage: message, + }); + getConnectionMock.mockResolvedValue(other); + client.setQueryData(queryKeys.tools.connection("conn-2"), other); + mockParams.connectionId = "conn-2"; + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + expect(container.textContent).toContain("Retry access"); + expect(container.querySelector('a[href="https://github.com/organizations/paperclipai/settings/installations/101"]')).toBeNull(); + }); + it("shows terminal OAuth failures as reconnect-required sign-in", async () => { mockParams.tab = "permissions"; getConnectionMock.mockResolvedValue(connection({ diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index d05c6da0a69..f0c8aad3ad7 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -62,6 +62,7 @@ import { isAppAggregator } from "@paperclipai/shared/aggregator-apps"; import { actionPermissionMutation } from "./app-detail/action-permissions"; import { RailwayAccessPanel } from "./app-detail/RailwayAccessPanel"; import { ReviewPanel } from "./app-detail/ReviewPanel"; +import { AccessRetryBanner } from "./app-detail/AccessRetryBanner"; import { ReconnectCard, DangerZone, @@ -82,7 +83,8 @@ export function AppDetail(props: { renderActions?: (connection: ToolConnection) if (connection.isPending) return

Loading connection…

; if (connection.error) return

{connection.error.message}

; const pluginKey = connection.data && aiConnectionRouterPluginKey(connection.data); - return pluginKey ? : ; + // Keyed so page state, such as a failed refresh's recovery link, never carries over to another connection. + return pluginKey ? : ; } function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectionSettings, onReconnect }: { @@ -469,11 +471,17 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio tone: "success", }); }, - onError: (error) => pushToast({ - title: "Couldn't refresh GitHub access", - body: error instanceof Error ? error.message : "Please try again.", - tone: "error", - }), + onError: (error) => { + // The failed check is saved on the connection; refetch so the banner + // shows it next to any GitHub recovery link. + queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) }); + pushToast({ + title: "Couldn't refresh GitHub access", + body: error instanceof Error ? error.message : "Please try again.", + tone: "error", + }); + }, }); const apply = (mutate: { @@ -592,12 +600,12 @@ function StandardAppDetail({ renderActions, renderAgentSettings, renderConnectio /> {status.tone === "attention" && connection.requiresReauthorization === false && ( -
-

{connection.healthMessage || "GitHub access could not be checked. Try again."}

- -
+ refreshGitHubAccess.mutate()} + /> )} {needsReconnect && ( void; +}) { + const recoveryUrl = githubAccessRecoveryUrl(error); + return ( +
+

{message || "GitHub access could not be checked. Try again."}

+ {recoveryUrl ? ( + + ) : null} + +
+ ); +}