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
|
||||
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
|
||||
|
||||
- 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 RunnerStage,
|
||||
} from "@repo/core-runner-protocol";
|
||||
import { runCommand } from "./exec";
|
||||
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").
|
||||
@@ -137,38 +140,59 @@ export type CommandMessage = Extract<
|
||||
{ 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+). */
|
||||
const NOT_IMPLEMENTED: Record<
|
||||
CommandMessage["type"],
|
||||
Exclude<CommandMessage["type"], "clone">,
|
||||
{ cause: RunnerErrorCause; stage?: RunnerStage }
|
||||
> = {
|
||||
clone: { cause: "clone-failed", stage: "cloning" },
|
||||
install: { cause: "install-failed", stage: "installing" },
|
||||
scan: { cause: "scan-failed", stage: "scanning" },
|
||||
"adapter-start": { cause: "adapter-start-failed", stage: "starting-preview" },
|
||||
"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(
|
||||
command: CommandMessage,
|
||||
emit: StageEmitter,
|
||||
_options: RunnerServerOptions,
|
||||
ctx: CommandContext,
|
||||
): Promise<void> {
|
||||
const notImplemented = NOT_IMPLEMENTED[command.type];
|
||||
emit.error(
|
||||
notImplemented.cause,
|
||||
`the "${command.type}" command is not implemented by this runner build yet`,
|
||||
notImplemented.stage,
|
||||
);
|
||||
const { emit, options } = ctx;
|
||||
const stageOptions = {
|
||||
emit,
|
||||
heartbeatMs: options.heartbeatMs,
|
||||
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 {
|
||||
@@ -257,7 +281,11 @@ export async function startRunnerServer(
|
||||
log.info("command-received", { command: command.type });
|
||||
commandChain = commandChain.then(async () => {
|
||||
try {
|
||||
await handleCommand(command, emit, options);
|
||||
await handleCommand(command, {
|
||||
emit,
|
||||
ready: () => send({ type: "ready" }),
|
||||
options,
|
||||
});
|
||||
} catch (error) {
|
||||
// Stages map their own failures; this is the last-resort net.
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
export async function expectNamedError(
|
||||
client: ProtocolClient,
|
||||
|
||||
Reference in New Issue
Block a user