#!/usr/bin/env bun import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, stat } from "node:fs/promises"; import path from "node:path"; const defaultTimeoutMs = Number(process.env.AGENTRUN_GIT_DEFAULT_TIMEOUT_MS || 60_000); const defaultConnectTimeoutSeconds = Number(process.env.AGENTRUN_GIT_CONNECT_TIMEOUT_SECONDS || 10); const defaultLowSpeedLimit = Number(process.env.GIT_HTTP_LOW_SPEED_LIMIT || 1_024); const defaultLowSpeedTime = Number(process.env.GIT_HTTP_LOW_SPEED_TIME || 15); const defaultHttpVersion = process.env.AGENTRUN_GIT_HTTP_VERSION || process.env.GIT_HTTP_VERSION || "HTTP/1.1"; const defaultDirectHosts = ["github.com", "api.github.com", "codeload.github.com", "objects.githubusercontent.com", "raw.githubusercontent.com", "registry.npmjs.org", "registry.npmmirror.com"]; function help() { return { ok: true, tool: "agentrun-git", purpose: "bounded GitHub/codeload clone, fetch, ls-remote and download helper for AgentRun runners", commands: ["ls-remote", "clone", "fetch", "download"], usage: [ "agentrun-git ls-remote [ref] [--timeout-ms N]", "agentrun-git clone [--ref REF] [--depth N] [--timeout-ms N]", "agentrun-git fetch [--remote origin] [--depth N] [--timeout-ms N]", "agentrun-git download [--timeout-ms N]", ], defaults: transportDefaults(), valuesPrinted: false, }; } function transportDefaults() { return { timeoutMs: defaultTimeoutMs, connectTimeoutSeconds: defaultConnectTimeoutSeconds, httpVersion: defaultHttpVersion, lowSpeedLimitBytes: defaultLowSpeedLimit, lowSpeedTimeSeconds: defaultLowSpeedTime, credentialHelper: "gh-auth-setup-git", directHosts: directHosts(), valuesPrinted: false, }; } function writeJson(value) { process.stdout.write(`${JSON.stringify(value)}\n`); } function fail(message, details = {}, code = 2) { writeJson({ ok: false, action: "agentrun-git", failureKind: "schema-invalid", message, ...details, valuesPrinted: false }); process.exit(code); } function parseArgs(argv) { const positional = []; const flags = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg.startsWith("--")) { positional.push(arg); continue; } const name = arg.slice(2); const next = argv[i + 1]; if (!next || next.startsWith("--")) flags[name] = "true"; else { flags[name] = next; i += 1; } } return { positional, flags }; } function numberFlag(flags, name, fallback) { const raw = flags[name]; if (raw === undefined) return fallback; const value = Number(raw); if (!Number.isFinite(value) || value <= 0) fail(`--${name} must be a positive number`, { flag: name }); return Math.trunc(value); } function stringFlag(flags, name, fallback = null) { const raw = flags[name]; return typeof raw === "string" && raw !== "true" && raw.trim().length > 0 ? raw.trim() : fallback; } function directHosts() { const raw = process.env.AGENTRUN_GIT_DIRECT_HOSTS; if (!raw || raw.trim().length === 0) return [...defaultDirectHosts]; return raw.split(",").map((item) => item.trim()).filter(Boolean); } function hostFromTarget(value) { const raw = String(value || "").trim(); if (/^git@github\.com:/u.test(raw)) return "github.com"; try { return new URL(raw).hostname; } catch { return null; } } function proxyDecision(target) { const host = hostFromTarget(target); const hosts = directHosts(); const direct = host ? hosts.includes(host) : false; const proxyConfigured = Boolean(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.ALL_PROXY || process.env.http_proxy || process.env.https_proxy || process.env.all_proxy); return { host, mode: direct ? "direct-no-proxy" : proxyConfigured ? "env-proxy" : "direct-no-proxy", reason: direct ? "AGENTRUN_GIT_DIRECT_HOSTS" : proxyConfigured ? "proxy-env" : "no-proxy-env", proxyConfigured, valuesPrinted: false, }; } function commandEnv(target) { const decision = proxyDecision(target); const env = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_HTTP_VERSION: defaultHttpVersion, GIT_HTTP_LOW_SPEED_LIMIT: String(defaultLowSpeedLimit), GIT_HTTP_LOW_SPEED_TIME: String(defaultLowSpeedTime), }; if (decision.mode === "direct-no-proxy") { delete env.HTTP_PROXY; delete env.HTTPS_PROXY; delete env.ALL_PROXY; delete env.http_proxy; delete env.https_proxy; delete env.all_proxy; } const noProxy = new Set(String(env.NO_PROXY || env.no_proxy || "").split(",").map((item) => item.trim()).filter(Boolean)); for (const host of directHosts()) noProxy.add(host); if (decision.host) noProxy.add(decision.host); env.NO_PROXY = [...noProxy].join(","); env.no_proxy = env.NO_PROXY; return { env, decision }; } async function runCommand(command, args, options) { const startedAt = Date.now(); const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => { stdout = bounded(`${stdout}${chunk}`); }); child.stderr.on("data", (chunk) => { stderr = bounded(`${stderr}${chunk}`); }); let timedOut = false; let closed = false; const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); setTimeout(() => { if (!closed) child.kill("SIGKILL"); }, 2_000).unref(); }, options.timeoutMs); const result = await new Promise((resolve, reject) => { child.on("error", reject); child.on("close", (code, signal) => { closed = true; resolve({ code, signal }); }); }).catch((error) => ({ code: 127, signal: null, spawnError: error instanceof Error ? error.message : String(error) })); clearTimeout(timeout); return { code: result.code, signal: timedOut ? "SIGTERM" : result.signal, timedOut, elapsedMs: Date.now() - startedAt, stdout: redact(stdout), stderr: redact(stderr), spawnError: result.spawnError ? redact(result.spawnError) : null, }; } function bounded(text) { const limit = 12_000; return text.length > limit ? text.slice(text.length - limit) : text; } function redact(text) { return String(text) .replace(/\b(ghp_[A-Za-z0-9_]{8,}|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9_-]{8,})\b/gu, "REDACTED") .replace(/(authorization\s*[:=]\s*)(bearer\s+)?[A-Za-z0-9._~+/=-]+/giu, "$1$2REDACTED") .replace(/(https?:\/\/[^:\s/@]+:)[^@\s]+(@)/giu, "$1REDACTED$2"); } function gitArgs(args) { return [ "-c", `http.version=${defaultHttpVersion}`, "-c", `http.lowSpeedLimit=${defaultLowSpeedLimit}`, "-c", `http.lowSpeedTime=${defaultLowSpeedTime}`, ...args, ]; } async function setupCredentialHelper(env, timeoutMs) { const tokenPresent = Boolean(env.GH_TOKEN || env.GITHUB_TOKEN); if (!tokenPresent) return { helper: "gh-auth-setup-git", tokenPresent: false, status: "skipped-no-token", valuesPrinted: false }; const result = await runCommand("gh", ["auth", "setup-git"], { env, timeoutMs: Math.min(timeoutMs, 15_000) }); return { helper: "gh-auth-setup-git", tokenPresent: true, status: result.code === 0 ? "configured" : "failed", exitCode: result.code, stderrTail: result.stderr.slice(-500), valuesPrinted: false }; } function timeoutBudget(timeoutMs, elapsedMs, timedOut) { return { timeoutMs, elapsedMs, state: timedOut ? "timed-out" : "terminal", valuesPrinted: false }; } function pathSummary(value) { const parts = path.resolve(value).split(/[\\/]+/u).filter(Boolean); return { absolute: path.isAbsolute(value), basename: parts.at(-1) || null, depth: parts.length, valuesPrinted: false }; } async function gitRevParse(cwd, env, timeoutMs, ref = "HEAD") { const result = await runCommand("git", gitArgs(["rev-parse", ref]), { cwd, env, timeoutMs: Math.min(timeoutMs, 10_000) }); return result.code === 0 ? result.stdout.trim().split(/\s+/u)[0] || null : null; } async function operationResult(operation, target, timeoutMs, body) { const { env, decision } = commandEnv(target); const credential = await setupCredentialHelper(env, timeoutMs); const result = await body(env, decision, credential); const ok = result.code === 0 && result.timedOut !== true; writeJson({ ok, action: "agentrun-git", operation, target: redactedTarget(target), timeoutBudget: timeoutBudget(timeoutMs, result.elapsedMs, result.timedOut), protocol: { httpVersion: defaultHttpVersion, terminalPrompt: false, lowSpeedLimitBytes: defaultLowSpeedLimit, lowSpeedTimeSeconds: defaultLowSpeedTime, valuesPrinted: false }, proxyDecision: decision, credential, result: result.result ?? {}, failureKind: ok ? null : result.timedOut ? "backend-timeout" : "infra-failed", exitCode: result.code, signal: result.signal, stderrTail: result.stderr.slice(-1200), valuesPrinted: false, }); process.exit(ok ? 0 : result.timedOut ? 124 : 1); } function redactedTarget(value) { return redact(String(value)); } async function lsRemote(args, flags) { const repoUrl = args[0]; if (!repoUrl) fail("ls-remote requires "); const ref = args[1] || "HEAD"; const timeoutMs = numberFlag(flags, "timeout-ms", defaultTimeoutMs); await operationResult("ls-remote", repoUrl, timeoutMs, async (env) => { const result = await runCommand("git", gitArgs(["ls-remote", repoUrl, ref]), { env, timeoutMs }); const first = result.stdout.trim().split(/\r?\n/u)[0] || ""; return { ...result, result: { ref, firstLine: first.slice(0, 160), valuesPrinted: false } }; }); } async function cloneRepo(args, flags) { const repoUrl = args[0]; const targetDir = args[1]; if (!repoUrl || !targetDir) fail("clone requires "); const timeoutMs = numberFlag(flags, "timeout-ms", defaultTimeoutMs); const depth = String(numberFlag(flags, "depth", 1)); const ref = stringFlag(flags, "ref"); await mkdir(path.dirname(path.resolve(targetDir)), { recursive: true }); await operationResult("clone", repoUrl, timeoutMs, async (env) => { let result; if (ref) { await mkdir(targetDir, { recursive: true }); const init = await runCommand("git", gitArgs(["init"]), { cwd: targetDir, env, timeoutMs: Math.min(timeoutMs, 10_000) }); if (init.code !== 0) return init; const remote = await runCommand("git", gitArgs(["remote", "add", "origin", repoUrl]), { cwd: targetDir, env, timeoutMs: Math.min(timeoutMs, 10_000) }); if (remote.code !== 0) return remote; result = await runCommand("git", gitArgs(["fetch", "--depth", depth, "origin", ref]), { cwd: targetDir, env, timeoutMs }); if (result.code === 0) result = await runCommand("git", gitArgs(["checkout", "--detach", "FETCH_HEAD"]), { cwd: targetDir, env, timeoutMs: Math.min(timeoutMs, 10_000) }); } else { result = await runCommand("git", gitArgs(["clone", "--depth", depth, repoUrl, targetDir]), { env, timeoutMs }); } const commitId = result.code === 0 ? await gitRevParse(targetDir, env, timeoutMs) : null; return { ...result, result: { target: pathSummary(targetDir), ref, depth: Number(depth), commitId, valuesPrinted: false } }; }); } async function fetchRepo(args, flags) { const repoDir = args[0]; const ref = args[1]; if (!repoDir || !ref) fail("fetch requires "); const timeoutMs = numberFlag(flags, "timeout-ms", defaultTimeoutMs); const depth = String(numberFlag(flags, "depth", 1)); const remote = stringFlag(flags, "remote", "origin"); await operationResult("fetch", `${repoDir}:${remote}:${ref}`, timeoutMs, async (env) => { const result = await runCommand("git", gitArgs(["fetch", "--depth", depth, remote, ref]), { cwd: repoDir, env, timeoutMs }); const fetchHead = result.code === 0 ? await gitRevParse(repoDir, env, timeoutMs, "FETCH_HEAD") : null; return { ...result, result: { repoDir: pathSummary(repoDir), remote, ref, depth: Number(depth), fetchHead, valuesPrinted: false } }; }); } async function download(args, flags) { const url = args[0]; const output = args[1]; if (!url || !output) fail("download requires "); const timeoutMs = numberFlag(flags, "timeout-ms", defaultTimeoutMs); const seconds = Math.max(1, Math.ceil(timeoutMs / 1000)); await mkdir(path.dirname(path.resolve(output)), { recursive: true }); await operationResult("download", url, timeoutMs, async (env) => { const result = await runCommand("curl", ["-L", "--fail", "--max-time", String(seconds), "--connect-timeout", String(defaultConnectTimeoutSeconds), "-o", output, url], { env, timeoutMs: timeoutMs + 1_000 }); let bytes = 0; let sha256 = null; if (result.code === 0) { const file = Bun.file(output); const buffer = Buffer.from(await file.arrayBuffer()); bytes = buffer.length; sha256 = createHash("sha256").update(buffer).digest("hex"); } else { try { bytes = (await stat(output)).size; } catch { bytes = 0; } } return { ...result, result: { output: pathSummary(output), bytes, sha256: sha256 ? sha256.slice(0, 16) : null, valuesPrinted: false } }; }); } async function main() { const argv = process.argv.slice(2); if (argv.length === 0 || argv[0] === "--help" || argv[0] === "help" || argv[0] === "-h") { writeJson(help()); return; } const command = argv[0]; const { positional, flags } = parseArgs(argv.slice(1)); if (command === "ls-remote") return await lsRemote(positional, flags); if (command === "clone") return await cloneRepo(positional, flags); if (command === "fetch") return await fetchRepo(positional, flags); if (command === "download") return await download(positional, flags); fail(`unsupported command: ${command}`, { commands: help().commands }); } await main();