# 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` (default `0` = ephemeral), `RUNNER_HOST` (default `127.0.0.1`), `RUNNER_HEARTBEAT_MS` (default `1000`). - **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`/`SIGINT` shut 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 answers `ready`. - Commands run one at a time in arrival order. A running stage streams `status` (stage + elapsedMs: start, heartbeats, final). Success ends with `ready` (the idle marker the story-07 orchestrator drives on); failure ends with a named `error` and 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=` (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:`, 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 contradicting `packageManager` field), then the `packageManager` field, then npm as the default (vite-kitchen's shape: no lockfile, no field). - The detected manager runs `install` in the clone (`npm` adds `--no-audit --no-fund`), streaming staged `status` heartbeats; every failure — including "install before clone" — is the named `install-failed` cause 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 in `tests/` (`protocol-server.test.ts` runs the real server in-process so v8 coverage sees it; `runner-process.integration.test.ts` spawns the real child process and discovers the port from stdout). - `tests/protocol-client.ts` envelope-parses every inbound frame, so every test doubles as an outbound-conformance assertion. - Coverage: app-tier vitest thresholds inherited from `vitest.base.node`; only `src/main.ts` (bootstrap glue, exercised by the spawn suite in a child process) is excluded.