mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +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>
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { unprocessable } from "../../errors.js";
|
|
|
|
function gitHubRepositoryOwnerType(repository: Record<string, unknown>) {
|
|
const type = recordValue(repository.owner) ? repository.owner.type : undefined;
|
|
return type === "Organization" ? "organization" as const
|
|
: type === "User" ? "personal" as const : undefined;
|
|
}
|
|
|
|
export async function loadGitHubTokenRepositories(
|
|
headers: Record<string, string>,
|
|
request: typeof fetch = fetch,
|
|
) {
|
|
const repositories: Array<{
|
|
id: string;
|
|
fullName: string;
|
|
ownerType?: "personal" | "organization";
|
|
private?: boolean;
|
|
}> = [];
|
|
for (let page = 1; ; page += 1) {
|
|
const response = await request(
|
|
`https://api.github.com/user/repos?per_page=100&page=${page}`,
|
|
{
|
|
headers: {
|
|
...headers,
|
|
accept: "application/vnd.github+json",
|
|
"user-agent": "Paperclip",
|
|
"x-github-api-version": "2022-11-28",
|
|
},
|
|
signal: AbortSignal.timeout(15_000),
|
|
},
|
|
);
|
|
if (!response.ok)
|
|
throw unprocessable(
|
|
"Could not load GitHub repositories. Reconnect GitHub and try again.",
|
|
);
|
|
const rows: unknown = await response.json();
|
|
if (!Array.isArray(rows))
|
|
throw unprocessable("GitHub returned invalid repositories");
|
|
for (const row of rows) {
|
|
if (
|
|
!recordValue(row) ||
|
|
!githubId(row.id) ||
|
|
typeof row.full_name !== "string" ||
|
|
!/^[A-Za-z0-9][A-Za-z0-9-]*\/(?!\.{1,2}$)[A-Za-z0-9_.-]+$/.test(
|
|
row.full_name,
|
|
)
|
|
) {
|
|
throw unprocessable("GitHub returned invalid repository metadata");
|
|
}
|
|
repositories.push({
|
|
id: githubId(row.id)!,
|
|
fullName: row.full_name,
|
|
...(gitHubRepositoryOwnerType(row) ? { ownerType: gitHubRepositoryOwnerType(row) } : {}),
|
|
...(typeof row.private === "boolean" ? { private: row.private } : {}),
|
|
});
|
|
}
|
|
if (!/;\s*rel="next"/.test(response.headers.get("link") ?? ""))
|
|
return repositories;
|
|
if (!rows.length) throw unprocessable("GitHub returned invalid pagination");
|
|
}
|
|
}
|
|
|
|
export async function loadGitHubGrantMetadata(
|
|
accessToken: string,
|
|
request: typeof fetch = fetch,
|
|
appSlug?: string,
|
|
): Promise<{
|
|
userId: string;
|
|
login: string;
|
|
avatarUrl?: string;
|
|
installationCount: number;
|
|
repositoryCount: number;
|
|
repositorySelection: "all" | "selected" | "mixed" | "none";
|
|
installationIds: string[];
|
|
installationOwnerLogins: string[];
|
|
repositories: Array<{
|
|
id: string;
|
|
fullName: string;
|
|
installationId: string;
|
|
ownerType?: "personal" | "organization";
|
|
private?: boolean;
|
|
}>;
|
|
installationUrl: string;
|
|
managementUrl: string;
|
|
appSlug?: string;
|
|
accessRevision: string;
|
|
lastAccessRefreshAt: string;
|
|
webhookHealth: "pending";
|
|
}> {
|
|
let resolvedAppSlug = appSlug;
|
|
const accessRefreshStartedAt = new Date().toISOString();
|
|
const github = async (
|
|
path: string,
|
|
): Promise<{ data: Record<string, unknown>; hasNext: boolean }> => {
|
|
const response = await request(`https://api.github.com${path}`, {
|
|
headers: {
|
|
accept: "application/vnd.github+json",
|
|
authorization: `Bearer ${accessToken}`,
|
|
"user-agent": "Paperclip",
|
|
"x-github-api-version": "2022-11-28",
|
|
},
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
if (!response.ok) {
|
|
throw unprocessable(
|
|
"GitHub could not verify this account's installation access",
|
|
{
|
|
code:
|
|
response.status === 401
|
|
? "oauth_reauthorization_required"
|
|
: "github_access_check_failed",
|
|
},
|
|
);
|
|
}
|
|
const value = (await response.json()) as unknown;
|
|
if (!recordValue(value))
|
|
throw unprocessable("GitHub returned invalid account metadata", {
|
|
code: "github_bad_response",
|
|
});
|
|
return {
|
|
data: value,
|
|
hasNext: /;\s*rel="next"/.test(response.headers.get("link") ?? ""),
|
|
};
|
|
};
|
|
const list = async (
|
|
path: string,
|
|
key: string,
|
|
): Promise<Record<string, unknown>[]> => {
|
|
const items: Record<string, unknown>[] = [];
|
|
for (let page = 1; ; page += 1) {
|
|
const { data, hasNext } = await github(
|
|
`${path}?per_page=100&page=${page}`,
|
|
);
|
|
const batch = data[key];
|
|
if (
|
|
!Array.isArray(batch) ||
|
|
!batch.every(recordValue) ||
|
|
(hasNext && batch.length === 0)
|
|
) {
|
|
throw unprocessable("GitHub returned invalid access metadata", {
|
|
code: "github_bad_response",
|
|
});
|
|
}
|
|
items.push(...batch);
|
|
if (!hasNext) return items;
|
|
}
|
|
};
|
|
const { data: user } = await github("/user");
|
|
const userId = githubId(user.id);
|
|
const login = typeof user.login === "string" ? user.login : null;
|
|
if (!userId || !login)
|
|
throw unprocessable("GitHub returned invalid account metadata", {
|
|
code: "github_bad_response",
|
|
});
|
|
const installations = await list("/user/installations", "installations");
|
|
const installationIds: string[] = [];
|
|
const owners = new Set<string>();
|
|
const selections = new Set<"all" | "selected">();
|
|
const managementUrls = new Set<string>();
|
|
const suspendedManagementUrls = new Set<string>();
|
|
let suspendedCount = 0;
|
|
const repositories = new Map<
|
|
string,
|
|
{ id: string; fullName: string; installationId: string; ownerType?: "personal" | "organization"; private?: boolean }
|
|
>();
|
|
for (const installation of installations) {
|
|
const installationId = githubId(installation.id);
|
|
if (!installationId) continue;
|
|
// Older grants predate the broker's appSlug field. GitHub's installation
|
|
// response identifies this token's app without choosing an environment.
|
|
if (
|
|
!resolvedAppSlug &&
|
|
typeof installation.app_slug === "string" &&
|
|
/^[a-z0-9-]{1,100}$/.test(installation.app_slug)
|
|
) {
|
|
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"
|
|
) {
|
|
selections.add(installation.repository_selection);
|
|
}
|
|
const account = recordValue(installation.account)
|
|
? installation.account
|
|
: null;
|
|
if (typeof account?.login === "string") owners.add(account.login);
|
|
const managementUrl = githubInstallationManagementUrl(
|
|
installation.html_url,
|
|
);
|
|
if (managementUrl) managementUrls.add(managementUrl);
|
|
for (const repository of await list(
|
|
`/user/installations/${installationId}/repositories`,
|
|
"repositories",
|
|
)) {
|
|
const id = githubId(repository.id);
|
|
const fullName =
|
|
typeof repository.full_name === "string" ? repository.full_name : "";
|
|
if (
|
|
!id ||
|
|
!/^[A-Za-z0-9][A-Za-z0-9-]*\/(?!\.{1,2}$)[A-Za-z0-9_.-]+$/.test(
|
|
fullName,
|
|
)
|
|
) {
|
|
throw unprocessable("GitHub returned invalid repository metadata", {
|
|
code: "github_bad_response",
|
|
});
|
|
}
|
|
repositories.set(id, {
|
|
id,
|
|
fullName,
|
|
installationId,
|
|
...(gitHubRepositoryOwnerType(repository) ? { ownerType: gitHubRepositoryOwnerType(repository) } : {}),
|
|
...(typeof repository.private === "boolean"
|
|
? { private: repository.private }
|
|
: {}),
|
|
});
|
|
}
|
|
}
|
|
const repositoryCount = repositories.size;
|
|
if (installationIds.length === 0 || repositoryCount === 0) {
|
|
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.",
|
|
{
|
|
code: "github_installation_required",
|
|
installationUrl,
|
|
managementUrl: "https://github.com/settings/installations",
|
|
},
|
|
);
|
|
}
|
|
const installationUrl = resolvedAppSlug
|
|
? `https://github.com/apps/${resolvedAppSlug}/installations/new`
|
|
: "https://github.com/settings/installations";
|
|
return {
|
|
userId,
|
|
login,
|
|
...(typeof user.avatar_url === "string"
|
|
? { avatarUrl: user.avatar_url }
|
|
: {}),
|
|
installationCount: installationIds.length,
|
|
repositoryCount,
|
|
repositorySelection:
|
|
selections.size > 1
|
|
? "mixed"
|
|
: (selections.values().next().value ?? "none"),
|
|
installationIds,
|
|
installationOwnerLogins: [...owners],
|
|
repositories: [...repositories.values()].sort((a, b) =>
|
|
a.fullName.localeCompare(b.fullName),
|
|
),
|
|
installationUrl,
|
|
managementUrl:
|
|
managementUrls.size === 1
|
|
? managementUrls.values().next().value!
|
|
: "https://github.com/settings/installations",
|
|
...(resolvedAppSlug ? { appSlug: resolvedAppSlug } : {}),
|
|
accessRevision: randomUUID(),
|
|
lastAccessRefreshAt: accessRefreshStartedAt,
|
|
webhookHealth: "pending",
|
|
};
|
|
}
|
|
|
|
function githubInstallationManagementUrl(value: unknown): string | null {
|
|
if (typeof value !== "string" || value.length > 2_000) return null;
|
|
try {
|
|
const url = new URL(value);
|
|
if (
|
|
url.protocol !== "https:" ||
|
|
url.hostname.toLowerCase() !== "github.com"
|
|
)
|
|
return null;
|
|
return url.pathname.includes("/settings/installations/")
|
|
? url.toString()
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function githubId(value: unknown): string | null {
|
|
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0)
|
|
return String(value);
|
|
if (typeof value === "string" && /^[1-9][0-9]{0,30}$/.test(value))
|
|
return value;
|
|
return null;
|
|
}
|
|
|
|
function recordValue(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|