fix: authenticate clones via gh / token, never fall into git's prompt (#13)
resolveCheckout shelled out to a bare `git clone` and relied entirely on the ambient credential helper. On a workstation with none configured, git falls through to its interactive username/password prompt — which GitHub no longer accepts — and the resulting error talks about *the repository* rather than about cast's missing credentials. Being logged into `gh` does not help: `gh auth login` alone does not wire git's helper (that is `gh auth setup-git`, a separate act most people never run). Not routable around for prod: resolveCheckout refuses --path with --env prod, so the clone is the only path and its auth is mandatory. cast now resolves credentials itself, in order: `gh` borrowed as a per-invocation credential helper (no mutation of the user's global git config), then GITHUB_TOKEN / GH_TOKEN, then the ambient helper. The token is never embedded in the clone URL or in http.extraheader — both leak it into `ps`, and the latter persists it into the clone's git config. The helper reads it from the environment at run time, so what lands in argv is the literal text `$CAST_GIT_TOKEN`, never its value. GIT_TERMINAL_PROMPT=0 on every path: whichever credential was used, git may never fall through to a prompt it cannot satisfy — it can only hang, or hide the real fault. When there were no credentials at all, cast now says so, and names the fix. Note that the empty `credential.helper=` reset clears URL-scoped helpers (`credential.https://github.com.helper`, what `gh auth setup-git` writes) as well as generic ones — verified against a live private clone, along with all three acceptance criteria. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1fa5307652
commit
79834369b1
2 changed files with 245 additions and 8 deletions
158
src/resolve.ts
158
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<string, string>;
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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-"));
|
||||
|
|
|
|||
Loading…
Reference in a new issue