83a303959e
Co-authored-by: AgentRun Codex <agentrun-codex@users.noreply.github.com>
344 lines
14 KiB
TypeScript
Executable File
344 lines
14 KiB
TypeScript
Executable File
#!/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 <repo-url> [ref] [--timeout-ms N]",
|
|
"agentrun-git clone <repo-url> <target-dir> [--ref REF] [--depth N] [--timeout-ms N]",
|
|
"agentrun-git fetch <repo-dir> <ref> [--remote origin] [--depth N] [--timeout-ms N]",
|
|
"agentrun-git download <url> <output-file> [--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 <repo-url>");
|
|
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 <repo-url> <target-dir>");
|
|
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 <repo-dir> <ref>");
|
|
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 <url> <output-file>");
|
|
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();
|