feat(runner): clone stage with ephemeral credential helper
Clone stage per tech spec §6, verbatim mechanics: every git invocation carries a BLANK credential.helper first (suppresses OS keychain helpers) then the inline veect helper; the PAT reaches git via child env only — never argv, URLs, logs, or .git/config. GIT_TERMINAL_PROMPT and GIT_ASKPASS are pinned so a headless clone can never hang on a TTY or ambient IDE askpass. Staged status events (start/heartbeat/final) + ready on success; named failures: invalid-git-url, auth-failed, clone-failed (daemon's 'repository not exported' maps to bad-URL, not auth). Integration suite clones the daemon-served vite-kitchen and an authenticated dumb-HTTP remote that asserts the exact Basic credential git presented, plus leak assertions over logs/argv/.git. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
55
apps/runner/src/exec.ts
Normal file
55
apps/runner/src/exec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { execFile } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Minimal child-process runner shared by the stages. Array-args only —
|
||||
* never a shell — so URLs and paths cannot be interpreted, and secrets
|
||||
* can only travel via `env` (spec §6/§10).
|
||||
*/
|
||||
export interface CommandResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export type RunCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; env?: Record<string, string> },
|
||||
) => Promise<CommandResult>;
|
||||
|
||||
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Ten minutes: covers a cold `npm install` of a real repo; prevents hangs. */
|
||||
const COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export const runCommand: RunCommand = (command, args, options) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...options.env },
|
||||
maxBuffer: MAX_OUTPUT_BYTES,
|
||||
timeout: COMMAND_TIMEOUT_MS,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error === null) {
|
||||
resolve({ exitCode: 0, stdout, stderr });
|
||||
return;
|
||||
}
|
||||
// Non-zero exit is a result, not an exception — stages map it to
|
||||
// named protocol errors. Spawn-level failures still reject.
|
||||
const exitCode = typeof error.code === "number" ? error.code : null;
|
||||
if (exitCode !== null) {
|
||||
resolve({ exitCode, stdout, stderr });
|
||||
return;
|
||||
}
|
||||
if (error.killed === true) {
|
||||
resolve({ exitCode: 124, stdout, stderr: `${stderr}\n(timed out)` });
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user