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..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;
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;
/**
* 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;
type GitCredentialSecretsDeps = {
getByName: (
companyId: string,
name: string,
) => Promise<{ id: string } | null | undefined> | ReturnType;
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 | null = null;
const resolveCredential = async (): Promise => {
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);
};
}