mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 16:35:27 +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>
360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
DEFAULT_GITHUB_TOKEN_SECRET_NAMES,
|
|
GIT_CREDENTIAL_TOKEN_ENV_KEY,
|
|
buildGitAuthInvocation,
|
|
createGitRemoteAuthProvider,
|
|
describeGitAuthFailure,
|
|
isGitHubHttpsRemoteUrl,
|
|
scrubGitCredentialText,
|
|
} from "../services/git-credentials.ts";
|
|
|
|
const fakeDb = null as unknown as Db;
|
|
|
|
function buildSecretsFake(byName: Record<string, string | Error>) {
|
|
const getByName = vi.fn(async (_companyId: string, name: string) => {
|
|
if (!(name in byName)) return null;
|
|
return { id: `secret-${name}` };
|
|
});
|
|
const resolveSecretValue = vi.fn(async (_companyId: string, secretId: string) => {
|
|
const name = secretId.replace(/^secret-/, "");
|
|
const value = byName[name];
|
|
if (value instanceof Error) throw value;
|
|
return value ?? "";
|
|
});
|
|
return { getByName, resolveSecretValue };
|
|
}
|
|
|
|
describe("isGitHubHttpsRemoteUrl", () => {
|
|
it("accepts https github.com and www.github.com URLs", () => {
|
|
expect(isGitHubHttpsRemoteUrl("https://github.com/example/repo.git")).toBe(true);
|
|
expect(isGitHubHttpsRemoteUrl("https://www.github.com/example/repo.git")).toBe(true);
|
|
});
|
|
|
|
it("rejects ssh, http, enterprise hosts, other providers, userinfo URLs, and non-URLs", () => {
|
|
expect(isGitHubHttpsRemoteUrl("git@github.com:example/repo.git")).toBe(false);
|
|
expect(isGitHubHttpsRemoteUrl("ssh://git@github.com/example/repo.git")).toBe(false);
|
|
expect(isGitHubHttpsRemoteUrl("http://github.com/example/repo.git")).toBe(false);
|
|
expect(isGitHubHttpsRemoteUrl("https://github.enterprise.example/org/repo.git")).toBe(false);
|
|
expect(isGitHubHttpsRemoteUrl("https://gitlab.com/example/repo.git")).toBe(false);
|
|
expect(isGitHubHttpsRemoteUrl("https://alice:token@github.com/example/repo.git")).toBe(false);
|
|
expect(isGitHubHttpsRemoteUrl("/local/path/repo.git")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("createGitRemoteAuthProvider", () => {
|
|
const githubUrl = "https://github.com/example/repo.git";
|
|
|
|
it("prefers company secrets in declared order", async () => {
|
|
const secrets = buildSecretsFake({ GH_TOKEN: "gh-token", PAPERCLIP_GITHUB_TOKEN: "pc-token" });
|
|
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
|
|
secrets,
|
|
env: { GITHUB_TOKEN: "env-token" },
|
|
});
|
|
const invocation = await provider(githubUrl);
|
|
expect(invocation?.env[GIT_CREDENTIAL_TOKEN_ENV_KEY]).toBe("gh-token");
|
|
expect(invocation?.source).toBe("company_secret");
|
|
expect(invocation?.secretName).toBe("GH_TOKEN");
|
|
// GITHUB_TOKEN is probed first even though only GH_TOKEN exists.
|
|
expect(secrets.getByName.mock.calls.map((call) => call[1])).toEqual(["GITHUB_TOKEN", "GH_TOKEN"]);
|
|
});
|
|
|
|
it("falls back to the server env, GITHUB_TOKEN before GH_TOKEN", async () => {
|
|
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
|
|
secrets: buildSecretsFake({}),
|
|
env: { GITHUB_TOKEN: "env-github", GH_TOKEN: "env-gh" },
|
|
});
|
|
const invocation = await provider(githubUrl);
|
|
expect(invocation?.env[GIT_CREDENTIAL_TOKEN_ENV_KEY]).toBe("env-github");
|
|
expect(invocation?.source).toBe("server_env");
|
|
expect(invocation?.secretName).toBeNull();
|
|
});
|
|
|
|
it("returns null when no token is available anywhere", async () => {
|
|
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
|
|
secrets: buildSecretsFake({}),
|
|
env: {},
|
|
});
|
|
await expect(provider(githubUrl)).resolves.toBeNull();
|
|
});
|
|
|
|
it("accepts GitHub SSH remotes for process-scoped HTTPS rewriting", async () => {
|
|
const secrets = buildSecretsFake({ GITHUB_TOKEN: "token" });
|
|
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
|
|
secrets,
|
|
env: {},
|
|
});
|
|
const invocation = await provider("git@github.com:example/repo.git");
|
|
expect(invocation?.env.GIT_CONFIG_VALUE_3).toBe("git@github.com:");
|
|
expect(invocation?.env.GIT_CONFIG_KEY_3).toBe("url.https://github.com/.insteadOf");
|
|
});
|
|
|
|
it("returns null for non-GitHub URLs without touching the secret store", async () => {
|
|
const secrets = buildSecretsFake({ GITHUB_TOKEN: "token" });
|
|
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
|
|
secrets,
|
|
env: {},
|
|
});
|
|
await expect(provider("https://gitlab.com/example/repo.git")).resolves.toBeNull();
|
|
expect(secrets.getByName).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("memoizes the credential lookup across calls", async () => {
|
|
const secrets = buildSecretsFake({ GITHUB_TOKEN: "token" });
|
|
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
|
|
secrets,
|
|
env: {},
|
|
});
|
|
await provider(githubUrl);
|
|
await provider(githubUrl);
|
|
await provider("https://github.com/example/another.git");
|
|
expect(secrets.getByName).toHaveBeenCalledTimes(1);
|
|
expect(secrets.resolveSecretValue).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("passes a system access context so resolution is audited", async () => {
|
|
const secrets = buildSecretsFake({ GITHUB_TOKEN: "token" });
|
|
const provider = createGitRemoteAuthProvider(
|
|
fakeDb,
|
|
"company-1",
|
|
{ issueId: "issue-1", heartbeatRunId: "run-1" },
|
|
{ secrets, env: {} },
|
|
);
|
|
await provider(githubUrl);
|
|
expect(secrets.resolveSecretValue).toHaveBeenCalledWith("company-1", "secret-GITHUB_TOKEN", "latest", {
|
|
accessContext: expect.objectContaining({
|
|
consumerType: "system",
|
|
consumerId: "workspace-git-credential",
|
|
actorType: "system",
|
|
issueId: "issue-1",
|
|
heartbeatRunId: "run-1",
|
|
}),
|
|
});
|
|
});
|
|
|
|
it("continues down the chain when one secret fails to resolve", async () => {
|
|
const secrets = buildSecretsFake({
|
|
GITHUB_TOKEN: new Error("provider outage"),
|
|
GH_TOKEN: "gh-token",
|
|
});
|
|
const provider = createGitRemoteAuthProvider(fakeDb, "company-1", undefined, {
|
|
secrets,
|
|
env: {},
|
|
});
|
|
const invocation = await provider(githubUrl);
|
|
expect(invocation?.secretName).toBe("GH_TOKEN");
|
|
});
|
|
|
|
it("ignores a managed connection installed only for another agent", async () => {
|
|
const query = (rows: unknown[]) => ({
|
|
from: () => ({ where: async () => rows }),
|
|
});
|
|
const db = {
|
|
select: vi.fn()
|
|
.mockReturnValueOnce(query([{
|
|
id: "github-connection",
|
|
companyId: "company-1",
|
|
enabled: true,
|
|
status: "active",
|
|
config: { sourceTemplateKey: "github" },
|
|
}]))
|
|
.mockReturnValueOnce(query([{
|
|
connectionId: "github-connection",
|
|
companyId: "company-1",
|
|
targetType: "agent",
|
|
targetId: "agent-a",
|
|
}])),
|
|
} as unknown as Db;
|
|
const secrets = buildSecretsFake({ GH_TOKEN: "agent-b-legacy-token" });
|
|
const provider = createGitRemoteAuthProvider(db, "company-1", { agentId: "agent-b" }, {
|
|
secrets,
|
|
env: {},
|
|
});
|
|
|
|
const invocation = await provider(githubUrl);
|
|
|
|
expect(invocation?.source).toBe("company_secret");
|
|
expect(invocation?.secretName).toBe("GH_TOKEN");
|
|
expect(invocation?.env[GIT_CREDENTIAL_TOKEN_ENV_KEY]).toBe("agent-b-legacy-token");
|
|
expect(db.select).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("buildGitAuthInvocation", () => {
|
|
it("keeps the token out of argv and installs the helper URL-scoped to github.com", () => {
|
|
const invocation = buildGitAuthInvocation({
|
|
token: "super-secret-token",
|
|
source: "company_secret",
|
|
secretName: "GITHUB_TOKEN",
|
|
});
|
|
expect(invocation.configArgs.join(" ")).not.toContain("super-secret-token");
|
|
expect(invocation.configArgs[0]).toBe("-c");
|
|
expect(invocation.configArgs[1]).toBe("credential.helper=");
|
|
expect(invocation.configArgs[3]).toContain("credential.https://github.com.helper=");
|
|
expect(invocation.configArgs[3]).toContain("x-access-token");
|
|
expect(invocation.configArgs[5]).toContain("credential.https://www.github.com.helper=");
|
|
expect(invocation.env[GIT_CREDENTIAL_TOKEN_ENV_KEY]).toBe("super-secret-token");
|
|
expect(invocation.env.GH_TOKEN).toBe("super-secret-token");
|
|
expect(invocation.env.GITHUB_TOKEN).toBe("super-secret-token");
|
|
expect(invocation.env.GIT_TERMINAL_PROMPT).toBe("0");
|
|
expect(invocation.env).not.toHaveProperty("HOME");
|
|
});
|
|
|
|
it("sets GitHub's stable noreply commit identity without exposing the token in config", () => {
|
|
const invocation = buildGitAuthInvocation({
|
|
token: "super-secret-token",
|
|
source: "managed_connection",
|
|
secretName: null,
|
|
githubIdentity: { userId: "12345", login: "octocat" },
|
|
});
|
|
expect(invocation.env.GIT_CONFIG_KEY_7).toBe("user.name");
|
|
expect(invocation.env.GIT_CONFIG_VALUE_7).toBe("octocat");
|
|
expect(invocation.env.GIT_CONFIG_KEY_8).toBe("user.email");
|
|
expect(invocation.env.GIT_CONFIG_VALUE_8).toBe("12345+octocat@users.noreply.github.com");
|
|
expect(invocation.env.GIT_AUTHOR_NAME).toBe("octocat");
|
|
expect(invocation.env.GIT_AUTHOR_EMAIL).toBe("12345+octocat@users.noreply.github.com");
|
|
expect(invocation.env.GIT_COMMITTER_NAME).toBe("octocat");
|
|
expect(invocation.env.GIT_COMMITTER_EMAIL).toBe("12345+octocat@users.noreply.github.com");
|
|
expect(Object.values(invocation.env).filter((value) => value.includes("super-secret-token"))).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe("credential helper execution (real git, no network)", () => {
|
|
async function runCredentialFill(description: string) {
|
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-git-cred-fill-"));
|
|
try {
|
|
const invocation = buildGitAuthInvocation({
|
|
token: "abc123",
|
|
source: "company_secret",
|
|
secretName: "GITHUB_TOKEN",
|
|
});
|
|
return await new Promise<{ code: number | null; stdout: string; stderr: string }>(
|
|
(resolve, reject) => {
|
|
const child = spawn("git", [...invocation.configArgs, "credential", "fill"], {
|
|
cwd,
|
|
env: { ...process.env, ...invocation.env },
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
|
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
child.on("error", reject);
|
|
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
child.stdin.write(description);
|
|
child.stdin.end();
|
|
},
|
|
);
|
|
} finally {
|
|
await fs.rm(cwd, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
it("answers a github.com https request with the env-carried token", async () => {
|
|
const result = await runCredentialFill("protocol=https\nhost=github.com\n\n");
|
|
expect(result.code).toBe(0);
|
|
expect(result.stdout).toContain("username=x-access-token");
|
|
expect(result.stdout).toContain("password=abc123");
|
|
});
|
|
|
|
it("never hands the token to another host, even if git asks", async () => {
|
|
// Simulates a request whose effective host changed after our pre-invocation URL check
|
|
// (for example a repository-local url.<base>.insteadOf rewrite): the URL-scoped helper
|
|
// config keeps git from consulting the helper, prompts are disabled, so the fill fails
|
|
// and the token is never emitted.
|
|
const result = await runCredentialFill("protocol=https\nhost=evil.example\n\n");
|
|
expect(result.code).not.toBe(0);
|
|
expect(result.stdout).not.toContain("abc123");
|
|
});
|
|
|
|
it("never answers plain-http requests for github.com", async () => {
|
|
const result = await runCredentialFill("protocol=http\nhost=github.com\n\n");
|
|
expect(result.code).not.toBe(0);
|
|
expect(result.stdout).not.toContain("abc123");
|
|
});
|
|
});
|
|
|
|
describe("scrubGitCredentialText", () => {
|
|
it("masks URL userinfo", () => {
|
|
expect(scrubGitCredentialText("https://x-access-token:ghp_secret@github.com/a/b.git")).toBe(
|
|
"https://***@github.com/a/b.git",
|
|
);
|
|
});
|
|
|
|
it("masks userinfo on non-HTTP schemes, leaving scp-style remotes alone", () => {
|
|
expect(scrubGitCredentialText("ssh://deploy:hunter2@internal.example/repo.git")).toBe(
|
|
"ssh://***@internal.example/repo.git",
|
|
);
|
|
expect(scrubGitCredentialText("git@github.com:example/repo.git")).toBe(
|
|
"git@github.com:example/repo.git",
|
|
);
|
|
});
|
|
|
|
it("masks entire URL query strings regardless of parameter names", () => {
|
|
expect(scrubGitCredentialText("https://github.com/a/b.git?access_token=ghs_secret&ref=main")).toBe(
|
|
"https://github.com/a/b.git?***",
|
|
);
|
|
expect(scrubGitCredentialText("https://host.example/r.git?obscure_cred_name=secret")).toBe(
|
|
"https://host.example/r.git?***",
|
|
);
|
|
});
|
|
|
|
it("leaves credential-free text unchanged", () => {
|
|
expect(scrubGitCredentialText("fatal: repository not found")).toBe("fatal: repository not found");
|
|
});
|
|
});
|
|
|
|
describe("describeGitAuthFailure", () => {
|
|
it("names the company secret when a stored credential was used", () => {
|
|
expect(describeGitAuthFailure({
|
|
error: "fatal: Authentication failed",
|
|
used: { source: "company_secret", secretName: "GH_TOKEN" },
|
|
})).toContain("the GH_TOKEN company-secret GitHub credential");
|
|
});
|
|
|
|
it("names the server environment when an env credential was used", () => {
|
|
expect(describeGitAuthFailure({
|
|
error: "fatal: Authentication failed",
|
|
used: { source: "server_env", secretName: null },
|
|
})).toContain("server-environment GitHub credential");
|
|
});
|
|
|
|
it("points at Settings → Secrets for auth-looking failures without a credential", () => {
|
|
expect(describeGitAuthFailure({
|
|
error: "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
|
|
used: null,
|
|
})).toContain("add a GITHUB_TOKEN or GH_TOKEN company secret");
|
|
});
|
|
|
|
it("stays silent for non-auth failures without a credential", () => {
|
|
expect(describeGitAuthFailure({
|
|
error: "fatal: unable to resolve host example.invalid",
|
|
used: null,
|
|
})).toBeNull();
|
|
});
|
|
|
|
it("stays silent for non-auth failures even when a credential was used", () => {
|
|
// A credential present during an unrelated failure (network outage, target-path
|
|
// collision) must not be blamed for it.
|
|
expect(describeGitAuthFailure({
|
|
error: "fatal: destination path '/x/y' already exists and is not an empty directory.",
|
|
used: { source: "company_secret", secretName: "GH_TOKEN" },
|
|
})).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("DEFAULT_GITHUB_TOKEN_SECRET_NAMES", () => {
|
|
it("keeps the shared name order stable", () => {
|
|
expect([...DEFAULT_GITHUB_TOKEN_SECRET_NAMES]).toEqual([
|
|
"GITHUB_TOKEN",
|
|
"GH_TOKEN",
|
|
"PAPERCLIP_GITHUB_TOKEN",
|
|
]);
|
|
});
|
|
});
|