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:
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()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user