mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## 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
201 lines
6.1 KiB
TypeScript
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,
|
|
};
|
|
}
|