mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
## 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 <org>/<repo>.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 <noreply@anthropic.com>
173 lines
8.7 KiB
TypeScript
173 lines
8.7 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { loadGitHubGrantMetadata } from "../services/tool-access.js";
|
|
|
|
function json(value: unknown, next = false): Response {
|
|
return new Response(JSON.stringify(value), {
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(next ? { link: '<https://api.github.com/next>; rel="next"' } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("GitHub grant metadata", () => {
|
|
it("lists every page of installations and repositories, persisting only display metadata", async () => {
|
|
const request = vi.fn<typeof fetch>(async (input) => {
|
|
const url = new URL(String(input));
|
|
const secondPage = url.searchParams.get("page") === "2";
|
|
if (url.pathname === "/user") return json({ id: 42, login: "octocat", avatar_url: "https://avatars.example/octocat" });
|
|
if (url.pathname === "/user/installations") {
|
|
return json({ installations: secondPage
|
|
? [{ id: 102, repository_selection: "all", account: { login: "octocat" } }]
|
|
: [{ id: 101, repository_selection: "selected", html_url: "https://github.com/settings/installations/101", account: { login: "paperclipai" } }],
|
|
}, !secondPage);
|
|
}
|
|
if (url.pathname === "/user/installations/101/repositories") {
|
|
return json({ total_count: 2, repositories: secondPage
|
|
? [{ id: 2, full_name: "paperclipai/b", owner: { type: "Organization" }, private: true, description: "must-not-persist", clone_url: "must-not-persist" }]
|
|
: [{ id: 1, full_name: "paperclipai/a", owner: { type: "Organization" }, private: false }],
|
|
}, !secondPage);
|
|
}
|
|
if (url.pathname === "/user/installations/102/repositories") {
|
|
return json({ total_count: 1, repositories: [{ id: 3, full_name: "octocat/c", owner: { type: "User" } }] });
|
|
}
|
|
throw new Error(`Unexpected GitHub path: ${url.pathname}`);
|
|
});
|
|
|
|
const metadata = await loadGitHubGrantMetadata("ghu_secret", request, "paperclip-development");
|
|
expect(metadata).toMatchObject({
|
|
userId: "42",
|
|
login: "octocat",
|
|
installationCount: 2,
|
|
repositoryCount: 3,
|
|
repositorySelection: "mixed",
|
|
installationIds: ["101", "102"],
|
|
installationOwnerLogins: ["paperclipai", "octocat"],
|
|
repositories: [
|
|
{ id: "3", fullName: "octocat/c", installationId: "102", ownerType: "personal" },
|
|
{ id: "1", fullName: "paperclipai/a", installationId: "101", ownerType: "organization", private: false },
|
|
{ id: "2", fullName: "paperclipai/b", installationId: "101", ownerType: "organization", private: true },
|
|
],
|
|
installationUrl: "https://github.com/apps/paperclip-development/installations/new",
|
|
managementUrl: "https://github.com/settings/installations/101",
|
|
appSlug: "paperclip-development",
|
|
webhookHealth: "pending",
|
|
});
|
|
expect(metadata.repositories[0]).not.toHaveProperty("private");
|
|
expect(JSON.stringify(metadata)).not.toContain("must-not-persist");
|
|
expect(request).toHaveBeenCalledTimes(6);
|
|
for (const [input, init] of request.mock.calls) {
|
|
expect(new URL(String(input)).origin).toBe("https://api.github.com");
|
|
expect(new Headers(init?.headers).get("authorization")).toBe("Bearer ghu_secret");
|
|
}
|
|
});
|
|
|
|
it("recovers a legacy grant's app chooser from GitHub installation metadata", async () => {
|
|
const request = vi.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(json({ id: 42, login: "octocat" }))
|
|
.mockResolvedValueOnce(json({ installations: [{ id: 101, app_slug: "paperclip-staging", repository_selection: "selected" }] }))
|
|
.mockResolvedValueOnce(json({ repositories: [{ id: 1, full_name: "octocat/a" }] }));
|
|
await expect(loadGitHubGrantMetadata("ghu_secret", request)).resolves.toMatchObject({
|
|
appSlug: "paperclip-staging",
|
|
installationUrl: "https://github.com/apps/paperclip-staging/installations/new",
|
|
});
|
|
});
|
|
|
|
it("requires at least one installation with an accessible repository", async () => {
|
|
const request = vi.fn<typeof fetch>(async (input) => String(input).endsWith("/user")
|
|
? json({ id: 42, login: "octocat" })
|
|
: json({ installations: [] }));
|
|
await expect(loadGitHubGrantMetadata("ghu_secret", request)).rejects.toMatchObject({
|
|
details: expect.objectContaining({ code: "github_installation_required" }),
|
|
});
|
|
});
|
|
|
|
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<typeof fetch>(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<typeof fetch>(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<typeof fetch>()
|
|
.mockResolvedValueOnce(json({ id: 42, login: "octocat" }))
|
|
.mockResolvedValueOnce(json({ installations: [{ id: 101, repository_selection: "selected" }] }))
|
|
.mockResolvedValueOnce(json({ repositories: [{ id: 1, full_name: "octocat/a" }] }, true))
|
|
.mockResolvedValueOnce(new Response("Unavailable", { status: 503 }));
|
|
await expect(loadGitHubGrantMetadata("ghu_secret", request)).rejects.toMatchObject({
|
|
details: expect.objectContaining({ code: "github_access_check_failed" }),
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[401, "oauth_reauthorization_required"],
|
|
[503, "github_access_check_failed"],
|
|
])("preserves the retry decision for HTTP %s without reflecting provider text", async (status, code) => {
|
|
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response("private provider detail", { status }));
|
|
await expect(loadGitHubGrantMetadata("ghu_secret", request)).rejects.toMatchObject({
|
|
status: 422,
|
|
message: "GitHub could not verify this account's installation access",
|
|
details: { code },
|
|
});
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rejects an empty next page before reporting an incomplete access snapshot", async () => {
|
|
const request = vi.fn<typeof fetch>()
|
|
.mockResolvedValueOnce(json({ id: 42, login: "octocat" }))
|
|
.mockResolvedValueOnce(json({ installations: [] }, true));
|
|
await expect(loadGitHubGrantMetadata("ghu_secret", request)).rejects.toMatchObject({
|
|
details: { code: "github_bad_response" },
|
|
});
|
|
expect(request).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|