Files
PaperClipAI/server/src/services/git-credentials.ts
T
Devin Foley 2c90cf0f2c fix(server): serialize managed-checkout materialization and stop misattributing clone failures (#10723)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Repo-only project workspaces are materialized by a server-side
managed `git clone` into a per-project directory (#10720 added
credentials for private repos)
> - Two issues on the same project routinely wake seconds apart, and
both runs race the same clone target
> - The loser fails with "destination path already exists", and its
failure cleanup removes the directory out from under the winner's
in-progress clone — both runs then fail every round
> - This pull request serializes materialization per target directory
and makes the clone land atomically via a temp sibling + rename, so the
shared target is never partial and never removed
> - The benefit is that concurrent runs on the same project converge:
one clone happens, everyone adopts it, and unrelated failures no longer
blame the GitHub credential

## Linked Issues or Issue Description

**What happened?**

With isolated workspaces enabled on a project whose only workspace is
repo-only, unblocking two issues at once produced lockstep mutual
destruction (observed live, two consecutive rounds): both runs called
the managed-checkout materialization concurrently; one clone created the
target directory, the other's `git clone` failed with `fatal:
destination path '…' already exists and is not an empty directory`, and
that run's failure cleanup deleted the directory while the first clone
was still writing into it (`fatal: could not set 'core…'`). Both runs
failed `workspace_validation_failed`; their staggered retries could race
again. The failure message also wrongly claimed the GitHub credential
"was rejected or lacks access" — the collision had nothing to do with
auth.

**Expected behavior**

Concurrent materializations of the same project checkout share one
clone; a completed checkout is never removed by a failing sibling; the
credential is only blamed for auth-shaped failures.

**Steps to reproduce**

1. Project with a repo-only workspace (private repo, isolated workspaces
on).
2. Move two issues on that project to `todo` at the same time so both
runs start within seconds.
3. Both runs fail workspace validation with "destination path already
exists" / "could not set 'core…'" instead of one clone succeeding.

**Paperclip version or commit**

`master` (75f6256b76).

## What Changed

- `ensureManagedProjectWorkspace` serializes in-flight materializations
per target cwd (a module-level promise map): concurrent callers share
one attempt.
- The clone lands in a `<target>.clone-XXXXXX` temp sibling created with
`mkdtemp`, then moves into place with an atomic `rename`. Clone failure
removes only the temp directory; the shared target is never created
partially and never deleted. If the target appears between the emptiness
check and the rename (another process won), the completed checkout is
adopted instead of failing the run.
- `describeGitAuthFailure` attributes the GitHub credential only when
the error matches the auth-failure pattern; unrelated failures (path
collisions, network errors) no longer claim the token was rejected.

## Verification

- `cd server && npx vitest run
src/__tests__/heartbeat-managed-clone-credentials.test.ts
src/__tests__/git-credentials.test.ts
src/__tests__/heartbeat-workspace-session.test.ts` — 169 tests pass,
including new cases: concurrent materializations of the same checkout
succeed with one shared result and no temp litter; failed clones leave
neither target nor temp directories; an authenticated clone failing for
non-auth reasons does not blame the credential.
- `pnpm --filter @paperclipai/server typecheck` — clean.

## Risks

- Low risk. The serialization is in-process and keyed by exact target
path; the temp+rename pattern stays on the same filesystem (sibling
path) so the rename is atomic. Single-run behavior is byte-identical
apart from the temp-dir intermediate.
- The rename-conflict adoption path accepts a checkout another
materialization completed; the pre-existing `gitDirExists` adoption
semantics are unchanged.

## Model Used

Claude Fable 5 (`claude-fable-5`, extended thinking, agentic tool use
via Claude Code CLI).

## 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
- [ ] All Paperclip CI gates are green
- [ ] 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-08-02 22:14:58 -07:00

204 lines
9.2 KiB
TypeScript

import type { Db } from "@paperclipai/db";
import { isGitHubDotCom } from "./github-fetch.js";
import { secretService } from "./secrets.js";
/**
* Server-side git credentials for managed project checkouts and execution-workspace base
* refreshes. Operators store a GitHub token as a company secret under one of the well-known
* names below (the same convention the GitHub external-object provider reads); this module
* resolves it and turns it into a git invocation that authenticates clone/fetch against
* github.com over HTTPS without ever placing the token in argv, URLs, or on disk.
*
* The provider factory is deliberately the single seam for future credential sources (for
* example a brokered GitHub connection): swap the factory, keep every call site unchanged.
*/
/** Company-secret names probed for a GitHub token, in priority order. */
export const DEFAULT_GITHUB_TOKEN_SECRET_NAMES = ["GITHUB_TOKEN", "GH_TOKEN", "PAPERCLIP_GITHUB_TOKEN"] as const;
/** Env var the credential helper reads the token from; never appears in argv. */
export const GIT_CREDENTIAL_TOKEN_ENV_KEY = "PAPERCLIP_GIT_TOKEN";
// `!`-prefixed helpers run via `sh -c` with the credential action appended as "$1". Only the
// `get` action answers; store/erase drain stdin and exit 0 silently. `x-access-token`
// authenticates classic PATs, fine-grained PATs, and GitHub App installation tokens alike.
//
// The helper re-validates the credential request from its stdin description and answers only
// for `protocol=https` + `host=github.com`/`www.github.com`. The pre-invocation URL check
// runs before git applies configuration like repository-local `url.<base>.insteadOf`
// rewrites, so a rewritten remote could otherwise request the token for an arbitrary host.
// The helper is additionally installed URL-scoped (`credential.https://github.com.helper`)
// so git does not consult it for other hosts in the first place — two independent gates.
const GIT_CREDENTIAL_HELPER =
`!f() { ok=; proto=; while IFS= read -r l && [ -n "$l" ]; do case "$l" in host=github.com|host=www.github.com) ok=1;; protocol=https) proto=1;; esac; done; if [ "$1" = get ] && [ -n "$ok" ] && [ -n "$proto" ]; then printf 'username=x-access-token\\npassword=%s\\n' "$PAPERCLIP_GIT_TOKEN"; fi; }; f`;
export type GitCredential = {
token: string;
source: "company_secret" | "server_env";
/** The company-secret name the token came from; null for a server-environment token. */
secretName: string | null;
};
/** A prepared, credential-bearing git invocation: config args plus the env that carries the token. */
export type GitAuthInvocation = {
configArgs: string[];
env: Record<string, string>;
source: GitCredential["source"];
secretName: string | null;
};
/**
* Resolve auth for one remote URL. Returns null when the URL is out of scope (non-GitHub,
* ssh, or already credentialed) or when no token is available — callers then run git with
* ambient behavior, exactly as before this module existed.
*/
export type GitRemoteAuthProvider = (remoteUrl: string) => Promise<GitAuthInvocation | null>;
/**
* True only for `https://github.com/...` (or `www.`) URLs without inline userinfo. GHES and
* other hosts are out of scope for now — sending a github.com token to an arbitrary host
* would leak it, and an operator's inline URL credential must never be overridden.
*/
export function isGitHubHttpsRemoteUrl(remoteUrl: string): boolean {
let parsed: URL;
try {
parsed = new URL(remoteUrl);
} catch {
return false;
}
if (parsed.protocol !== "https:") return false;
if (parsed.username || parsed.password) return false;
return isGitHubDotCom(parsed.hostname);
}
/**
* Mask credential material embedded in URLs so it never reaches warnings, run errors, or
* persisted payloads: userinfo on any scheme (`https://user:token@host`,
* `ssh://user:pass@host`) and the entire query string of any URL (`?access_token=…` and
* every other parameter — masked wholesale rather than by an inevitably incomplete
* parameter-name list). Scp-style remotes (`git@host:path`) carry no password and are left
* alone.
*/
export function scrubGitCredentialText(text: string): string {
return text
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/gi, "$1***@")
.replace(/([a-z][a-z0-9+.-]*:\/\/[^\s"'?]*)\?[^\s"']*/gi, "$1?***");
}
export function buildGitAuthInvocation(credential: GitCredential): GitAuthInvocation {
return {
// The leading empty helper clears ambient helpers (gh, osxkeychain, credential-store) so
// they neither outrank the resolved token nor receive store/erase callbacks for it. The
// token helper is installed URL-scoped: git consults it only for credential requests
// whose context matches github.com over https, so an `insteadOf`-rewritten remote never
// reaches it (and the helper itself re-checks the request host — see above).
configArgs: [
"-c", "credential.helper=",
"-c", `credential.https://github.com.helper=${GIT_CREDENTIAL_HELPER}`,
"-c", `credential.https://www.github.com.helper=${GIT_CREDENTIAL_HELPER}`,
],
env: {
[GIT_CREDENTIAL_TOKEN_ENV_KEY]: credential.token,
GIT_TERMINAL_PROMPT: "0",
},
source: credential.source,
secretName: credential.secretName,
};
}
const GIT_AUTH_FAILURE_PATTERN =
/authentication failed|could not read username|could not read password|invalid username or password|terminal prompts disabled|repository not found|not accessible|permission denied|HTTP 40[13]|The requested URL returned error: 40[13]/i;
/**
* Turn a failed git network operation into an actionable suffix for the error message.
* Returns null when the failure does not look auth-related — a credential that was merely
* present during an unrelated failure (network outage, target-path collision) must not be
* blamed for it.
*/
export function describeGitAuthFailure(input: {
error: string;
used: { source: GitCredential["source"]; secretName: string | null } | null;
}): string | null {
if (!GIT_AUTH_FAILURE_PATTERN.test(input.error)) {
return null;
}
if (input.used) {
const label = input.used.secretName
? `the ${input.used.secretName} company-secret GitHub credential`
: "the server-environment GitHub credential";
return `The operation authenticated with ${label}, which was rejected or lacks access to this repository.`;
}
return "No GitHub credential is configured — add a GITHUB_TOKEN or GH_TOKEN company secret in Settings → Secrets, or configure a local checkout cwd for this project workspace.";
}
type SecretServiceLike = ReturnType<typeof secretService>;
type GitCredentialSecretsDeps = {
getByName: (
companyId: string,
name: string,
) => Promise<{ id: string } | null | undefined> | ReturnType<SecretServiceLike["getByName"]>;
resolveSecretValue: SecretServiceLike["resolveSecretValue"];
};
/**
* Build the credential provider for one run. Resolution order: company secret by well-known
* name, then the server process env (`GITHUB_TOKEN`/`GH_TOKEN`) for self-hosted operators,
* then null. The lookup is memoized per provider instance so one run performs at most one
* secret resolution (and writes at most one audit event) no matter how many git operations
* it authenticates.
*/
export function createGitRemoteAuthProvider(
db: Db,
companyId: string,
context?: {
issueId?: string | null;
heartbeatRunId?: string | null;
responsibleUserId?: string | null;
},
deps?: {
secrets?: GitCredentialSecretsDeps;
env?: NodeJS.ProcessEnv;
secretNames?: readonly string[];
},
): GitRemoteAuthProvider {
const secrets: GitCredentialSecretsDeps = deps?.secrets ?? secretService(db);
const env = deps?.env ?? process.env;
const secretNames = deps?.secretNames ?? DEFAULT_GITHUB_TOKEN_SECRET_NAMES;
let credentialPromise: Promise<GitCredential | null> | null = null;
const resolveCredential = async (): Promise<GitCredential | null> => {
for (const secretName of secretNames) {
const secret = await Promise.resolve(secrets.getByName(companyId, secretName)).catch(() => null);
if (!secret) continue;
// A resolution failure (inactive secret, provider outage) records its own failure audit
// event; fall through to the next source instead of failing the whole git operation here.
const token = await secrets
.resolveSecretValue(companyId, secret.id, "latest", {
accessContext: {
consumerType: "system",
consumerId: "workspace-git-credential",
actorType: "system",
issueId: context?.issueId ?? null,
heartbeatRunId: context?.heartbeatRunId ?? null,
responsibleUserId: context?.responsibleUserId ?? null,
},
})
.then((value) => value.trim())
.catch(() => "");
if (token) return { token, source: "company_secret", secretName };
}
const envToken = env.GITHUB_TOKEN?.trim() || env.GH_TOKEN?.trim() || "";
if (envToken) return { token: envToken, source: "server_env", secretName: null };
return null;
};
return async (remoteUrl: string) => {
if (!isGitHubHttpsRemoteUrl(remoteUrl)) return null;
credentialPromise ??= resolveCredential();
const credential = await credentialPromise;
if (!credential) return null;
return buildGitAuthInvocation(credential);
};
}