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:
@@ -33,6 +33,36 @@ manifest, no use-case layer, no DI container.
|
|||||||
- Zod issues are summarized as path + code only — payload values (which
|
- Zod issues are summarized as path + code only — payload values (which
|
||||||
may include a PAT) are never echoed into errors or logs.
|
may include a PAT) are never echoed into errors or logs.
|
||||||
|
|
||||||
|
## Clone stage — credential mechanics (tech spec §6, verbatim)
|
||||||
|
|
||||||
|
- Every git invocation carries `-c credential.helper=` (BLANK first —
|
||||||
|
resets the helper list, suppressing OS keychain helpers) followed by
|
||||||
|
`-c credential.helper=<veect-helper>` (an inline shell function that
|
||||||
|
answers `get` from env vars and ignores `store`/`erase`).
|
||||||
|
- The PAT reaches git via the child's env (`VEECT_GIT_PAT`) — never
|
||||||
|
argv (the helper string only names env vars), never in the URL, never
|
||||||
|
written to `.git/config` or anywhere else on disk.
|
||||||
|
- `GIT_TERMINAL_PROMPT=0` and `GIT_ASKPASS=echo` ride every invocation:
|
||||||
|
a clone must never hang on a TTY prompt or an ambient IDE askpass.
|
||||||
|
- Failures map to named causes (`src/stages/clone.ts`): unusable URL /
|
||||||
|
unreachable repo → `invalid-git-url`, credential rejection →
|
||||||
|
`auth-failed`, anything else → `clone-failed`.
|
||||||
|
|
||||||
|
### How the auth path is honestly tested
|
||||||
|
|
||||||
|
`git daemon` (story 01's transport) has no authentication, so it cannot
|
||||||
|
prove credential delivery. The integration suite therefore also serves
|
||||||
|
the same bare fixture over **authenticated dumb HTTP**
|
||||||
|
(`tests/http-git-server.ts`): a real `git clone` probes, receives 401,
|
||||||
|
consults the ephemeral helper, and retries with Basic auth — the test
|
||||||
|
asserts the server received exactly `x-access-token:<PAT>`, that git's
|
||||||
|
first probe was unauthenticated, and that the PAT appears nowhere in
|
||||||
|
runner logs (which include every spawned git argv), `.git/config`, or
|
||||||
|
any other file under `.git/`. Wrong/missing PAT → named `auth-failed`.
|
||||||
|
What is NOT covered: a real smart-HTTP provider (GitHub et al.) — the
|
||||||
|
dumb-HTTP fallback exercises the same credential machinery in git, but
|
||||||
|
the smart-protocol surface itself first meets reality in later PRDs.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Unit suites live next to sources in `src/`; protocol/WS suites live in
|
- Unit suites live next to sources in `src/`; protocol/WS suites live 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
67
apps/runner/src/git/credential-helper.test.ts
Normal file
67
apps/runner/src/git/credential-helper.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildCredentialHelperArgs,
|
||||||
|
buildCredentialHelperEnv,
|
||||||
|
PAT_ENV_VAR,
|
||||||
|
PAT_USERNAME,
|
||||||
|
USERNAME_ENV_VAR,
|
||||||
|
VEECT_CREDENTIAL_HELPER,
|
||||||
|
} from "@/git/credential-helper";
|
||||||
|
|
||||||
|
const PAT = "ghp_super-secret-token-123";
|
||||||
|
|
||||||
|
describe("buildCredentialHelperArgs", () => {
|
||||||
|
it("passes the BLANK helper first to suppress OS keychain helpers (spec §6)", () => {
|
||||||
|
const args = buildCredentialHelperArgs(true);
|
||||||
|
expect(args.slice(0, 2)).toEqual(["-c", "credential.helper="]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the veect helper second when a credential exists", () => {
|
||||||
|
const args = buildCredentialHelperArgs(true);
|
||||||
|
expect(args).toEqual([
|
||||||
|
"-c",
|
||||||
|
"credential.helper=",
|
||||||
|
"-c",
|
||||||
|
`credential.helper=${VEECT_CREDENTIAL_HELPER}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still suppresses OS helpers when no credential exists", () => {
|
||||||
|
expect(buildCredentialHelperArgs(false)).toEqual([
|
||||||
|
"-c",
|
||||||
|
"credential.helper=",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains no secret material — only env var NAMES ride argv", () => {
|
||||||
|
// The helper string is argv-visible; it must reference the env vars
|
||||||
|
// by name and never embed a value.
|
||||||
|
expect(VEECT_CREDENTIAL_HELPER).toContain(`$${USERNAME_ENV_VAR}`);
|
||||||
|
expect(VEECT_CREDENTIAL_HELPER).toContain(`$${PAT_ENV_VAR}`);
|
||||||
|
expect(VEECT_CREDENTIAL_HELPER).toContain('"$1" = "get"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers get only — store/erase are no-ops so git can never persist", () => {
|
||||||
|
expect(VEECT_CREDENTIAL_HELPER).toMatch(
|
||||||
|
/^!f\(\) \{ if \[ "\$1" = "get" \]/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildCredentialHelperEnv", () => {
|
||||||
|
it("carries the PAT via env, with the conventional PAT username", () => {
|
||||||
|
expect(buildCredentialHelperEnv(PAT)).toEqual({
|
||||||
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
|
GIT_ASKPASS: "echo",
|
||||||
|
[USERNAME_ENV_VAR]: PAT_USERNAME,
|
||||||
|
[PAT_ENV_VAR]: PAT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables terminal prompts and neutralizes ambient askpass so a clone can never hang", () => {
|
||||||
|
expect(buildCredentialHelperEnv(undefined)).toEqual({
|
||||||
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
|
GIT_ASKPASS: "echo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
71
apps/runner/src/git/credential-helper.ts
Normal file
71
apps/runner/src/git/credential-helper.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Ephemeral git credential helper — tech spec §6 mechanics, verbatim:
|
||||||
|
*
|
||||||
|
* Every git invocation the runner spawns carries, in order:
|
||||||
|
*
|
||||||
|
* 1. `-c credential.helper=` — a BLANK helper FIRST. An empty value
|
||||||
|
* resets git's helper list, suppressing any OS-configured helpers
|
||||||
|
* (keychain / credential manager) that could answer or cache the
|
||||||
|
* credential.
|
||||||
|
* 2. `-c credential.helper=<veect-helper>` — an inline shell helper
|
||||||
|
* that answers `get` by printing username/password sourced from the
|
||||||
|
* spawned child's environment.
|
||||||
|
*
|
||||||
|
* The secret reaches git via env (`VEECT_GIT_PAT` on the child process),
|
||||||
|
* NEVER via argv — the helper string only names the env vars — and never
|
||||||
|
* embedded in the remote URL (URLs with tokens leak into `.git/config`
|
||||||
|
* and process lists). The helper ignores `store`/`erase`, so git can
|
||||||
|
* never persist the credential anywhere.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Child-env variable the helper reads the PAT from. Never logged. */
|
||||||
|
export const PAT_ENV_VAR = "VEECT_GIT_PAT";
|
||||||
|
|
||||||
|
/** Child-env variable the helper reads the username from. */
|
||||||
|
export const USERNAME_ENV_VAR = "VEECT_GIT_USERNAME";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Username accompanying a PAT over HTTP basic auth. GitHub accepts any
|
||||||
|
* non-empty username for PATs; `x-access-token` is the conventional one.
|
||||||
|
*/
|
||||||
|
export const PAT_USERNAME = "x-access-token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inline helper. Contains NO secret material — only the names of the
|
||||||
|
* env vars — so it is safe inside argv. git invokes it with
|
||||||
|
* `get`/`store`/`erase` as `$1`; everything but `get` is a no-op.
|
||||||
|
*/
|
||||||
|
export const VEECT_CREDENTIAL_HELPER = `!f() { if [ "$1" = "get" ]; then printf 'username=%s\\npassword=%s\\n' "$${USERNAME_ENV_VAR}" "$${PAT_ENV_VAR}"; fi; }; f`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `-c` args for one git invocation: blank suppressor always; the veect
|
||||||
|
* helper only when a credential exists to serve.
|
||||||
|
*/
|
||||||
|
export function buildCredentialHelperArgs(withCredential: boolean): string[] {
|
||||||
|
const args = ["-c", "credential.helper="];
|
||||||
|
if (withCredential) {
|
||||||
|
args.push("-c", `credential.helper=${VEECT_CREDENTIAL_HELPER}`);
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Env additions for one git invocation. `GIT_TERMINAL_PROMPT=0` rides
|
||||||
|
* every invocation — a clone must never hang on a TTY prompt (spec §6) —
|
||||||
|
* and `GIT_ASKPASS=echo` overrides any ambient askpass program (IDE
|
||||||
|
* shells export e.g. VS Code's askpass, which would block a headless
|
||||||
|
* clone on GUI IPC): `echo <prompt>` answers with an empty credential,
|
||||||
|
* so a missing/denied credential fails fast instead of hanging. The
|
||||||
|
* veect helper always answers first when a PAT exists.
|
||||||
|
*/
|
||||||
|
export function buildCredentialHelperEnv(pat?: string): Record<string, string> {
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
|
GIT_ASKPASS: "echo",
|
||||||
|
};
|
||||||
|
if (pat !== undefined) {
|
||||||
|
env[USERNAME_ENV_VAR] = PAT_USERNAME;
|
||||||
|
env[PAT_ENV_VAR] = pat;
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
type RunnerMessage,
|
type RunnerMessage,
|
||||||
type RunnerStage,
|
type RunnerStage,
|
||||||
} from "@repo/core-runner-protocol";
|
} from "@repo/core-runner-protocol";
|
||||||
|
import { runCommand } from "./exec";
|
||||||
import type { Logger } from "./log";
|
import type { Logger } from "./log";
|
||||||
|
import { cloneRepository } from "./stages/clone";
|
||||||
|
import { runStage, type StageEmitter } from "./stages/stage-runner";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The runner's WS protocol server (ADR-027, protocol version "0").
|
* The runner's WS protocol server (ADR-027, protocol version "0").
|
||||||
@@ -137,38 +140,59 @@ export type CommandMessage = Extract<
|
|||||||
{ type: "clone" | "install" | "scan" | "adapter-start" | "render-frame" }
|
{ type: "clone" | "install" | "scan" | "adapter-start" | "render-frame" }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface StageEmitter {
|
|
||||||
status: (stage: RunnerStage, elapsedMs: number) => void;
|
|
||||||
error: (
|
|
||||||
cause: RunnerErrorCause,
|
|
||||||
message: string,
|
|
||||||
stage?: RunnerStage,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stages that later walking-skeleton stories wire up (05+). */
|
/** Stages that later walking-skeleton stories wire up (05+). */
|
||||||
const NOT_IMPLEMENTED: Record<
|
const NOT_IMPLEMENTED: Record<
|
||||||
CommandMessage["type"],
|
Exclude<CommandMessage["type"], "clone">,
|
||||||
{ cause: RunnerErrorCause; stage?: RunnerStage }
|
{ cause: RunnerErrorCause; stage?: RunnerStage }
|
||||||
> = {
|
> = {
|
||||||
clone: { cause: "clone-failed", stage: "cloning" },
|
|
||||||
install: { cause: "install-failed", stage: "installing" },
|
install: { cause: "install-failed", stage: "installing" },
|
||||||
scan: { cause: "scan-failed", stage: "scanning" },
|
scan: { cause: "scan-failed", stage: "scanning" },
|
||||||
"adapter-start": { cause: "adapter-start-failed", stage: "starting-preview" },
|
"adapter-start": { cause: "adapter-start-failed", stage: "starting-preview" },
|
||||||
"render-frame": { cause: "render-failed" },
|
"render-frame": { cause: "render-failed" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface CommandContext {
|
||||||
|
emit: StageEmitter;
|
||||||
|
/** Signal successful command completion — the session's idle marker. */
|
||||||
|
ready: () => void;
|
||||||
|
options: RunnerServerOptions;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCommand(
|
async function handleCommand(
|
||||||
command: CommandMessage,
|
command: CommandMessage,
|
||||||
emit: StageEmitter,
|
ctx: CommandContext,
|
||||||
_options: RunnerServerOptions,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const notImplemented = NOT_IMPLEMENTED[command.type];
|
const { emit, options } = ctx;
|
||||||
emit.error(
|
const stageOptions = {
|
||||||
notImplemented.cause,
|
emit,
|
||||||
`the "${command.type}" command is not implemented by this runner build yet`,
|
heartbeatMs: options.heartbeatMs,
|
||||||
notImplemented.stage,
|
log: options.log,
|
||||||
);
|
};
|
||||||
|
switch (command.type) {
|
||||||
|
case "clone": {
|
||||||
|
const result = await runStage(
|
||||||
|
"cloning",
|
||||||
|
"clone-failed",
|
||||||
|
stageOptions,
|
||||||
|
() =>
|
||||||
|
cloneRepository({
|
||||||
|
workspaceDir: options.workspaceDir,
|
||||||
|
runCommand,
|
||||||
|
log: options.log,
|
||||||
|
})(command),
|
||||||
|
);
|
||||||
|
if (result.ok) ctx.ready();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const notImplemented = NOT_IMPLEMENTED[command.type];
|
||||||
|
emit.error(
|
||||||
|
notImplemented.cause,
|
||||||
|
`the "${command.type}" command is not implemented by this runner build yet`,
|
||||||
|
notImplemented.stage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rawToString(data: RawData): string {
|
function rawToString(data: RawData): string {
|
||||||
@@ -257,7 +281,11 @@ export async function startRunnerServer(
|
|||||||
log.info("command-received", { command: command.type });
|
log.info("command-received", { command: command.type });
|
||||||
commandChain = commandChain.then(async () => {
|
commandChain = commandChain.then(async () => {
|
||||||
try {
|
try {
|
||||||
await handleCommand(command, emit, options);
|
await handleCommand(command, {
|
||||||
|
emit,
|
||||||
|
ready: () => send({ type: "ready" }),
|
||||||
|
options,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Stages map their own failures; this is the last-resort net.
|
// Stages map their own failures; this is the last-resort net.
|
||||||
log.error("command-crashed", {
|
log.error("command-crashed", {
|
||||||
|
|||||||
247
apps/runner/src/stages/clone.test.ts
Normal file
247
apps/runner/src/stages/clone.test.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildCloneInvocation,
|
||||||
|
cloneRepository,
|
||||||
|
mapCloneFailure,
|
||||||
|
repoPath,
|
||||||
|
validateGitUrl,
|
||||||
|
} from "@/stages/clone";
|
||||||
|
import { StageError } from "@/stages/stage-runner";
|
||||||
|
import { PAT_ENV_VAR } from "@/git/credential-helper";
|
||||||
|
import { createLogger } from "@/log";
|
||||||
|
import type { CommandResult, RunCommand } from "@/exec";
|
||||||
|
|
||||||
|
const PAT = "ghp_never-in-argv-or-config";
|
||||||
|
|
||||||
|
function fakeRun(result: Partial<CommandResult>): {
|
||||||
|
run: RunCommand;
|
||||||
|
calls: unknown[][];
|
||||||
|
} {
|
||||||
|
const calls: unknown[][] = [];
|
||||||
|
const run: RunCommand = async (...args) => {
|
||||||
|
calls.push(args);
|
||||||
|
return { exitCode: 0, stdout: "", stderr: "", ...result };
|
||||||
|
};
|
||||||
|
return { run, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("validateGitUrl", () => {
|
||||||
|
it("accepts git, http(s), and file URLs", () => {
|
||||||
|
expect(validateGitUrl("git://127.0.0.1:9418/vite-kitchen.git")).toBeNull();
|
||||||
|
expect(validateGitUrl("https://github.com/acme/app.git")).toBeNull();
|
||||||
|
expect(validateGitUrl("http://127.0.0.1:8080/repo.git")).toBeNull();
|
||||||
|
expect(validateGitUrl("file:///tmp/fixture.git")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects junk that is not a URL", () => {
|
||||||
|
expect(validateGitUrl("not a url at all")).toMatch(/not a valid URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects protocols the runner does not clone from", () => {
|
||||||
|
expect(validateGitUrl("ssh://git@github.com/acme/app.git")).toMatch(
|
||||||
|
/unsupported protocol/,
|
||||||
|
);
|
||||||
|
expect(validateGitUrl("ftp://example.com/repo.git")).toMatch(
|
||||||
|
/unsupported protocol/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildCloneInvocation", () => {
|
||||||
|
it("never places the PAT in argv — env is the only channel (spec §6)", () => {
|
||||||
|
const invocation = buildCloneInvocation(
|
||||||
|
"https://example.com/a.git",
|
||||||
|
"/ws/repo",
|
||||||
|
PAT,
|
||||||
|
);
|
||||||
|
expect(invocation.args.join(" ")).not.toContain(PAT);
|
||||||
|
expect(invocation.env[PAT_ENV_VAR]).toBe(PAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders helpers blank-first and ends options with -- before the URL", () => {
|
||||||
|
const invocation = buildCloneInvocation(
|
||||||
|
"https://example.com/a.git",
|
||||||
|
"/ws/repo",
|
||||||
|
PAT,
|
||||||
|
);
|
||||||
|
const flat = invocation.args;
|
||||||
|
expect(flat.indexOf("credential.helper=")).toBeLessThan(
|
||||||
|
flat.findIndex((arg) => arg.startsWith("credential.helper=!f()")),
|
||||||
|
);
|
||||||
|
expect(flat.slice(-3)).toEqual([
|
||||||
|
"--",
|
||||||
|
"https://example.com/a.git",
|
||||||
|
"/ws/repo",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the veect helper (but keeps the suppressor) without a PAT", () => {
|
||||||
|
const invocation = buildCloneInvocation("git://h/a.git", "/ws/repo");
|
||||||
|
expect(
|
||||||
|
invocation.args.filter((arg) => arg.startsWith("credential.helper")),
|
||||||
|
).toEqual(["credential.helper="]);
|
||||||
|
expect(invocation.env[PAT_ENV_VAR]).toBeUndefined();
|
||||||
|
expect(invocation.env.GIT_TERMINAL_PROMPT).toBe("0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapCloneFailure", () => {
|
||||||
|
it.each([
|
||||||
|
// git daemon refuses a repo outside its export root — the URL is
|
||||||
|
// wrong, and "access denied" here must NOT read as an auth failure.
|
||||||
|
[
|
||||||
|
"fatal: remote error: access denied or repository not exported: /nope.git",
|
||||||
|
"invalid-git-url",
|
||||||
|
],
|
||||||
|
["fatal: repository 'https://x.test/a.git/' not found", "invalid-git-url"],
|
||||||
|
[
|
||||||
|
"fatal: unable to access 'https://x.test/a.git/': The requested URL returned error: 404",
|
||||||
|
"invalid-git-url",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"fatal: unable to access 'https://gone.test/a.git/': Could not resolve host: gone.test",
|
||||||
|
"invalid-git-url",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"fatal: '/tmp/missing.git' does not appear to be a git repository",
|
||||||
|
"invalid-git-url",
|
||||||
|
],
|
||||||
|
["fatal: Authentication failed for 'https://x.test/a.git/'", "auth-failed"],
|
||||||
|
[
|
||||||
|
"fatal: could not read Username for 'https://x.test': terminal prompts disabled",
|
||||||
|
"auth-failed",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"fatal: unable to access 'https://x.test/a.git/': The requested URL returned error: 401",
|
||||||
|
"auth-failed",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"fatal: unable to access 'https://x.test/a.git/': The requested URL returned error: 403",
|
||||||
|
"auth-failed",
|
||||||
|
],
|
||||||
|
["remote: Invalid username or password.", "auth-failed"],
|
||||||
|
["fatal: the remote end hung up unexpectedly", "clone-failed"],
|
||||||
|
["", "clone-failed"],
|
||||||
|
])("maps %j to %s", (stderr, cause) => {
|
||||||
|
const error = mapCloneFailure(stderr);
|
||||||
|
expect(error).toBeInstanceOf(StageError);
|
||||||
|
expect(error.namedCause).toBe(cause);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only a bounded single-line tail of stderr in the message", () => {
|
||||||
|
const error = mapCloneFailure(`line one\nline two\n${"x".repeat(1000)}`);
|
||||||
|
expect(error.message).not.toContain("\n");
|
||||||
|
expect(error.message.length).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cloneRepository", () => {
|
||||||
|
it("rejects an invalid URL before ever spawning git", async () => {
|
||||||
|
const { run, calls } = fakeRun({});
|
||||||
|
const clone = cloneRepository({
|
||||||
|
workspaceDir: "/ws",
|
||||||
|
runCommand: run,
|
||||||
|
log: createLogger(() => undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
clone({ type: "clone", gitUrl: "not a url" }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
namedCause: "invalid-git-url",
|
||||||
|
});
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawns git clone into <workspaceDir>/repo and resolves the dest", async () => {
|
||||||
|
const workspaceDir = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "veect-clone-unit-"),
|
||||||
|
);
|
||||||
|
const { run, calls } = fakeRun({});
|
||||||
|
const clone = cloneRepository({
|
||||||
|
workspaceDir,
|
||||||
|
runCommand: run,
|
||||||
|
log: createLogger(() => undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dest = await clone({
|
||||||
|
type: "clone",
|
||||||
|
gitUrl: "git://h/a.git",
|
||||||
|
pat: PAT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dest).toBe(repoPath(workspaceDir));
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const [command, args, options] = calls[0] as [
|
||||||
|
string,
|
||||||
|
string[],
|
||||||
|
{ env: Record<string, string> },
|
||||||
|
];
|
||||||
|
expect(command).toBe("git");
|
||||||
|
expect(args).toContain("clone");
|
||||||
|
expect(args.join(" ")).not.toContain(PAT);
|
||||||
|
expect(options.env[PAT_ENV_VAR]).toBe(PAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a stale <workspaceDir>/repo before cloning (idempotent retry)", async () => {
|
||||||
|
const workspaceDir = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "veect-clone-unit-"),
|
||||||
|
);
|
||||||
|
const stale = path.join(repoPath(workspaceDir), "stale.txt");
|
||||||
|
await mkdir(repoPath(workspaceDir), { recursive: true });
|
||||||
|
await writeFile(stale, "old");
|
||||||
|
const { run } = fakeRun({});
|
||||||
|
const clone = cloneRepository({
|
||||||
|
workspaceDir,
|
||||||
|
runCommand: run,
|
||||||
|
log: createLogger(() => undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
await clone({ type: "clone", gitUrl: "git://h/a.git" });
|
||||||
|
|
||||||
|
expect(existsSync(stale)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws the mapped StageError when git exits non-zero", async () => {
|
||||||
|
const workspaceDir = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "veect-clone-unit-"),
|
||||||
|
);
|
||||||
|
const { run } = fakeRun({
|
||||||
|
exitCode: 128,
|
||||||
|
stderr: "fatal: Authentication failed for 'https://x.test/a.git/'",
|
||||||
|
});
|
||||||
|
const clone = cloneRepository({
|
||||||
|
workspaceDir,
|
||||||
|
runCommand: run,
|
||||||
|
log: createLogger(() => undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
clone({ type: "clone", gitUrl: "https://x.test/a.git" }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
namedCause: "auth-failed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs the spawned argv (secret-free) and never the PAT", async () => {
|
||||||
|
const workspaceDir = await mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "veect-clone-unit-"),
|
||||||
|
);
|
||||||
|
const lines: string[] = [];
|
||||||
|
const { run } = fakeRun({});
|
||||||
|
const clone = cloneRepository({
|
||||||
|
workspaceDir,
|
||||||
|
runCommand: run,
|
||||||
|
log: createLogger((line) => lines.push(line)),
|
||||||
|
});
|
||||||
|
|
||||||
|
await clone({ type: "clone", gitUrl: "git://h/a.git", pat: PAT });
|
||||||
|
|
||||||
|
const logged = lines.join("\n");
|
||||||
|
expect(logged).toContain("git-spawn");
|
||||||
|
expect(logged).not.toContain(PAT);
|
||||||
|
});
|
||||||
|
});
|
||||||
156
apps/runner/src/stages/clone.ts
Normal file
156
apps/runner/src/stages/clone.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { CloneMessage } from "@repo/core-runner-protocol";
|
||||||
|
import {
|
||||||
|
buildCredentialHelperArgs,
|
||||||
|
buildCredentialHelperEnv,
|
||||||
|
} from "../git/credential-helper";
|
||||||
|
import type { RunCommand } from "../exec";
|
||||||
|
import type { Logger } from "../log";
|
||||||
|
import { StageError } from "./stage-runner";
|
||||||
|
|
||||||
|
/** Where the workspace's clone lives: `<workspaceDir>/repo`. */
|
||||||
|
export const REPO_DIR_NAME = "repo";
|
||||||
|
|
||||||
|
export function repoPath(workspaceDir: string): string {
|
||||||
|
return path.join(workspaceDir, REPO_DIR_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protocols the runner will clone from. SSH is deliberately absent for
|
||||||
|
* cloud runners (ADR-027 retires SSH-via-agent there); `file:` and
|
||||||
|
* `git:` exist for the fixture-serving integration suites.
|
||||||
|
*/
|
||||||
|
const ALLOWED_PROTOCOLS = new Set(["git:", "http:", "https:", "file:"]);
|
||||||
|
|
||||||
|
/** Returns a human-readable rejection, or null when the URL is usable. */
|
||||||
|
export function validateGitUrl(gitUrl: string): string | null {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(gitUrl);
|
||||||
|
} catch {
|
||||||
|
return `"${gitUrl}" is not a valid URL`;
|
||||||
|
}
|
||||||
|
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
|
||||||
|
return `unsupported protocol "${url.protocol}" (allowed: git:, http:, https:, file:)`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitInvocation {
|
||||||
|
/** argv for `git` — contains no secret material by construction. */
|
||||||
|
args: string[];
|
||||||
|
/** Env additions for the child — the only channel a PAT travels on. */
|
||||||
|
env: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the clone invocation per spec §6/§10: blank-then-veect
|
||||||
|
* credential helpers, array args, `--` end-of-options, credential via
|
||||||
|
* child env only.
|
||||||
|
*/
|
||||||
|
export function buildCloneInvocation(
|
||||||
|
gitUrl: string,
|
||||||
|
dest: string,
|
||||||
|
pat?: string,
|
||||||
|
): GitInvocation {
|
||||||
|
return {
|
||||||
|
args: [
|
||||||
|
...buildCredentialHelperArgs(pat !== undefined),
|
||||||
|
"clone",
|
||||||
|
"--",
|
||||||
|
gitUrl,
|
||||||
|
dest,
|
||||||
|
],
|
||||||
|
env: buildCredentialHelperEnv(pat),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordering matters: the bad-URL signatures are checked first because
|
||||||
|
* `git daemon`'s "access denied or repository not exported" would
|
||||||
|
* otherwise false-positive the auth patterns; over the git protocol
|
||||||
|
* there is no authentication at all.
|
||||||
|
*/
|
||||||
|
const BAD_URL_PATTERNS: RegExp[] = [
|
||||||
|
/repository not exported/i, // git daemon: not-exported path
|
||||||
|
/repository .* not found/i,
|
||||||
|
/returned error: 404/i,
|
||||||
|
/could not resolve host/i,
|
||||||
|
/does not appear to be a git repository/i,
|
||||||
|
/no such file or directory/i, // file:// to a missing path
|
||||||
|
/protocol .* is not supported/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUTH_FAILURE_PATTERNS: RegExp[] = [
|
||||||
|
/authentication failed/i,
|
||||||
|
/invalid username or password/i,
|
||||||
|
/could not read username/i,
|
||||||
|
/could not read password/i,
|
||||||
|
/returned error: 401/i,
|
||||||
|
/returned error: 403/i,
|
||||||
|
/access denied/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Trimmed single-line tail of git's stderr for the protocol error message. */
|
||||||
|
function stderrTail(stderr: string): string {
|
||||||
|
const flat = stderr
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" | ");
|
||||||
|
return flat.length > 400 ? `…${flat.slice(-400)}` : flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a failed `git clone` to a named protocol cause (PRD user story 5). */
|
||||||
|
export function mapCloneFailure(stderr: string): StageError {
|
||||||
|
const detail = stderrTail(stderr) || "git clone failed with no output";
|
||||||
|
if (BAD_URL_PATTERNS.some((pattern) => pattern.test(stderr))) {
|
||||||
|
return new StageError(
|
||||||
|
"invalid-git-url",
|
||||||
|
`git could not reach the repository: ${detail}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(stderr))) {
|
||||||
|
return new StageError(
|
||||||
|
"auth-failed",
|
||||||
|
`git authentication failed: ${detail}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new StageError("clone-failed", `git clone failed: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloneStageDeps {
|
||||||
|
workspaceDir: string;
|
||||||
|
runCommand: RunCommand;
|
||||||
|
log: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The clone stage: validate URL → reset `<workspaceDir>/repo` (idempotent
|
||||||
|
* retry) → `git clone` with the ephemeral credential helper. Throws
|
||||||
|
* `StageError` with a named cause on every failure path.
|
||||||
|
*/
|
||||||
|
export function cloneRepository(deps: CloneStageDeps) {
|
||||||
|
return async (message: CloneMessage): Promise<string> => {
|
||||||
|
const rejection = validateGitUrl(message.gitUrl);
|
||||||
|
if (rejection !== null) {
|
||||||
|
throw new StageError("invalid-git-url", rejection);
|
||||||
|
}
|
||||||
|
const dest = repoPath(deps.workspaceDir);
|
||||||
|
await rm(dest, { recursive: true, force: true });
|
||||||
|
const invocation = buildCloneInvocation(message.gitUrl, dest, message.pat);
|
||||||
|
// argv is secret-free by construction; logging it lets the
|
||||||
|
// integration suite grep the spawned invocation for leaks (spec §15).
|
||||||
|
deps.log.info("git-spawn", { args: invocation.args });
|
||||||
|
const result = await deps.runCommand("git", invocation.args, {
|
||||||
|
cwd: deps.workspaceDir,
|
||||||
|
env: invocation.env,
|
||||||
|
});
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw mapCloneFailure(result.stderr);
|
||||||
|
}
|
||||||
|
return dest;
|
||||||
|
};
|
||||||
|
}
|
||||||
115
apps/runner/src/stages/stage-runner.test.ts
Normal file
115
apps/runner/src/stages/stage-runner.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { RunnerErrorCause, RunnerStage } from "@repo/core-runner-protocol";
|
||||||
|
import { runStage, StageError, type StageEmitter } from "@/stages/stage-runner";
|
||||||
|
import { createLogger } from "@/log";
|
||||||
|
|
||||||
|
interface Recorded {
|
||||||
|
statuses: { stage: RunnerStage; elapsedMs: number }[];
|
||||||
|
errors: { cause: RunnerErrorCause; message: string; stage?: RunnerStage }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordingEmitter(): { emit: StageEmitter; recorded: Recorded } {
|
||||||
|
const recorded: Recorded = { statuses: [], errors: [] };
|
||||||
|
return {
|
||||||
|
recorded,
|
||||||
|
emit: {
|
||||||
|
status: (stage, elapsedMs) =>
|
||||||
|
recorded.statuses.push({ stage, elapsedMs }),
|
||||||
|
error: (cause, message, stage) =>
|
||||||
|
recorded.errors.push({ cause, message, stage }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(emit: StageEmitter, heartbeatMs = 10_000) {
|
||||||
|
return { emit, heartbeatMs, log: createLogger(() => undefined) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
describe("runStage", () => {
|
||||||
|
it("emits a start status (0ms) and a final status on success", async () => {
|
||||||
|
const { emit, recorded } = recordingEmitter();
|
||||||
|
const result = await runStage(
|
||||||
|
"cloning",
|
||||||
|
"clone-failed",
|
||||||
|
options(emit),
|
||||||
|
async () => 42,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, value: 42 });
|
||||||
|
expect(recorded.errors).toEqual([]);
|
||||||
|
expect(recorded.statuses.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(recorded.statuses[0]).toEqual({ stage: "cloning", elapsedMs: 0 });
|
||||||
|
const last = recorded.statuses.at(-1);
|
||||||
|
expect(last?.stage).toBe("cloning");
|
||||||
|
expect(last?.elapsedMs).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams heartbeat statuses with nondecreasing elapsedMs while work runs", async () => {
|
||||||
|
const { emit, recorded } = recordingEmitter();
|
||||||
|
await runStage("installing", "install-failed", options(emit, 20), () =>
|
||||||
|
delay(150),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(recorded.statuses.length).toBeGreaterThanOrEqual(4);
|
||||||
|
const elapsed = recorded.statuses.map((s) => s.elapsedMs);
|
||||||
|
expect([...elapsed].sort((a, b) => a - b)).toEqual(elapsed);
|
||||||
|
expect(recorded.statuses.every((s) => s.stage === "installing")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops heartbeats once the stage completes", async () => {
|
||||||
|
const { emit, recorded } = recordingEmitter();
|
||||||
|
await runStage(
|
||||||
|
"cloning",
|
||||||
|
"clone-failed",
|
||||||
|
options(emit, 10),
|
||||||
|
async () => "done",
|
||||||
|
);
|
||||||
|
const count = recorded.statuses.length;
|
||||||
|
await delay(60);
|
||||||
|
expect(recorded.statuses.length).toBe(count);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a StageError to its named cause with the failing stage", async () => {
|
||||||
|
const { emit, recorded } = recordingEmitter();
|
||||||
|
const result = await runStage(
|
||||||
|
"cloning",
|
||||||
|
"clone-failed",
|
||||||
|
options(emit),
|
||||||
|
async () => {
|
||||||
|
throw new StageError("invalid-git-url", "nope");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false });
|
||||||
|
expect(recorded.errors).toEqual([
|
||||||
|
{ cause: "invalid-git-url", message: "nope", stage: "cloning" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an unexpected throw to the stage's fallback cause", async () => {
|
||||||
|
const { emit, recorded } = recordingEmitter();
|
||||||
|
const result = await runStage(
|
||||||
|
"installing",
|
||||||
|
"install-failed",
|
||||||
|
options(emit),
|
||||||
|
async () => {
|
||||||
|
throw new Error("ECONNRESET");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false });
|
||||||
|
expect(recorded.errors).toEqual([
|
||||||
|
{ cause: "install-failed", message: "ECONNRESET", stage: "installing" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stringifies non-Error throws", async () => {
|
||||||
|
const { emit, recorded } = recordingEmitter();
|
||||||
|
await runStage("cloning", "clone-failed", options(emit), async () => {
|
||||||
|
throw "raw string";
|
||||||
|
});
|
||||||
|
expect(recorded.errors[0]?.message).toBe("raw string");
|
||||||
|
});
|
||||||
|
});
|
||||||
67
apps/runner/src/stages/stage-runner.ts
Normal file
67
apps/runner/src/stages/stage-runner.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { RunnerErrorCause, RunnerStage } from "@repo/core-runner-protocol";
|
||||||
|
import type { Logger } from "../log";
|
||||||
|
|
||||||
|
/** A stage failure that already knows its named protocol cause. */
|
||||||
|
export class StageError extends Error {
|
||||||
|
readonly namedCause: RunnerErrorCause;
|
||||||
|
|
||||||
|
constructor(namedCause: RunnerErrorCause, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "StageError";
|
||||||
|
this.namedCause = namedCause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a stage talks back to the connected client. */
|
||||||
|
export interface StageEmitter {
|
||||||
|
status: (stage: RunnerStage, elapsedMs: number) => void;
|
||||||
|
error: (
|
||||||
|
cause: RunnerErrorCause,
|
||||||
|
message: string,
|
||||||
|
stage?: RunnerStage,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StageRunOptions {
|
||||||
|
emit: StageEmitter;
|
||||||
|
heartbeatMs: number;
|
||||||
|
log: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StageResult<T> = { ok: true; value: T } | { ok: false };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one stage with staged, honest progress (ui-gap §5): a `status` at
|
||||||
|
* start (elapsedMs 0), heartbeat `status` events while the work runs, a
|
||||||
|
* final `status` with the total elapsed on success. On failure, emits
|
||||||
|
* one named `error` event (`StageError` carries the cause; anything else
|
||||||
|
* maps to `fallbackCause`) — never a blank board.
|
||||||
|
*/
|
||||||
|
export async function runStage<T>(
|
||||||
|
stage: RunnerStage,
|
||||||
|
fallbackCause: RunnerErrorCause,
|
||||||
|
options: StageRunOptions,
|
||||||
|
work: () => Promise<T>,
|
||||||
|
): Promise<StageResult<T>> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const elapsed = (): number => Math.max(0, Math.round(Date.now() - startedAt));
|
||||||
|
options.emit.status(stage, 0);
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
options.emit.status(stage, elapsed());
|
||||||
|
}, options.heartbeatMs);
|
||||||
|
try {
|
||||||
|
const value = await work();
|
||||||
|
options.emit.status(stage, elapsed());
|
||||||
|
options.log.info("stage-complete", { stage, elapsedMs: elapsed() });
|
||||||
|
return { ok: true, value };
|
||||||
|
} catch (error) {
|
||||||
|
const cause =
|
||||||
|
error instanceof StageError ? error.namedCause : fallbackCause;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
options.log.error("stage-failed", { stage, cause });
|
||||||
|
options.emit.error(cause, message, stage);
|
||||||
|
return { ok: false };
|
||||||
|
} finally {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
}
|
||||||
|
}
|
||||||
272
apps/runner/tests/clone-stage.integration.test.ts
Normal file
272
apps/runner/tests/clone-stage.integration.test.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Clone-stage integration suite: real WS protocol session, real `git`
|
||||||
|
* subprocesses, real transports.
|
||||||
|
*
|
||||||
|
* Transport per concern:
|
||||||
|
* - `git daemon` (story 01's helper) proves the primary clone path and
|
||||||
|
* the named bad-URL failure — the git protocol has NO authentication,
|
||||||
|
* so it cannot prove credential delivery.
|
||||||
|
* - An authenticated dumb-HTTP server over the same bare repo proves the
|
||||||
|
* ephemeral-credential-helper mechanics end to end: git receives a 401,
|
||||||
|
* consults the helper, retries with Basic auth — and the server records
|
||||||
|
* exactly which credential arrived. Success with the right PAT + the
|
||||||
|
* named `auth-failed` failure with a wrong/missing PAT.
|
||||||
|
*
|
||||||
|
* Credential-leak assertions (spec §15) ride the success path: the PAT
|
||||||
|
* must appear nowhere in the runner's logs (which include every spawned
|
||||||
|
* git argv), nowhere under the clone's `.git/config`, and nowhere in the
|
||||||
|
* clone's stored remote URL.
|
||||||
|
*/
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
afterAll,
|
||||||
|
afterEach,
|
||||||
|
beforeAll,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from "vitest";
|
||||||
|
import {
|
||||||
|
serveFixtureRepo,
|
||||||
|
type ServedFixtureRepo,
|
||||||
|
} from "@repo/core-testing/git";
|
||||||
|
import { startRunnerServer, type RunnerServer } from "@/server";
|
||||||
|
import { createLogger } from "@/log";
|
||||||
|
import { repoPath } from "@/stages/clone";
|
||||||
|
import { PAT_USERNAME } from "@/git/credential-helper";
|
||||||
|
import {
|
||||||
|
connectProtocolClient,
|
||||||
|
expectNamedError,
|
||||||
|
handshake,
|
||||||
|
sendAndAwaitReady,
|
||||||
|
type ProtocolClient,
|
||||||
|
} from "./protocol-client";
|
||||||
|
import {
|
||||||
|
serveBareRepoOverHttp,
|
||||||
|
type AuthedGitHttpServer,
|
||||||
|
} from "./http-git-server";
|
||||||
|
|
||||||
|
const TOKEN = "clone-suite-token";
|
||||||
|
const PAT = "ghp_integration-pat-7f3a9";
|
||||||
|
|
||||||
|
/** repo-root/fixtures/vite-kitchen, resolved from this file's location. */
|
||||||
|
const FIXTURE_DIR = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"../../../fixtures/vite-kitchen",
|
||||||
|
);
|
||||||
|
|
||||||
|
let served: ServedFixtureRepo;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// One served fixture for the whole suite (cheap teardown per test is
|
||||||
|
// the runner server, not the daemon).
|
||||||
|
served = await serveFixtureRepo(FIXTURE_DIR);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await served.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
let server: RunnerServer;
|
||||||
|
let workspaceDir: string;
|
||||||
|
let logLines: string[];
|
||||||
|
let clients: ProtocolClient[];
|
||||||
|
|
||||||
|
async function startSession(): Promise<ProtocolClient> {
|
||||||
|
workspaceDir = await mkdtemp(path.join(os.tmpdir(), "veect-clone-int-"));
|
||||||
|
logLines = [];
|
||||||
|
server = await startRunnerServer({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
token: TOKEN,
|
||||||
|
workspaceDir,
|
||||||
|
heartbeatMs: 100,
|
||||||
|
log: createLogger((line) => logLines.push(line)),
|
||||||
|
});
|
||||||
|
const client = await connectProtocolClient(server.port, TOKEN);
|
||||||
|
clients.push(client);
|
||||||
|
await handshake(client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clients = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const client of clients) client.close();
|
||||||
|
await server.close();
|
||||||
|
await rm(workspaceDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Every file under the clone's .git that could persist a credential. */
|
||||||
|
async function gitDirLeakSurface(cloneDir: string): Promise<string> {
|
||||||
|
const gitDir = path.join(cloneDir, ".git");
|
||||||
|
const parts: string[] = [];
|
||||||
|
const walk = async (dir: string): Promise<void> => {
|
||||||
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === "objects") continue; // packfiles: binary repo data
|
||||||
|
await walk(full);
|
||||||
|
} else {
|
||||||
|
parts.push(await readFile(full, "utf8").catch(() => ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await walk(gitDir);
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leak assertions (spec §15): the PAT must appear nowhere in the runner
|
||||||
|
* logs (which include every spawned git argv), the clone's
|
||||||
|
* `.git/config`, or any other file under `.git/`.
|
||||||
|
*/
|
||||||
|
async function expectNoCredentialLeak(cloneDir: string): Promise<void> {
|
||||||
|
expect(logLines.join("\n")).not.toContain(PAT);
|
||||||
|
const gitConfig = await readFile(
|
||||||
|
path.join(cloneDir, ".git", "config"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(gitConfig).not.toContain(PAT);
|
||||||
|
expect(await gitDirLeakSurface(cloneDir)).not.toContain(PAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("clone via git daemon (primary transport)", () => {
|
||||||
|
it("clones vite-kitchen with staged progress and no credential leakage", async () => {
|
||||||
|
const client = await startSession();
|
||||||
|
// A PAT is supplied even though the git protocol never asks for it —
|
||||||
|
// the helper env rides the invocation, so the leak assertions below
|
||||||
|
// are exercised against a real spawned git.
|
||||||
|
await sendAndAwaitReady(
|
||||||
|
client,
|
||||||
|
{ type: "clone", gitUrl: served.cloneUrl, pat: PAT },
|
||||||
|
"clone completion",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Staged, honest progress: first cloning status is 0ms; elapsed is
|
||||||
|
// nondecreasing; completion signalled by ready.
|
||||||
|
const statuses = client
|
||||||
|
.received()
|
||||||
|
.flatMap((m) => (m.type === "status" ? [m] : []));
|
||||||
|
expect(statuses.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(statuses[0]).toEqual({
|
||||||
|
type: "status",
|
||||||
|
stage: "cloning",
|
||||||
|
elapsedMs: 0,
|
||||||
|
});
|
||||||
|
const elapsed = statuses.map((s) => s.elapsedMs);
|
||||||
|
expect([...elapsed].sort((a, b) => a - b)).toEqual(elapsed);
|
||||||
|
|
||||||
|
// The clone actually landed, and the stored remote URL is the
|
||||||
|
// credential-free URL, verbatim.
|
||||||
|
const cloneDir = repoPath(workspaceDir);
|
||||||
|
expect(existsSync(path.join(cloneDir, "package.json"))).toBe(true);
|
||||||
|
const gitConfig = await readFile(
|
||||||
|
path.join(cloneDir, ".git", "config"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(gitConfig).toContain(served.cloneUrl);
|
||||||
|
|
||||||
|
await expectNoCredentialLeak(cloneDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the named invalid-git-url failure for a repo the daemon does not export", async () => {
|
||||||
|
const client = await startSession();
|
||||||
|
const base = served.cloneUrl.replace(/\/[^/]+$/, "");
|
||||||
|
client.send({
|
||||||
|
type: "clone",
|
||||||
|
gitUrl: `${base}/definitely-not-exported.git`,
|
||||||
|
});
|
||||||
|
const error = await expectNamedError(client, "invalid-git-url");
|
||||||
|
expect(error.stage).toBe("cloning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the named invalid-git-url failure for a malformed URL without spawning git", async () => {
|
||||||
|
const client = await startSession();
|
||||||
|
client.send({ type: "clone", gitUrl: "not a url at all" });
|
||||||
|
const error = await expectNamedError(client, "invalid-git-url");
|
||||||
|
expect(error.stage).toBe("cloning");
|
||||||
|
expect(logLines.join("\n")).not.toContain("git-spawn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the session usable after a failed clone (corrected retry succeeds)", async () => {
|
||||||
|
const client = await startSession();
|
||||||
|
client.send({ type: "clone", gitUrl: "not a url at all" });
|
||||||
|
await expectNamedError(client, "invalid-git-url");
|
||||||
|
await sendAndAwaitReady(
|
||||||
|
client,
|
||||||
|
{ type: "clone", gitUrl: served.cloneUrl },
|
||||||
|
"retried clone completion",
|
||||||
|
);
|
||||||
|
expect(existsSync(path.join(repoPath(workspaceDir), "package.json"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clone via authenticated HTTP (credential-helper mechanics)", () => {
|
||||||
|
let httpServer: AuthedGitHttpServer;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await httpServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers the PAT to git via the ephemeral helper — and only via it", async () => {
|
||||||
|
httpServer = await serveBareRepoOverHttp(served.bareRepoPath, {
|
||||||
|
username: PAT_USERNAME,
|
||||||
|
password: PAT,
|
||||||
|
});
|
||||||
|
const client = await startSession();
|
||||||
|
await sendAndAwaitReady(
|
||||||
|
client,
|
||||||
|
{ type: "clone", gitUrl: httpServer.cloneUrl, pat: PAT },
|
||||||
|
"authenticated clone completion",
|
||||||
|
);
|
||||||
|
|
||||||
|
// git's first probe is unauthenticated; the 401 made it consult the
|
||||||
|
// helper; the retried request carried exactly username:PAT.
|
||||||
|
expect(httpServer.unauthenticatedRequests()).toBeGreaterThanOrEqual(1);
|
||||||
|
const expected =
|
||||||
|
"Basic " + Buffer.from(`${PAT_USERNAME}:${PAT}`).toString("base64");
|
||||||
|
expect(httpServer.authorizationHeaders().length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(
|
||||||
|
httpServer.authorizationHeaders().every((header) => header === expected),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// And the credential persisted nowhere.
|
||||||
|
await expectNoCredentialLeak(repoPath(workspaceDir));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the named auth-failed failure for a wrong PAT", async () => {
|
||||||
|
httpServer = await serveBareRepoOverHttp(served.bareRepoPath, {
|
||||||
|
username: PAT_USERNAME,
|
||||||
|
password: PAT,
|
||||||
|
});
|
||||||
|
const client = await startSession();
|
||||||
|
client.send({
|
||||||
|
type: "clone",
|
||||||
|
gitUrl: httpServer.cloneUrl,
|
||||||
|
pat: "wrong-pat",
|
||||||
|
});
|
||||||
|
const error = await expectNamedError(client, "auth-failed");
|
||||||
|
expect(error.stage).toBe("cloning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the named auth-failed failure when the PAT is missing entirely", async () => {
|
||||||
|
httpServer = await serveBareRepoOverHttp(served.bareRepoPath, {
|
||||||
|
username: PAT_USERNAME,
|
||||||
|
password: PAT,
|
||||||
|
});
|
||||||
|
const client = await startSession();
|
||||||
|
client.send({ type: "clone", gitUrl: httpServer.cloneUrl });
|
||||||
|
const error = await expectNamedError(client, "auth-failed");
|
||||||
|
expect(error.stage).toBe("cloning");
|
||||||
|
});
|
||||||
|
});
|
||||||
107
apps/runner/tests/http-git-server.ts
Normal file
107
apps/runner/tests/http-git-server.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { once } from "node:events";
|
||||||
|
import { createReadStream } from "node:fs";
|
||||||
|
import { stat } from "node:fs/promises";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve a bare repository over authenticated "dumb" HTTP.
|
||||||
|
*
|
||||||
|
* `git daemon` (story 01's default transport) has no authentication, so
|
||||||
|
* it cannot exercise the ephemeral-credential-helper path. This helper
|
||||||
|
* can: a plain static-file server over the bare repo (dumb git-HTTP
|
||||||
|
* protocol — enabled by running `git update-server-info`) that rejects
|
||||||
|
* every request lacking the expected HTTP Basic credential with a 401 +
|
||||||
|
* `WWW-Authenticate`. A real `git clone` against it performs the real
|
||||||
|
* credential dance: unauthenticated probe → 401 → git consults the
|
||||||
|
* configured credential helper → retry with Basic auth.
|
||||||
|
*
|
||||||
|
* Every received Authorization header is recorded, so tests can assert
|
||||||
|
* the exact credential git presented — i.e. that the PAT reached git
|
||||||
|
* through the helper (and only through it).
|
||||||
|
*/
|
||||||
|
export interface AuthedGitHttpServer {
|
||||||
|
/** Clone URL: `http://127.0.0.1:<port>/repo.git` */
|
||||||
|
cloneUrl: string;
|
||||||
|
/** Every Authorization header received, in order. */
|
||||||
|
authorizationHeaders: () => string[];
|
||||||
|
/** Count of requests that arrived with no Authorization header. */
|
||||||
|
unauthenticatedRequests: () => number;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function serveBareRepoOverHttp(
|
||||||
|
bareRepoPath: string,
|
||||||
|
credentials: { username: string; password: string },
|
||||||
|
): Promise<AuthedGitHttpServer> {
|
||||||
|
// Generate info/refs + objects/info/packs so the dumb protocol works.
|
||||||
|
await execFileAsync("git", ["update-server-info"], { cwd: bareRepoPath });
|
||||||
|
|
||||||
|
const expected =
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(`${credentials.username}:${credentials.password}`).toString(
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
const received: string[] = [];
|
||||||
|
let unauthenticated = 0;
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if (auth === undefined) {
|
||||||
|
unauthenticated += 1;
|
||||||
|
} else {
|
||||||
|
received.push(auth);
|
||||||
|
}
|
||||||
|
if (auth !== expected) {
|
||||||
|
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="veect-fixture"' });
|
||||||
|
res.end("authentication required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0] ?? "/");
|
||||||
|
const relative = urlPath.replace(/^\/repo\.git\/?/, "");
|
||||||
|
const file = path.resolve(bareRepoPath, relative);
|
||||||
|
if (!file.startsWith(path.resolve(bareRepoPath) + path.sep)) {
|
||||||
|
res.writeHead(403);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void stat(file).then(
|
||||||
|
(stats) => {
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "content-type": "application/octet-stream" });
|
||||||
|
createReadStream(file).pipe(res);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
const address = server.address();
|
||||||
|
if (address === null || typeof address === "string") {
|
||||||
|
server.close();
|
||||||
|
throw new Error("serveBareRepoOverHttp: could not determine a port");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cloneUrl: `http://127.0.0.1:${address.port}/repo.git`,
|
||||||
|
authorizationHeaders: () => [...received],
|
||||||
|
unauthenticatedRequests: () => unauthenticated,
|
||||||
|
stop: () =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
server.closeAllConnections();
|
||||||
|
server.close((error) => (error ? reject(error) : resolve()));
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -115,6 +115,32 @@ export async function handshake(
|
|||||||
return client.waitFor((m) => m.type === "ready", "ready handshake reply");
|
return client.waitFor((m) => m.type === "ready", "ready handshake reply");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a command and resolve on the runner's NEXT `ready` — the
|
||||||
|
* protocol's success marker for a completed command. Counting readies
|
||||||
|
* (instead of matching any `ready`) keeps this correct on sessions that
|
||||||
|
* already completed earlier commands.
|
||||||
|
*/
|
||||||
|
export async function sendAndAwaitReady(
|
||||||
|
client: ProtocolClient,
|
||||||
|
message: RunnerMessage,
|
||||||
|
description: string,
|
||||||
|
timeoutMs?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const readiesBefore = client
|
||||||
|
.received()
|
||||||
|
.filter((m) => m.type === "ready").length;
|
||||||
|
client.send(message);
|
||||||
|
await client.waitFor(
|
||||||
|
(m) =>
|
||||||
|
m.type === "ready" &&
|
||||||
|
client.received().filter((r) => r.type === "ready").length >
|
||||||
|
readiesBefore,
|
||||||
|
description,
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Wait for the next `error` event and assert its named cause. */
|
/** Wait for the next `error` event and assert its named cause. */
|
||||||
export async function expectNamedError(
|
export async function expectNamedError(
|
||||||
client: ProtocolClient,
|
client: ProtocolClient,
|
||||||
|
|||||||
Reference in New Issue
Block a user