mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents need source control access for repository work > - A shared token cannot preserve the responsible person's identity or an agent's dedicated identity > - GitHub App tokens also need durable refresh, repository access checks, and webhook delivery > - Paperclip already has managed connections, encrypted grants, run secret leases, and merge-confirmation behavior > - This pull request extends those systems with GitHub identities instead of adding a parallel credential system > - The benefit is durable GitHub access with explicit identity, repository, runtime, and webhook boundaries ## Linked Issues or Issue Description No public GitHub issue describes this connection change. This description follows the feature request template. **Subsystem affected** Connected Apps, connection grants, secret resolution, native Git runtime setup, webhook processing, and the Apps UI. **Problem or motivation** Users need to connect GitHub once and let agents use the correct GitHub identity. A run should use a dedicated agent account when one exists. Otherwise, it should use the responsible person's account. The connection must survive token expiry, repository access changes, and temporary instance downtime. **Proposed solution** Add user-owned and agent-owned GitHub grants to the existing connection model. Resolve one identity for MCP, Git, `gh`, health checks, and webhook bindings. Store provider tokens in the existing encrypted secret system. Refresh expiring token pairs under the existing lease and compare-and-swap path. Register signed Cloud webhook bindings and process normalized pull request and installation events through a durable local inbox. **Alternatives considered** An organization-wide GitHub token would lose person and agent attribution. Environment variables alone would bypass the managed connection and grant model. A new GitHub-only credential store would duplicate the existing secret and access systems. GitHub App installation tokens and private-key custody remain outside this first version. **Roadmap alignment** This change implements the Connected Apps direction. It also extends the shipped MCP Tool Gateway, per-agent secret access, and action-attribution systems. It does not add a repository catalog. The open repository catalog work in [#11234](https://github.com/paperclipai/paperclip/pull/11234) is related and complementary. ## What Changed - Added agent-owned connection grants and a per-agent credential policy with company and subject constraints. - Added a managed GitHub App method while keeping the personal access token method as an advanced fallback. - Added durable access-token and refresh-token handling with proactive rotation and one automatic recovery after a provider `401`. - Added GitHub identity and installation summaries without storing repository-name lists. - Added signed Cloud webhook binding, event lease, acknowledgement, local idempotency, pull request merge processing, and installation access handling. - Added one identity resolver for MCP, native Git, `gh`, checkout, health checks, and webhook bindings. - Added a class-3 run projection for `GH_TOKEN`, `GITHUB_TOKEN`, a `github.com`-only credential helper, SSH-to-HTTPS rewrite, and GitHub noreply commit attribution. - Added personal and dedicated-agent setup choices plus identity, repository, continuity, and webhook status in the Apps UI. - Added schema migrations, tests, and connection documentation. ## Verification - The current head is fully green in GitHub CI, including build, typecheck, all serialized/general server shards, all browser shards, policy, canary dry run, review, and security checks. - Live staging proof completed with a non-expiring GitHub App user token, selected-repository installation, repository add/remove refresh, managed MCP, native `gh`, HTTPS clone/push/delete, GitHub noreply commit attribution, signed merged-PR webhook acceptance, durable Cloud-to-instance delivery, and installation-access event processing. Temporary branches and temporary repository access were removed afterward. - `pnpm check:token-gates` passed. - `pnpm -r typecheck` passed before and after the rebase onto `origin/master`. - `pnpm build` passed. - The focused connector suite passed 285 tests after the rebase. - The full stable suite passed 5,790 tests and failed 22 tests across 8 general server files. The failures reproduced as shared-runner environment issues. They included `/tmp` versus `/private/tmp`, closed database connections, and invalid high ephemeral ports. The focused connection tests pass in isolation. ## Risks - Migrations add agent grant subjects and a durable connection-event inbox. Migration numbering and safety checks pass. - A raw GitHub user token enters the agent process for Git and `gh`. Per-tool Ask-first controls cannot limit those shell operations. The UI warns users about this boundary. - GitHub App user tokens can be non-expiring. Paperclip performs a continuity check every 30 days, but provider revocation still requires a reconnect. - The webhook path accepts only signed and bounded payloads. It stores a minimal normalized record and no raw provider payload. - GitHub repository permissions remain authoritative. Removed access can make a cached repository count temporarily stale, but runtime access fails immediately. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, `gpt-5.6-sol`, extended reasoning, tool use, code execution, browser control, and multi-file repository editing. The context window size was not provided. ## 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 - [ ] 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
447 lines
15 KiB
TypeScript
447 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type {
|
|
ToolCatalogEntry,
|
|
ToolPolicy,
|
|
ToolProfileEffectiveSummary,
|
|
} from "@paperclipai/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createElement } from "react";
|
|
|
|
const mockToolsApi = vi.hoisted(() => ({
|
|
getEffectiveProfilesForAgent: vi.fn(),
|
|
listConnections: vi.fn(),
|
|
listPolicies: vi.fn(),
|
|
listCatalog: vi.fn(),
|
|
listConnectionGrants: vi.fn(),
|
|
listAudit: vi.fn(),
|
|
putConnectionInstalls: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../api/tools", () => ({ toolsApi: mockToolsApi }));
|
|
|
|
// Render the company-aware Link as a plain anchor so we don't need a Router.
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({ to, children, ...rest }: { to: string; children: unknown }) =>
|
|
createElement("a", { href: to, ...rest }, children as never),
|
|
}));
|
|
|
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
}
|
|
|
|
async function flushReact(cycles = 4) {
|
|
// Multiple cycles let dependent query waves settle (connections → per-connection catalog).
|
|
for (let i = 0; i < cycles; i += 1) {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
}
|
|
|
|
function makeCatalogEntry(overrides: Partial<ToolCatalogEntry>): ToolCatalogEntry {
|
|
return {
|
|
id: "cat-1",
|
|
companyId: "company-1",
|
|
applicationId: "app-1",
|
|
connectionId: "conn-1",
|
|
entryKind: "tool",
|
|
toolName: "github.read_repo",
|
|
title: "Read repo",
|
|
description: null,
|
|
inputSchema: null,
|
|
outputSchema: null,
|
|
annotations: null,
|
|
riskLevel: "read",
|
|
isReadOnly: true,
|
|
isWrite: false,
|
|
isDestructive: false,
|
|
status: "active",
|
|
addedAt: new Date("2026-06-01T00:00:00Z"),
|
|
version: null,
|
|
schemaHash: null,
|
|
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
|
|
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
|
|
reviewedAt: null,
|
|
reviewedByAgentId: null,
|
|
reviewedByUserId: null,
|
|
createdAt: new Date("2026-06-01T00:00:00Z"),
|
|
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makePolicy(overrides: Partial<ToolPolicy>): ToolPolicy {
|
|
return {
|
|
id: "pol-1",
|
|
companyId: "company-1",
|
|
name: "Require approval for writes",
|
|
description: "All write tools need board approval",
|
|
policyType: "require_approval",
|
|
priority: 100,
|
|
enabled: true,
|
|
selectors: {},
|
|
conditions: null,
|
|
config: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
createdAt: new Date("2026-06-01T00:00:00Z"),
|
|
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("AgentToolsTab", () => {
|
|
let container: HTMLDivElement;
|
|
let root: ReturnType<typeof createRoot>;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
mockToolsApi.getEffectiveProfilesForAgent.mockReset();
|
|
mockToolsApi.listConnections.mockReset();
|
|
mockToolsApi.listPolicies.mockReset();
|
|
mockToolsApi.listCatalog.mockReset();
|
|
mockToolsApi.listConnectionGrants.mockReset();
|
|
mockToolsApi.putConnectionInstalls.mockReset();
|
|
mockToolsApi.listConnectionGrants.mockResolvedValue({
|
|
connection: { id: "conn-1", uid: "conn-1" },
|
|
grants: [],
|
|
currentUserId: "user-1",
|
|
members: [],
|
|
capabilities: {},
|
|
});
|
|
mockToolsApi.putConnectionInstalls.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => {
|
|
root?.unmount();
|
|
});
|
|
container.remove();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
async function renderTab() {
|
|
const { AgentToolsTab } = await import("./AgentToolsTab");
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
const agent = { id: "agent-1", name: "Coder" } as never;
|
|
await act(async () => {
|
|
root = createRoot(container);
|
|
root.render(
|
|
createElement(
|
|
QueryClientProvider,
|
|
{ client },
|
|
createElement(AgentToolsTab, { agent, companyId: "company-1" }),
|
|
),
|
|
);
|
|
});
|
|
await flushReact();
|
|
}
|
|
|
|
it("preserves checkbox changes made after the last saved install state", async () => {
|
|
const { mergeInstallDraft } = await import("./AgentToolsTab");
|
|
|
|
expect(
|
|
mergeInstallDraft(
|
|
{ "conn-1": true, "conn-2": false },
|
|
{ "conn-1": true, "conn-2": true },
|
|
{ "conn-1": false, "conn-2": false },
|
|
),
|
|
).toEqual({
|
|
draft: { "conn-1": true, "conn-2": true },
|
|
hasPendingChanges: true,
|
|
});
|
|
});
|
|
|
|
it("renders effective access, access profiles, governing policy, and unavailable tools", async () => {
|
|
const allowed = makeCatalogEntry({ id: "cat-allow", toolName: "github.read_repo" });
|
|
const denied = makeCatalogEntry({
|
|
id: "cat-deny",
|
|
toolName: "github.delete_repo",
|
|
riskLevel: "critical",
|
|
isReadOnly: false,
|
|
isWrite: true,
|
|
isDestructive: true,
|
|
});
|
|
|
|
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
|
|
agentId: "agent-1",
|
|
profiles: [
|
|
{
|
|
id: "prof-1",
|
|
companyId: "company-1",
|
|
profileKey: "github-safe",
|
|
name: "GitHub safe",
|
|
description: null,
|
|
status: "active",
|
|
defaultAction: "deny",
|
|
newToolsReviewedAt: null,
|
|
metadata: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
entries: [],
|
|
bindings: [],
|
|
summary: {
|
|
accessMode: "selected",
|
|
allowedToolCount: 1,
|
|
allowedApplicationCount: 1,
|
|
excludedToolCount: 0,
|
|
totalToolCount: 1,
|
|
assignmentCount: 1,
|
|
appliesToAgentCount: 1,
|
|
isCompanyDefault: true,
|
|
},
|
|
},
|
|
],
|
|
entries: [],
|
|
bindings: [
|
|
{
|
|
id: "bind-1",
|
|
companyId: "company-1",
|
|
profileId: "prof-1",
|
|
targetType: "agent",
|
|
targetId: "agent-1",
|
|
priority: 100,
|
|
metadata: null,
|
|
createdByAgentId: null,
|
|
createdByUserId: null,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
},
|
|
],
|
|
allowedTools: [allowed],
|
|
allowedToolNames: ["github.read_repo"],
|
|
installedConnections: [],
|
|
} satisfies ToolProfileEffectiveSummary);
|
|
|
|
mockToolsApi.listConnections.mockResolvedValue({
|
|
connections: [{ id: "conn-1", name: "Production GitHub" }],
|
|
});
|
|
mockToolsApi.listPolicies.mockResolvedValue({
|
|
policies: [
|
|
makePolicy({ id: "pol-1", name: "Require approval for writes" }),
|
|
makePolicy({
|
|
id: "pol-2",
|
|
name: "Block other agent",
|
|
enabled: true,
|
|
selectors: { agentId: "someone-else" },
|
|
}),
|
|
],
|
|
});
|
|
mockToolsApi.listCatalog.mockResolvedValue({ catalog: [allowed, denied] });
|
|
|
|
await renderTab();
|
|
|
|
const text = container.textContent ?? "";
|
|
expect(text).toContain("Effective access");
|
|
expect(text).toContain("github.read_repo");
|
|
expect(text).toContain("Production GitHub");
|
|
expect(text).toContain("GitHub safe");
|
|
expect(text).toContain("Access profiles");
|
|
expect(text).toContain("Organization default");
|
|
expect(container.querySelector('a[href="/apps/advanced/profiles/prof-1"]')?.textContent).toBe("GitHub safe");
|
|
expect(container.querySelector('a[href="/apps/advanced/profiles?check=1"]')?.textContent).toBe("Check access");
|
|
// Governing policy #1 is the company-wide require_approval rule.
|
|
expect(text).toContain("#1 Require approval for writes");
|
|
// The policy that targets a different agent must NOT appear.
|
|
expect(text).not.toContain("Block other agent");
|
|
// Unavailable tool surfaced from the full tool list minus allowed.
|
|
expect(text).toContain("github.delete_repo");
|
|
});
|
|
|
|
it("shows the empty allow-list message when no profile applies", async () => {
|
|
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
|
|
agentId: "agent-1",
|
|
profiles: [],
|
|
entries: [],
|
|
bindings: [],
|
|
allowedTools: [],
|
|
allowedToolNames: [],
|
|
installedConnections: [],
|
|
} satisfies ToolProfileEffectiveSummary);
|
|
mockToolsApi.listConnections.mockResolvedValue({ connections: [] });
|
|
mockToolsApi.listPolicies.mockResolvedValue({ policies: [] });
|
|
|
|
await renderTab();
|
|
|
|
const text = container.textContent ?? "";
|
|
expect(text).toContain("No tools are allowed for this agent");
|
|
expect(text).toContain("No active profile applies");
|
|
expect(text).toContain("Use responsible person's GitHub");
|
|
expect(container.querySelector('a[href="/apps/connect?source=github"]')?.textContent).toBe("Connect my GitHub");
|
|
});
|
|
|
|
it("shows an active dedicated GitHub identity as the agent override", async () => {
|
|
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
|
|
agentId: "agent-1",
|
|
profiles: [],
|
|
entries: [],
|
|
bindings: [],
|
|
allowedTools: [],
|
|
allowedToolNames: [],
|
|
installedConnections: [],
|
|
} satisfies ToolProfileEffectiveSummary);
|
|
mockToolsApi.listConnections.mockResolvedValue({
|
|
connections: [{
|
|
id: "conn-github",
|
|
companyId: "company-1",
|
|
name: "Agent GitHub",
|
|
enabled: true,
|
|
status: "active",
|
|
config: { sourceTemplateKey: "github" },
|
|
transportConfig: {},
|
|
installs: [{ targetType: "company", targetId: "company-1" }],
|
|
}],
|
|
});
|
|
mockToolsApi.listPolicies.mockResolvedValue({ policies: [] });
|
|
mockToolsApi.listCatalog.mockResolvedValue({ catalog: [] });
|
|
mockToolsApi.listConnectionGrants.mockResolvedValue({
|
|
connection: { id: "conn-github", uid: "conn-github" },
|
|
grants: [{
|
|
id: "grant-agent",
|
|
kind: "agent",
|
|
subjectAgentId: "agent-1",
|
|
subjectUserId: null,
|
|
status: "active",
|
|
providerTenant: {
|
|
github: {
|
|
userId: "123",
|
|
login: "dottabot",
|
|
installationCount: 1,
|
|
repositoryCount: 1,
|
|
repositorySelection: "selected",
|
|
installationIds: ["456"],
|
|
installationOwnerLogins: ["paperclipai"],
|
|
},
|
|
},
|
|
}],
|
|
currentUserId: "user-1",
|
|
members: [],
|
|
capabilities: {},
|
|
});
|
|
|
|
await renderTab();
|
|
|
|
const text = container.textContent ?? "";
|
|
expect(text).toContain("@dottabot");
|
|
expect(text).toContain("takes precedence over the responsible person's GitHub");
|
|
expect(container.querySelector('a[href="/apps/conn-github/permissions"]')?.textContent).toBe("Manage GitHub identity");
|
|
expect(text).not.toContain("Connect my GitHub");
|
|
});
|
|
|
|
it("does not display a grant from a disabled or uninstalled GitHub connection", async () => {
|
|
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
|
|
agentId: "agent-1",
|
|
profiles: [],
|
|
entries: [],
|
|
bindings: [],
|
|
allowedTools: [],
|
|
allowedToolNames: [],
|
|
installedConnections: [],
|
|
} satisfies ToolProfileEffectiveSummary);
|
|
mockToolsApi.listConnections.mockResolvedValue({
|
|
connections: [{
|
|
id: "conn-github",
|
|
companyId: "company-1",
|
|
name: "Disabled GitHub",
|
|
enabled: false,
|
|
status: "active",
|
|
config: { sourceTemplateKey: "github" },
|
|
transportConfig: {},
|
|
installs: [{ targetType: "company", targetId: "company-1" }],
|
|
}],
|
|
});
|
|
mockToolsApi.listPolicies.mockResolvedValue({ policies: [] });
|
|
mockToolsApi.listCatalog.mockResolvedValue({ catalog: [] });
|
|
|
|
await renderTab();
|
|
|
|
const text = container.textContent ?? "";
|
|
expect(mockToolsApi.listConnectionGrants).not.toHaveBeenCalled();
|
|
expect(text).toContain("Use responsible person's GitHub");
|
|
expect(text).not.toContain("@dottabot");
|
|
});
|
|
|
|
it("shows a retryable error instead of connection setup when grants cannot be loaded", async () => {
|
|
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
|
|
agentId: "agent-1",
|
|
profiles: [],
|
|
entries: [],
|
|
bindings: [],
|
|
allowedTools: [],
|
|
allowedToolNames: [],
|
|
installedConnections: [],
|
|
} satisfies ToolProfileEffectiveSummary);
|
|
mockToolsApi.listConnections.mockResolvedValue({
|
|
connections: [{
|
|
id: "conn-github",
|
|
companyId: "company-1",
|
|
name: "Agent GitHub",
|
|
enabled: true,
|
|
status: "active",
|
|
config: { sourceTemplateKey: "github" },
|
|
transportConfig: {},
|
|
installs: [{ targetType: "company", targetId: "company-1" }],
|
|
}],
|
|
});
|
|
mockToolsApi.listPolicies.mockResolvedValue({ policies: [] });
|
|
mockToolsApi.listCatalog.mockResolvedValue({ catalog: [] });
|
|
mockToolsApi.listConnectionGrants.mockRejectedValue(new Error("temporary failure"));
|
|
|
|
await renderTab();
|
|
|
|
const text = container.textContent ?? "";
|
|
expect(text).toContain("Could not load GitHub identity");
|
|
expect(text).toContain("Your existing setup was not changed");
|
|
expect(text).not.toContain("Connect my GitHub");
|
|
expect(Array.from(container.querySelectorAll("button")).some((button) => button.textContent === "Retry")).toBe(true);
|
|
});
|
|
|
|
it("autosaves installed apps for the current agent", async () => {
|
|
const allowed = makeCatalogEntry({ id: "cat-allow", toolName: "github.read_repo" });
|
|
mockToolsApi.getEffectiveProfilesForAgent.mockResolvedValue({
|
|
agentId: "agent-1",
|
|
profiles: [],
|
|
entries: [],
|
|
bindings: [],
|
|
allowedTools: [allowed],
|
|
allowedToolNames: ["github.read_repo"],
|
|
installedConnections: [],
|
|
} satisfies ToolProfileEffectiveSummary);
|
|
mockToolsApi.listConnections.mockResolvedValue({
|
|
connections: [{ id: "conn-1", companyId: "company-1", name: "Production GitHub", installs: [] }],
|
|
});
|
|
mockToolsApi.listPolicies.mockResolvedValue({ policies: [] });
|
|
mockToolsApi.listCatalog.mockResolvedValue({ catalog: [allowed] });
|
|
|
|
await renderTab();
|
|
|
|
expect(container.textContent).toContain("Installed apps");
|
|
expect(container.textContent).toContain("Permitted only");
|
|
expect(container.textContent).toContain("Permitted but not installed — tools will not appear in runs.");
|
|
expect(container.querySelector('a[href="/apps/conn-1/permissions"]')?.textContent).toBe("Open permissions");
|
|
const installCheckbox = container.querySelector<HTMLElement>('[aria-label="Install Production GitHub on Coder"]');
|
|
expect(installCheckbox).toBeTruthy();
|
|
await act(async () => {
|
|
installCheckbox!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
await new Promise((resolve) => window.setTimeout(resolve, 300));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockToolsApi.putConnectionInstalls).toHaveBeenCalledWith("conn-1", [
|
|
{ targetType: "agent", targetId: "agent-1" },
|
|
]);
|
|
});
|
|
});
|