import { spawn } from 'node:child_process'; import { bounded, type Trace } from '../harness/trace.ts'; import { terminateProcessTree } from '../platform/process.ts'; export interface CommandResult { exitCode: number; stdout: string; stderr: string; timedOut: boolean } export function command(cwd: string, executable: string, args: string[], env?: NodeJS.ProcessEnv, timeoutMs = 300_000, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const child = spawn(executable, args, { cwd, env: env ?? process.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); let termination: Promise | undefined; const abort = () => { if (child.pid) termination ??= terminateProcessTree(child.pid); }; signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort(); let stdout = '', stderr = '', timedOut = false; const timer = setTimeout(() => { timedOut = true; abort(); }, timeoutMs); child.stdout.on('data', chunk => { stdout += chunk; }); child.stderr.on('data', chunk => { stderr += chunk; }); child.on('error', error => { signal?.removeEventListener('abort', abort); clearTimeout(timer); reject(error); }); child.on('close', code => { signal?.removeEventListener('abort', abort); clearTimeout(timer); if (signal?.aborted) { reject(signal.reason); return; } resolve({ exitCode: code ?? -1, stdout, stderr, timedOut }); }); }); } export async function git(cwd: string, args: string[], trace?: Trace, env?: NodeJS.ProcessEnv, allowFailure = false) { const result = await command(cwd, 'git', args, env); if (trace) { // Bounded: paginated tool reads (git_diff) re-run the same large command per page; the // trace keeps facts plus an excerpt, never the full repeated payload. const stdout = bounded(result.stdout), stderr = bounded(result.stderr); trace.emit('git', { args, exitCode: result.exitCode, timedOut: result.timedOut, truncated: stdout.truncated || stderr.truncated, stdout_chars: stdout.chars, stdout: stdout.excerpt, stdout_sha256: stdout.truncated ? stdout.sha256 : undefined, stderr_chars: stderr.chars, stderr: stderr.excerpt }); } if (result.exitCode !== 0 && !allowFailure) throw new Error(`Git command failed: ${args[0]} (exit ${result.exitCode})`); return result; } export function gitAuth(token: string, remoteUrl = 'https://github.com', username = 'x-access-token', scopeUrl?: string): NodeJS.ProcessEnv { const origin = new URL(remoteUrl); if (!['http:', 'https:'].includes(origin.protocol) || origin.username || origin.password || origin.search || origin.hash) throw new Error('Git remote URL is unsafe for authenticated transport'); let scope = `${origin.protocol}//${origin.host}`; if (scopeUrl) { const candidate = new URL(scopeUrl); if (!['http:', 'https:'].includes(candidate.protocol) || candidate.origin !== origin.origin || candidate.username || candidate.password || candidate.search || candidate.hash) { throw new Error('Git credential scope crossed the remote origin'); } const path = candidate.pathname.replace(/\/+$/, ''); if (path && origin.pathname !== path && !origin.pathname.startsWith(`${path}/`)) throw new Error('Git credential scope does not contain the remote URL'); scope += path; } return { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: `http.${scope}/.extraheader`, GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`${username}:${token}`).toString('base64')}` }; }