Files
PaperClipAI/server/src/services/github-operation-credentials.ts
T
Jamie Buchanan 3339582810 fix(adapter-utils): stop asking the GitHub broker on every local git call (#17886)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs use `git` and `gh` through the managed GitHub launcher.
> - The launcher asks the broker for credentials before every `git` and
`gh` call. It also asks for local commands that do not use the network,
such as `git status`.
> - Each request runs a transaction and an UPDATE in the database. On a
busy install this gives hundreds of thousands of writes each day and
long COMMIT waits.
> - This pull request stops the request for local `git` commands and
writes the identity summary only when it changes.
> - The benefit is fewer broker requests and fewer database writes. The
credential rules do not change.

## Linked Issues or Issue Description

- Fixes #17879

## What Changed

- The managed `git` launcher does not ask the broker for local
subcommands (`status`, `diff`, `log`, `rev-parse`, `add`, `config`,
`branch`, `checkout` and others). It reads past global options such as
`-C` and `-c`. An unknown subcommand or option still asks. `commit` and
`var` still ask, because the managed author comes from the broker
answer.
- The launcher keeps no broker answer between invocations, so no GitHub
credential is written to a file. The suggestion in the issue to reuse
one answer per run is not part of this pull request, because the reused
answer would have to rest on disk.
- `resolveGitHubOperationCredentials` writes
`run_identity_contexts.github` only when the summary changes.
- `doc/execution-github-identity.md` describes the behavior.

## Verification

- New launcher tests run real `git` against a fake broker that counts
POSTs. A 12-command sequence (init, status, config, rev-parse, add,
diff, commit, log, branch, show, status, diff) made 12 POSTs before and
1 POST after. The one POST is `commit`.
- The same tests check that `var`, `remote`, `fetch`, an alias, and an
unknown option still ask, and that `git var GIT_AUTHOR_IDENT` returns
the managed author.
- A new test covers the unchanged-summary write.
- `vitest run` on the launcher, launcher environment, callback bridge,
github-operation-credentials, run-identity, heartbeat-github-launchers,
native-github-access, openapi-routes, run-trust-preset,
http-log-redaction, skill-source-github-access and chat-channels tests:
12 files, 1901 passed, 1 skipped.
- `vitest run --project @paperclipai/adapter-utils` on `src`: 1903
passed, 12 skipped, 1 failed. The failure is
`sandbox-callback-bridge.test.ts`, a file this PR does not change. It
passes alone (89 of 89, three runs).
- `tsc --noEmit` passes in `packages/adapter-utils` and in `server`.
- Three deliberate breakages each made a test fail: `var` back in the
local list, the broker always asked, and the summary write skip removed.
- Not run: the full server suite and `pnpm build` (the local machine has
no `cargo` for the runner binary).

## Risks

- In a partial clone or an LFS repository, a local command that must
fetch a missing object runs without a token.
- A `git` command that is not local (`commit`, `fetch`, `push`) and
every `gh` command still asks the broker once.
- No migration. No credential is stored at rest.

## Model Used

- Claude Sonnet 5.5 (`claude-sonnet-5-5`, Anthropic) through Claude
Code, with tool use (shell, file edits, test runs), wrote the code and
tests.
- Claude Opus 5.5 (`claude-opus-5-5`, Anthropic) through Claude Code
wrote the issue text and this description.
- A person asked for the change and submits it.

## 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
2026-10-28 09:06:14 -05:00

201 lines
6.1 KiB
TypeScript

import { isDeepStrictEqual } from "node:util";
import { and, eq } from "drizzle-orm";
import { isUuidLike } from "@paperclipai/shared";
import {
agents,
heartbeatRuns,
issues,
projects,
runIdentityContexts,
type Db,
} from "@paperclipai/db";
import { forbidden } from "../errors.js";
import { captureRunIdentity, type RunIdentityContext } from "./run-identity.js";
import {
buildGitAuthInvocation,
resolveManagedGitHubCredential,
} from "./git-credentials.js";
import { secretService } from "./secrets.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { isLowTrustQuarantined } from "./source-trust.js";
export type GitHubCredentialSummary = {
status: "available" | "absent" | "unavailable";
source?: "personal" | "dedicated";
login?: string;
reason?: string;
connectionId?: string;
grantId?: string;
authenticationMode?: "managed" | "host" | "anonymous";
};
/** A raw GitHub token cannot enforce the low-trust read-only tool boundary. */
async function allowsGitHubCredentialExport(
db: Db,
run: typeof heartbeatRuns.$inferSelect,
) {
const issueId =
run.contextSnapshot?.issueId ??
run.contextSnapshot?.taskId ??
run.nativeIssueId;
if (
issueId !== undefined &&
issueId !== null &&
(typeof issueId !== "string" || !isUuidLike(issueId))
)
return false;
const [agent] = await db
.select({ companyId: agents.companyId, permissions: agents.permissions })
.from(agents)
.where(
and(eq(agents.id, run.agentId), eq(agents.companyId, run.companyId)),
);
if (!agent) return false;
const [issue] =
typeof issueId === "string"
? await db
.select({
companyId: issues.companyId,
projectId: issues.projectId,
executionPolicy: issues.executionPolicy,
sourceTrust: issues.sourceTrust,
})
.from(issues)
.where(
and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)),
)
: [];
if (issueId !== undefined && issueId !== null && !issue) return false;
if (isLowTrustQuarantined(issue?.sourceTrust)) return false;
const projectId = issue?.projectId ?? run.contextSnapshot?.projectId;
if (
projectId !== undefined &&
projectId !== null &&
(typeof projectId !== "string" || !isUuidLike(projectId))
)
return false;
const [project] =
typeof projectId === "string"
? await db
.select({
companyId: projects.companyId,
executionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(projects)
.where(
and(
eq(projects.id, projectId),
eq(projects.companyId, run.companyId),
),
)
: [];
if (projectId !== undefined && projectId !== null && !project) return false;
return (
resolveCoreTrustPreset({
companyId: run.companyId,
agent,
project,
issue,
run: {
companyId: run.companyId,
executionPolicy: run.contextSnapshot?.executionPolicy,
},
}).kind === "standard"
);
}
async function saveGitHubSummary(
db: Db,
context: RunIdentityContext,
summary: GitHubCredentialSummary,
) {
if (isDeepStrictEqual(context.github, JSON.parse(JSON.stringify(summary)))) return;
await db
.update(runIdentityContexts)
.set({ github: summary })
.where(eq(runIdentityContexts.id, context.id));
}
/** No company secrets or ambient credentials are consulted by this path. */
export async function resolveGitHubOperationCredentials(
db: Db,
input: {
companyId: string;
agentId: string;
runId: string;
},
) {
const { run, context } = await captureRunIdentity(db, input, { readScope: "github_credentials" });
if (!context) throw forbidden("This run predates managed GitHub credentials");
let summary: GitHubCredentialSummary;
let env: Record<string, string> = {};
// A sponsored guest's responsible person is an internal accountability field,
// not authorization to export that person's (or a dedicated bot's) token.
// Re-read every policy source for each operation, including a run's retained
// boundary after task policy edits. Deny before touching the credential store.
if (!(await allowsGitHubCredentialExport(db, run))) {
summary = {
status: "unavailable",
reason:
"GitHub credentials are not available to low-trust or unverified executions; use authorized read-only tools.",
};
await saveGitHubSummary(db, context, summary);
return {
identityContextId: context.id,
revision: context.revision,
...summary,
env,
};
}
try {
const resolved = await resolveManagedGitHubCredential(
db,
secretService(db),
input.companyId,
{
agentId: input.agentId,
heartbeatRunId: input.runId,
allowStandingDelegation: false,
responsibleUserId:
context?.cause === "company_default"
? null
: (context?.responsibleUserId ?? null),
issueId:
typeof run.contextSnapshot?.issueId === "string"
? run.contextSnapshot.issueId
: null,
},
);
if (resolved.credential) {
summary = {
status: "available",
source: resolved.credential.identitySource,
login: resolved.credential.githubIdentity?.login,
connectionId: resolved.credential.connectionId,
grantId: resolved.credential.grantId,
authenticationMode: "managed",
};
env = buildGitAuthInvocation(resolved.credential).env;
} else {
summary = {
status: resolved.configured ? "unavailable" : "absent",
source: resolved.identitySource ?? "personal",
reason: resolved.error ?? "No GitHub identity connected",
};
}
} catch {
// Provider/secret errors can contain sensitive response bodies. Never persist them.
summary = {
status: "unavailable",
reason: "GitHub credentials are temporarily unavailable",
};
}
if (context) await saveGitHubSummary(db, context, summary);
return {
identityContextId: context?.id ?? null,
revision: context?.revision ?? null,
...summary,
env,
};
}