Install stage: package-manager detection (lockfile beats the packageManager field, npm default — vite-kitchen's shape), install run inside the clone with staged status heartbeats, and every failure — including install-before-clone — mapped to the named install-failed cause with a bounded output tail. The spawned-runner suite now runs the full clone → install pipeline on the daemon-served vite-kitchen (real npm registry install) and greps the child's entire stdout+stderr plus the clone's .git/config for the PAT and workspace token. Shared test doubles extracted (exec.mock.ts, tests/runner-session.ts) to keep the suites duplication-free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
5.1 KiB
apps/runner — workspace runner (cloud-runner process)
The runner process of ADR-027: executes a workspace's repo code (clone,
install, scan, preview adapter) on behalf of the control plane, speaking
@repo/core-runner-protocol over plain WS/JSON. App-tier (tags: ["app"]): imperative code is expected here; there is no feature
manifest, no use-case layer, no DI container.
Process contract (what the provisioner — story 06 — relies on)
- Config is env-only (see
src/config.ts):RUNNER_TOKEN(required, secret — never argv),RUNNER_WORKSPACE_DIR(required),RUNNER_PORT(default0= ephemeral),RUNNER_HOST(default127.0.0.1),RUNNER_HEARTBEAT_MS(default1000). - Port discovery: on boot the runner writes a JSON line to stdout:
{"level":"info","event":"listening","host":...,"port":...}. - Logs are JSON lines on stdout. Log event names + safe fields only — never protocol payloads, never credentials. Integration tests grep the entire child output for the PAT and the workspace token.
SIGTERM/SIGINTshut down gracefully.
Protocol session semantics (documented in src/server.ts)
- Every inbound/outbound frame is envelope-wrapped and zod-parsed
(
envelopeSchema); rejections emit named errors (unsupported-protocol-version/invalid-message/unauthorized). Pre-handshake rejections and token mismatches close the socket (1008). - First message must be
hello; the runner answersready. - Commands run one at a time in arrival order. A running stage streams
status(stage + elapsedMs: start, heartbeats, final). Success ends withready(the idle marker the story-07 orchestrator drives on); failure ends with a namederrorand the session stays open. - 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 answersgetfrom env vars and ignoresstore/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/configor anywhere else on disk. GIT_TERMINAL_PROMPT=0andGIT_ASKPASS=echoride 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.
Install stage
- Package-manager detection (
src/stages/install.ts): lockfile first (pnpm-lock.yaml / yarn.lock / bun.lockb / bun.lock / package-lock.json / npm-shrinkwrap.json — the lockfile pins install semantics, so it beats a contradictingpackageManagerfield), then thepackageManagerfield, then npm as the default (vite-kitchen's shape: no lockfile, no field). - The detected manager runs
installin the clone (npmadds--no-audit --no-fund), streaming stagedstatusheartbeats; every failure — including "install before clone" — is the namedinstall-failedcause with a bounded output tail as the message. - The in-process integration suite covers success on a minimal no-dependency fixture (real npm, instant, offline) and the named failure via a fixture depending on a package that cannot exist; the spawned-runner suite runs the full clone → install pipeline on vite-kitchen (real registry install) with the leak grep over the child's entire output.
Testing
- Unit suites live next to sources in
src/; protocol/WS suites live intests/(protocol-server.test.tsruns the real server in-process so v8 coverage sees it;runner-process.integration.test.tsspawns the real child process and discovers the port from stdout). tests/protocol-client.tsenvelope-parses every inbound frame, so every test doubles as an outbound-conformance assertion.- Coverage: app-tier vitest thresholds inherited from
vitest.base.node; onlysrc/main.ts(bootstrap glue, exercised by the spawn suite in a child process) is excluded.