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:
2026-07-12 22:38:17 +02:00
parent b090e26701
commit 750ab44379
12 changed files with 1261 additions and 20 deletions

View File

@@ -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", {