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} + +
+ ); +}