b6e2c61d30
* fix(push_branch): retry transient push errors and surface full stderr/stdout issue #571 motivated three small improvements to `mcp__pullfrog__push_branch`: 1. classify push errors into `concurrent-push` / `transient` / `unknown`. - `concurrent-push` extends the existing `fetch first` / `non-fast-forward` matcher to also catch the server-side `cannot lock ref` form (the case #571 reports). all three route to the same fetch + integrate + retry recovery message; copy now mentions concurrent push as a likely cause. - `transient` covers RPC failed, early EOF, connection reset, dns flake, HTTP 5xx, HTTP/2 stream not closed, and unexpected sideband disconnect. these are retried in-tool with 2s + 5s backoff before surfacing the error. push is idempotent so verbatim retry is safe. - `unknown` (auth/permission/protected-branch/4xx) is rethrown unchanged — retrying these wastes time and noise. 2. surface stdout alongside stderr in `$git` failure messages and include the exit code. previously only `stderr.trim()` was forwarded, which could be empty in rare HTTPS failure modes (the agent on issue #571's run saw a one-line `failed to push some refs` and had nothing to diagnose with). 3. unit tests for the classifier covering all three branches plus the concurrent-push-wins-over-transient ordering. does not introduce auto fetch+rebase+retry inside the tool — that path is blocked under shell=disabled, can leave the working tree mid-conflict, and would create unwanted merge commits. the recovery message keeps the agent in the loop. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(push_branch): retry 429, jitter backoff, downgrade retry log to info - treat HTTP 429 (rate-limit / abuse detection) as transient — GitHub occasionally surfaces it on git push, where it is retry-safe unlike 401/403/404 - add ±25% jitter to backoff so concurrent agents hit by the same upstream blip don't retry in lockstep - log retries with log.info instead of log.warning to match retry.ts convention; a successful retry shouldn't leave a yellow GHA annotation behind in the job summary --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
184 lines
5.5 KiB
TypeScript
184 lines
5.5 KiB
TypeScript
/**
|
|
* git authentication via GIT_ASKPASS.
|
|
*
|
|
* a localhost HTTP server serves tokens via single-use UUID codes.
|
|
* each $git() call writes a unique askpass script with the server
|
|
* port+code baked into the file body — no secrets in subprocess env.
|
|
*
|
|
* see wiki/askpass.md for full security documentation.
|
|
*/
|
|
|
|
import { execSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { readFileSync, realpathSync, unlinkSync } from "node:fs";
|
|
import { log } from "./cli.ts";
|
|
import type { GitAuthServer } from "./gitAuthServer.ts";
|
|
import { filterEnv } from "./secrets.ts";
|
|
import { spawn } from "./subprocess.ts";
|
|
|
|
type SafeGitSubcommand = "fetch" | "push";
|
|
|
|
type GitAuthOptions = {
|
|
token: string;
|
|
cwd?: string;
|
|
};
|
|
|
|
type GitResult = {
|
|
stdout: string;
|
|
stderr: string;
|
|
};
|
|
|
|
// --- git binary resolution and tamper detection ---
|
|
|
|
type GitBinaryInfo = {
|
|
path: string;
|
|
sha256: string;
|
|
};
|
|
|
|
let gitBinary: GitBinaryInfo | undefined;
|
|
|
|
function hashFile(path: string): string {
|
|
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
}
|
|
|
|
/**
|
|
* resolve and fingerprint the git binary. must be called once at startup
|
|
* (in main()) before any agent code runs, so the path and hash reflect
|
|
* the untampered binary.
|
|
*
|
|
* resolves symlinks via realpath so the hash is of the actual binary.
|
|
* a malicious agent with sudo could replace the binary later, which is
|
|
* caught by verifyGitBinary() before each authenticated call.
|
|
*/
|
|
export function resolveGit(): void {
|
|
const whichPath = execSync("which git", { encoding: "utf-8" }).trim();
|
|
const resolvedPath = realpathSync(whichPath);
|
|
const sha256 = hashFile(resolvedPath);
|
|
gitBinary = { path: resolvedPath, sha256 };
|
|
log.debug(`» git binary: ${resolvedPath} (sha256: ${sha256.slice(0, 12)}...)`);
|
|
}
|
|
|
|
function verifyGitBinary(): string {
|
|
if (!gitBinary) {
|
|
throw new Error("git binary not initialized — call resolveGit() at startup");
|
|
}
|
|
const currentHash = hashFile(gitBinary.path);
|
|
if (currentHash !== gitBinary.sha256) {
|
|
throw new Error(
|
|
`git binary tampered: expected sha256 ${gitBinary.sha256}, got ${currentHash}. ` +
|
|
`path: ${gitBinary.path}`
|
|
);
|
|
}
|
|
return gitBinary.path;
|
|
}
|
|
|
|
// --- auth server ---
|
|
|
|
let authServer: GitAuthServer | undefined;
|
|
|
|
export function setGitAuthServer(server: GitAuthServer): void {
|
|
authServer = server;
|
|
}
|
|
|
|
/**
|
|
* execute authenticated git command via ASKPASS.
|
|
*
|
|
* subcommand is restricted to "fetch" | "push" — operations that talk to
|
|
* a remote and need credentials. working-tree operations (checkout, merge)
|
|
* use $() from shell.ts which has no token.
|
|
*
|
|
* per call: registers a one-time code with the auth server, writes a
|
|
* unique askpass script with port+code baked in, spawns git with
|
|
* GIT_ASKPASS pointing to the script, and deletes the script in finally.
|
|
*
|
|
* @example
|
|
* await $git("fetch", ["origin", "main"], { token });
|
|
* await $git("push", ["-u", "origin", "feature"], { token });
|
|
*/
|
|
export async function $git(
|
|
subcommand: SafeGitSubcommand,
|
|
args: string[],
|
|
options: GitAuthOptions
|
|
): Promise<GitResult> {
|
|
const gitPath = verifyGitBinary();
|
|
|
|
if (!authServer) {
|
|
throw new Error("git auth server not initialized — call setGitAuthServer() at startup");
|
|
}
|
|
|
|
const cwd = options.cwd ?? process.cwd();
|
|
|
|
const code = authServer.register(options.token);
|
|
const scriptPath = authServer.writeAskpassScript(code);
|
|
|
|
// -c flags override local .git/config — defense-in-depth against
|
|
// agent-set config that could spawn subprocesses before ASKPASS runs
|
|
const fullArgs = [
|
|
"-c",
|
|
"core.fsmonitor=false",
|
|
"-c",
|
|
"credential.helper=",
|
|
"-c",
|
|
"protocol.file.allow=never",
|
|
"-c",
|
|
"core.sshCommand=ssh",
|
|
subcommand,
|
|
...args,
|
|
];
|
|
|
|
log.debug(`git ${fullArgs.join(" ")}`);
|
|
|
|
try {
|
|
const result = await spawn({
|
|
cmd: gitPath,
|
|
args: fullArgs,
|
|
cwd,
|
|
env: {
|
|
...filterEnv(),
|
|
GIT_ASKPASS: scriptPath,
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
// blocks env-based git config injection from outer processes.
|
|
// GIT_CONFIG_COUNT=0 blocks the newer KEY_n/VALUE_n mechanism.
|
|
// GIT_CONFIG_PARAMETERS="" clears the legacy quoted-list mechanism.
|
|
// both are needed — they are independent systems.
|
|
GIT_CONFIG_COUNT: "0",
|
|
GIT_CONFIG_PARAMETERS: "",
|
|
},
|
|
activityTimeout: 0,
|
|
});
|
|
|
|
if (result.stderr.includes("askpass-compromised")) {
|
|
log.info("askpass code was already consumed — token has been revoked");
|
|
throw new Error("git auth failed — askpass code was already consumed, token revoked");
|
|
}
|
|
|
|
if (result.exitCode !== 0) {
|
|
const stderr = result.stderr.trim();
|
|
const stdout = result.stdout.trim();
|
|
// stderr is the primary channel for git diagnostics, but in rare cases
|
|
// (e.g. some HTTPS smart-protocol failures) the only useful detail is
|
|
// on stdout — without it the agent / operator sees an empty error.
|
|
// include exit code so we can distinguish e.g. signal-killed (1 with
|
|
// empty output) from a genuine git-level rejection.
|
|
const detail =
|
|
stderr && stdout
|
|
? `${stderr}\n--- stdout ---\n${stdout}`
|
|
: stderr || stdout || "(no output)";
|
|
const message = `git ${subcommand} failed (exit ${result.exitCode}): ${detail}`;
|
|
log.info(message);
|
|
throw new Error(message);
|
|
}
|
|
|
|
return {
|
|
stdout: result.stdout.trim(),
|
|
stderr: result.stderr.trim(),
|
|
};
|
|
} finally {
|
|
try {
|
|
unlinkSync(scriptPath);
|
|
} catch {
|
|
// script may have self-deleted already
|
|
}
|
|
}
|
|
}
|