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:
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