diff --git a/src/resolve.ts b/src/resolve.ts index fae4f2d..c76012a 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -6,6 +6,127 @@ import type { Desired } from "./diff.js"; import { type ResolvedEnv, resolveTemplate } from "./envtemplate.js"; import { loadManifest } from "./manifest.js"; +// How cast authenticated (or failed to authenticate) a clone. +// +// gh — `gh` is installed and holds a token; borrowed as a credential +// helper for this invocation only +// token — GITHUB_TOKEN / GH_TOKEN in the environment (the CI path) +// ambient — neither; whatever git's own credential helper does, if anything +export type GitAuth = { + source: "gh" | "token" | "ambient"; + configArgs: string[]; + env: Record; +}; + +// A credential helper reads the token from the ENVIRONMENT at run time. The +// alternatives both leak it: a token in the clone URL shows up in `ps` and in +// git's own error messages, and `http.extraheader` additionally persists into +// the clone's .git/config. What lands in argv here is the literal text +// `$CAST_GIT_TOKEN`, never its value. +const TOKEN_HELPER = + '!f() { test "$1" = get || exit 0; echo username=x-access-token; echo "password=$CAST_GIT_TOKEN"; }; f'; + +// `gh auth login` alone does NOT wire git's credential helper — that is +// `gh auth setup-git`, a separate act most people never run. So being logged +// into `gh` does not make `git clone` work, which is exactly the trap #13 +// fell into. Borrowing gh as a helper for this one invocation closes that gap +// without mutating the operator's global git config. +const GH_HELPER = "!gh auth git-credential"; + +function ghHasToken(): boolean { + try { + // A local keyring/config read, not a network call. We never keep the + // value — the helper re-reads it inside git. + execFileSync("gh", ["auth", "token"], { stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +// Resolve clone credentials INSIDE cast, in a fixed order, rather than leaving +// it to whatever the ambient git config happens to do. `credential.helper=` +// (empty) first RESETS the inherited helper list — otherwise a helper +// configured globally is consulted before ours and silently decides the +// outcome, which is the same "the connection target is implicit in a file's +// contents" problem #14 is about. +export function resolveGitAuth( + env: NodeJS.ProcessEnv = process.env, + hasGh: () => boolean = ghHasToken, +): GitAuth { + if (hasGh()) { + return { + source: "gh", + configArgs: [ + "-c", + "credential.helper=", + "-c", + `credential.helper=${GH_HELPER}`, + ], + env: {}, + }; + } + const token = env.GITHUB_TOKEN || env.GH_TOKEN; + if (token) { + return { + source: "token", + configArgs: [ + "-c", + "credential.helper=", + "-c", + `credential.helper=${TOKEN_HELPER}`, + ], + env: { CAST_GIT_TOKEN: token }, + }; + } + return { source: "ambient", configArgs: [], env: {} }; +} + +// GitHub answers "you cannot see this" with a 404, not a 403 — so a private +// repo you lack access to and a repo that does not exist are the same message +// on the wire. The failure text must not pick one; it has to name both, and +// name the credential cast actually used, or the operator debugs the wrong +// half. (The original bug reported *the repository* when the real fault was +// cast's missing credentials.) +export function cloneFailureMessage( + orgRepo: string, + auth: GitAuth, + stderr: string, +): string { + const detail = stderr.trim(); + const tail = detail + ? ["", "git said:", ...detail.split("\n").map((l) => ` ${l}`)] + : []; + if (auth.source === "ambient") { + return [ + `cannot clone ${orgRepo}: no GitHub credentials.`, + "", + "cast looked for, in order:", + " 1. `gh` — not installed, or not logged in (`gh auth token` failed)", + " 2. GITHUB_TOKEN / GH_TOKEN — not set in the environment", + " 3. git's own credential helper — did not supply credentials either", + "", + "Run `gh auth login`, or set GITHUB_TOKEN. (`gh auth setup-git` also works,", + "but cast borrows `gh` as a credential helper on its own, so logging in is", + "enough — you do not need to change your global git config.)", + ...tail, + ].join("\n"); + } + const used = + auth.source === "gh" + ? "`gh` (borrowed as a credential helper for this clone)" + : "GITHUB_TOKEN / GH_TOKEN from the environment"; + return [ + `cannot clone ${orgRepo}: authenticated with ${used}, and GitHub still refused.`, + "", + "GitHub answers 'you cannot see this' with a 404, so this is one of:", + ` - ${orgRepo} does not exist (check the slug)`, + " - it is private and this credential has no access to it", + " - the credential is expired, or lacks the `repo` scope", + ...tail, + ].join("\n"); +} + export function resolveCheckout( orgRepo: string, opts: { env: string; path?: string }, @@ -17,13 +138,36 @@ export function resolveCheckout( } if (opts.path) return opts.path; const dir = mkdtempSync(join(tmpdir(), "infra-checkout-")); - execFileSync( - "git", - ["clone", "--depth", "1", `https://github.com/${orgRepo}.git`, dir], - { - stdio: "pipe", - }, - ); + const auth = resolveGitAuth(); + try { + execFileSync( + "git", + [ + ...auth.configArgs, + "clone", + "--depth", + "1", + `https://github.com/${orgRepo}.git`, + dir, + ], + { + stdio: "pipe", + env: { + ...process.env, + ...auth.env, + // Belt and braces: whatever credential path we took, git may NEVER + // fall through to its interactive username/password prompt. GitHub + // stopped accepting passwords there years ago, so it cannot succeed + // — it can only hang cast, or (in the original report) hand back an + // error about the repository that hides the real fault. + GIT_TERMINAL_PROMPT: "0", + }, + }, + ); + } catch (err) { + const stderr = String((err as { stderr?: Buffer | string })?.stderr ?? ""); + throw new Error(cloneFailureMessage(orgRepo, auth, stderr)); + } return dir; } diff --git a/test/resolve.test.ts b/test/resolve.test.ts index 2adb7fa..1cb9de5 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { computeDiff } from "../src/diff.js"; -import { desiredFromManifest, resolveCheckout } from "../src/resolve.js"; +import { + cloneFailureMessage, + desiredFromManifest, + resolveCheckout, + resolveGitAuth, +} from "../src/resolve.js"; describe("resolveCheckout", () => { it("hard-refuses --path with prod", () => { @@ -21,6 +26,94 @@ describe("resolveCheckout", () => { }); }); +describe("resolveGitAuth", () => { + const noGh = () => false; + const yesGh = () => true; + + it("prefers gh, borrowed as a per-invocation credential helper", () => { + const auth = resolveGitAuth({ GITHUB_TOKEN: "t" }, yesGh); + expect(auth.source).toBe("gh"); + expect(auth.configArgs.join(" ")).toContain("!gh auth git-credential"); + // No token is materialized when gh is driving. + expect(auth.env).toEqual({}); + }); + + it("falls back to GITHUB_TOKEN when gh is absent", () => { + const auth = resolveGitAuth({ GITHUB_TOKEN: "ghp_secret" }, noGh); + expect(auth.source).toBe("token"); + expect(auth.env).toEqual({ CAST_GIT_TOKEN: "ghp_secret" }); + }); + + it("accepts GH_TOKEN as well as GITHUB_TOKEN", () => { + const auth = resolveGitAuth({ GH_TOKEN: "ghp_secret" }, noGh); + expect(auth.source).toBe("token"); + expect(auth.env).toEqual({ CAST_GIT_TOKEN: "ghp_secret" }); + }); + + // The acceptance criterion from #13: "the token never appears in process + // arguments or on disk". The helper string git receives must carry the + // NAME of the variable, never its value — sh expands it inside the helper. + it("never puts the token value in the git argv", () => { + const auth = resolveGitAuth({ GITHUB_TOKEN: "ghp_secret" }, noGh); + const argv = auth.configArgs.join(" "); + expect(argv).not.toContain("ghp_secret"); + expect(argv).toContain("$CAST_GIT_TOKEN"); + }); + + // A helper configured globally would otherwise be consulted first and + // silently decide the outcome, defeating the order cast just established. + it("resets the inherited helper list before installing its own", () => { + for (const auth of [ + resolveGitAuth({}, yesGh), + resolveGitAuth({ GITHUB_TOKEN: "t" }, noGh), + ]) { + expect(auth.configArgs.slice(0, 2)).toEqual(["-c", "credential.helper="]); + } + }); + + it("falls through to the ambient helper when there is nothing else", () => { + expect(resolveGitAuth({}, noGh)).toEqual({ + source: "ambient", + configArgs: [], + env: {}, + }); + }); +}); + +describe("cloneFailureMessage", () => { + const ambient = { source: "ambient" as const, configArgs: [], env: {} }; + const gh = { source: "gh" as const, configArgs: [], env: {} }; + + // The original bug: git's own error talked about THE REPOSITORY when the + // real fault was cast having no credentials at all. + it("blames the missing credentials, not the repo, when there were none", () => { + const msg = cloneFailureMessage("heavy-duty/incubator", ambient, ""); + expect(msg).toMatch(/no GitHub credentials/); + expect(msg).toMatch(/gh auth login/); + expect(msg).toMatch(/GITHUB_TOKEN/); + expect(msg).not.toMatch(/does not exist/); + }); + + // ...and the converse: once cast DID authenticate, the repo really is a + // candidate explanation again, and 404-means-403 has to be spelled out. + it("names both roads when a credential was used and GitHub still refused", () => { + const msg = cloneFailureMessage("heavy-duty/incubator", gh, ""); + expect(msg).toMatch(/gh/); + expect(msg).toMatch(/does not exist/); + expect(msg).toMatch(/private/); + expect(msg).not.toMatch(/no GitHub credentials/); + }); + + it("passes git's own stderr through rather than swallowing it", () => { + const msg = cloneFailureMessage( + "heavy-duty/incubator", + ambient, + "fatal: could not read Username for 'https://github.com'", + ); + expect(msg).toMatch(/could not read Username/); + }); +}); + describe("desiredFromManifest", () => { it("maps manifest + templates to Desired[] with resolved env", () => { const dir = mkdtempSync(join(tmpdir(), "infra-co-"));