@critical-labs/qa-conductor
v0.3.1
Published
Side-by-side pull request QA: runs your app's base and PR builds on cloned data, mirrors both panes, posts a verdict.
Maintainers
Readme
qa-conductor
A side-by-side PR-QA harness. For a pull request it boots two copies of your app: base (what's live now) and PR (the branch). Each runs against its own clone of real data, behind proxies that mirror scrolling and navigation between the two panes. A reviewer drives both at once and posts a verdict (a comment plus a label) back to the PR.
The conductor owns the choreography: session state, cancellation, the harness UI and API, the pane proxies and the verdict. Everything about your app and infrastructure comes from five adapters you supply, plus an optional sixth, Exposure, through which the conductor publishes itself on a front door such as tailscale serve.
Status: 0.x. The interface may still change: a 0.x minor version may break it, and so may a patch release when a fix needs to. Read the migration notes in CHANGELOG.md before upgrading, and to choose when that happens, install with
--save-exact: the^range npm saves by default takes patch releases.
Contents
- Try it, Requirements, Install
- Quickstart: QA your own app
- Use, with the Entry points
- Using the harness
- Security
- The seams: Types, Contracts, the built-in
build-worktree,provisioner-processandprovisioner-docker, the Effect wrappers, and Exposure - Configuration: The env file, Sample
.env.qafiles, Tokens, Keys, Acfgbuilt in code - HTTP API, Expose CLI, Demo
- Known limits
- Contributing, Reporting a vulnerability, License
Try it
git clone https://github.com/critical-labs/qa-conductor
cd qa-conductor
npm run demo # then open http://127.0.0.1:4100/The demo runs the real conductor against fixture PRs and fake adapters, so it needs nothing but Node: no GitHub token, containers or databases. demo/ is in the repository, not in the npm package. More in Demo.
Requirements
- Node 22 or later. The package has no runtime dependencies.
- Linux or macOS. Windows isn't supported: the process Provisioner signals process groups and reads
ps, and the Docker one runssh. - The tools each built-in adapter runs, on the conductor's
PATH:adapters/build-worktree:git, with worktree support;adapters/provisioner-process:ps, and to tell one boot of the machine from the next,/proc/sys/kernel/random/boot_idon Linux orsysctlon macOS;adapters/provisioner-dockerand./docker: thedockerCLI andsh, and a Docker daemon the conductor's user may use;adapters/exposure-tailscaleand the expose CLI: atailscaleCLI no older than the daemon (on macOS, the app's bundled one), run by a user the daemon lets changetailscale serve(on Linux, root or the usertailscale set --operatornames).
- No TypeScript declarations yet. The package is plain JavaScript (ES modules); the Types below describe what the adapters exchange.
Install
npm install @critical-labs/qa-conductorTo pin a git tag instead: npm install github:critical-labs/qa-conductor#v0.3.1.
Quickstart: QA your own app
This script is a whole conductor for an app that runs from a checkout with one node command and needs no database. It QAs acme/widget's pull requests on this machine: each pane is a git worktree, main and the PR head, running node server.js on a port of its own. It is this repository's own self-QA, qa/self.mjs, cut down.
- Create
.env.qabeside it, readable by you alone, since it holds a token, and keep it out of git:
Then put in it a GitHub token for the repository (see Tokens) and the repository's name:(umask 077 && touch .env.qa) echo .env.qa >> .gitignoreGITHUB_QA_TOKEN=<token> QA_REPO=acme/widget - Save this as
qa.mjs:import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { startConductor } from '@critical-labs/qa-conductor' import { loadConfig } from '@critical-labs/qa-conductor/config' import { createGithub } from '@critical-labs/qa-conductor/github' import { createWorktreeBuild } from '@critical-labs/qa-conductor/adapters/build-worktree' import { createProcessProvisioner } from '@critical-labs/qa-conductor/adapters/provisioner-process' // Panes on loopback: nothing outside this machine reaches them, so no identity gate. const cfg = loadConfig('.env.qa', { defaults: { QA_BASE_ORIGIN: 'http://127.0.0.1:3101', QA_PR_ORIGIN: 'http://127.0.0.1:3102' }, }) const github = createGithub({ token: cfg.githubToken, repo: cfg.repo, qaLabels: [cfg.verdictLabels.accept, cfg.verdictLabels.reject] }) const cacheDir = path.join(os.homedir(), '.cache/qa-conductor', cfg.repo.replace('/', '-')) const conductor = startConductor({ cfg, github, fsx: { readFile: p => fs.promises.readFile(p) }, adapters: { // main and each PR head in a git worktree, a PR only once it passes the trust gate build: createWorktreeBuild({ repo: cfg.repo, cacheDir: path.join(cacheDir, 'build'), github, servicesFor: dir => ({ app: dir }), install: { cmd: 'npm', args: ['ci', '--ignore-scripts'] }, }), // `node server.js` in each worktree, as a process group on 127.0.0.1 provisioner: createProcessProvisioner({ stateDir: path.join(cacheDir, 'state'), command: ({ ref }) => ({ cmd: process.execPath, args: ['server.js'], cwd: ref }), }), seed: { databases: [], seedPane: async () => {} }, envTransform: { derivePaneEnv: ({ pane }) => ({ app: { PORT: String(pane.services.app.port) } }) }, auth: { requiresDb: false, establishSession: async ({ pane }) => ({ landingUrl: `${pane.publicOrigin}/` }) }, }, }) for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) { process.on(sig, () => conductor.shutdown().then(() => process.exit(0))) } - Run
npm install @critical-labs/qa-conductorbeside it, thennode qa.mjs, openhttp://127.0.0.1:3100/qa/, and open a PR. Ctrl-C tears both panes down.
What the pieces do, and what to change for your app:
- The trust gate. A PR boots only when its author has write access to the repository and its head is in the repository or the author's own fork, since booting it runs its code as you. See
adapters/build-worktree. - Install and launch. The install runs with
--ignore-scripts. The server must be started directly, not throughnpm runornpx(the launch contract), and must listen on thePORTthe EnvTransform gives it, on127.0.0.1. It counts as up once/answers below500. - No data, no sign-in. The Seed clones nothing and the AuthBootstrap lands each pane on
/. To give each pane its own copy of a database, pass the Provisioner adatabaseand write aseedPane; to sign the reviewer in, return a sign-in URL fromestablishSession. The seams has the contracts. - Other devices. To open the harness from another machine, read Security, then Configuration's tailnet sample.
Use
import fs from 'node:fs'
import { startConductor } from '@critical-labs/qa-conductor'
import { loadConfig } from '@critical-labs/qa-conductor/config'
import { createGithub } from '@critical-labs/qa-conductor/github'
const cfg = loadConfig('/path/to/.env.qa', { defaults: { QA_REPO: 'acme/widget' } })
const github = createGithub({ token: cfg.githubToken, repo: cfg.repo, qaLabels: [cfg.verdictLabels.accept, cfg.verdictLabels.reject] })
const conductor = startConductor({
cfg,
github,
fsx: { readFile: p => fs.promises.readFile(p) }, // serves the harness UI files
adapters: { provisioner, build, seed, envTransform, auth }, // and optionally exposure
readBaseEnv: async () => ({ /* the env the pane env is derived from */ }),
})
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.on(sig, () => conductor.shutdown().then(() => process.exit(0)))
}startConductor takes:
| Option | Default | |
|---|---|---|
| cfg | (required) | the configuration, from loadConfig or built in code (see Configuration) |
| github | (required) | what the core calls on GitHub: createGithub(...), or anything with the members the core uses |
| fsx | (required) | { readFile(path) }, which serves the harness UI files from publicDir |
| adapters | (required) | { provisioner, build, seed, envTransform, auth }, plus exposure if you want one (The seams) |
| readBaseEnv | async () => ({}) | the env the panes' env is derived from, such as production's: passed to derivePaneEnv as prodEnv at each boot |
| publicDir | the package's public/ | the directory with the harness UI's index.html and harness.js and the panes' bridge.js |
| log | console | where the [qa] lines go: log.log and log.error |
It serves the harness on cfg.ports.harness and one proxy per pane on cfg.ports.base / cfg.ports.pr, all on cfg.host (127.0.0.1 by default). It returns { servers, stop(), shutdown(), exposure }:
shutdown()is the graceful exit. It stops the idle reaper and the exposure loop, refuses harness writes from then on (503, so no new boot can start), tears the session down (aborting an in-flight boot and tearing down both panes), ends the progress streams, closes every connection and resolves once all three servers are closed. Call it from your signal handlers; calling it again is a no-op.stop()only stops the reaper and the exposure loop, and asks the servers to close.exposureis the exposure loop's handle,{ ready, state(), reconcile() }.readyresolves with the state after the first pass, or as it stands whenstop()orshutdown()begins first (at once with no Exposure adapter).state()is the last pass, asGET /api/exposurereports it.reconcile()runs a pass now, or joins the one in flight; before all three servers listen there are no targets to mount yet, so it returnsready.
cfg.paneOrigins must be the URLs viewers actually reach the panes at, and cfg.harnessOrigin the one they open the harness at (see Configuration). To reach the harness from anywhere but the machine it runs on, read Security first, then either:
- put
tailscale serve --httpson the same host in front of these ports, in tailscale mode (QA_EXPOSURE=tailscale, the default for any layout that isn't loopback throughout): every server then answers only the Tailscale logins inQA_ALLOWED_LOGINS, and listens on loopback. Pass an Exposure adapter and the conductor sets up and keeps those mounts itself; or - put another TLS front door that authenticates viewers in front of them, and set
QA_EXPOSURE=none.
Entry points
| Import | Exports | |
|---|---|---|
| @critical-labs/qa-conductor | startConductor | the conductor: harness, pane proxies, sessions, the exposure loop (Use) |
| @critical-labs/qa-conductor/config | loadConfig, parseEnvFile, defaultExposure, defaultHarnessOrigin, isExposureInterval, EXPOSURE_MODES, EXPOSURE_INTERVAL_RULE, MAX_EXPOSURE_INTERVAL_MINUTES, HARNESS_PATH | reading .env.qa into cfg (Configuration), and the rules it checks |
| @critical-labs/qa-conductor/session | bootSession, teardownSession, createSession, reduce, touch, isIdle, ROLES, PANE_STAGES, parseEnv, renderEnv, migrateImageFor | the boot sequence and session state the conductor runs, and helpers for adapters: parsing and rendering env file text, and the migrate-<tag> image beside an app image |
| @critical-labs/qa-conductor/github | createGithub | the GitHub effect wrapper |
| @critical-labs/qa-conductor/docker | createDocker | the Docker effect wrapper |
| @critical-labs/qa-conductor/exec | makeExecFileFn | the execFile effect wrapper |
| @critical-labs/qa-conductor/identity | normalizeLogins, refusalReason, isAllowed, identityGate | the Tailscale identity gate |
| @critical-labs/qa-conductor/exposure | mountsFor, reconcileExposure, runExpose | one exposure pass, outside a conductor |
| @critical-labs/qa-conductor/proxy | createPaneProxy, panePolicy, parseSetCookie, isAllowedHost, requestHostname, misdirected | the pane proxy, and the Host allowlist check every server runs |
| @critical-labs/qa-conductor/verdict | formatVerdict, postVerdict | the verdict comment and label |
| @critical-labs/qa-conductor/adapters/provisioner-docker | createDockerProvisioner | a Provisioner that runs each pane as containers on the host's Docker daemon |
| @critical-labs/qa-conductor/adapters/provisioner-process | createProcessProvisioner | a Provisioner for local process groups |
| @critical-labs/qa-conductor/adapters/build-worktree | createWorktreeBuild, trustDecision | a BuildConvention that runs PRs from git worktrees, behind a trust gate |
| @critical-labs/qa-conductor/adapters/exposure-tailscale | createTailscaleExposure | an Exposure adapter on tailscale serve |
| @critical-labs/qa-conductor/package.json | (the manifest) | |
Each row lists every name its entry point exports, and no other entry point is exported. The package also installs one bin, qa-conductor-expose, the expose CLI, for operators.
Using the harness
Open the harness at its origin, under /qa/, as startup logs it ([qa] harness at <origin>/qa/); anywhere else it shows a banner that links there. Then:
- Pull requests. The picker lists the open PRs, at most the 50 most recently opened, with each one's build status:
ready — opens in seconds,building…,needs buildorcan't boot: <reason>. ↻ reloads the list and Open QA boots a PR. One session runs at a time: opening another PR offers Resume for the running one, or End #N and open #M to take over. - Boot. The four steps, Building, Preparing data, Migrating and Starting, each timed, with the BuildConvention's messages under the first. Cancel boot tears the boot down. A failed boot shows the error and the failing pane's log tail, with Retry boot.
- Mirror (
m) replays each click, key and form value from one pane in the other. Its ⌄ menu turns scroll mirroring off and on (s). - Re-sync → (
r) reloads the PR pane at the base pane's path, and its ⌄ menu the base pane at the PR pane's. A dot on it says the panes are at different paths. - 375, 768 and Full (
1,2,3) set both panes' width. - auto-end counts down to the idle teardown,
QA_IDLE_MINUTESafter the last interaction, and turns amber for the last five minutes. - End session (
e) tears the session down once you click it again within three seconds. - Each pane's bar has a dot (amber while signing in, green while the pane's bridge reports, grey after 10 seconds of silence), the pane's path, a count of interactions the other pane couldn't replay (click it to clear), and buttons to copy the path, reload the pane and open it in a new tab.
- Verdict (
v) opens the drawer: notes, kept in the browser as a draft for each PR, then Accept or Reject, a preview of the comment and the label change, and Post to PR #N. After posting: End session, Keep session open or Back to pull requests. ?lists the keyboard shortcuts, which work while the harness, not a pane, has the focus.Esccloses the drawer or the list, or cancels an end-session click. Below 900 pixels wide, the panes become BASE and PR tabs.
Security
The trust gate is the only real boundary between a PR's code and the reviewer's machine. Booting a PR runs its code. A BuildConvention that checks out and installs PRs must refuse untrusted ones in ensureBuilt, before any git call. Env scrubbing and loopback binding are defence in depth, not a boundary.
The conductor's own defences in depth:
Tailscale identity gate. In tailscale mode (
QA_EXPOSURE=tailscale), the harness and both pane proxies answer403to any request whoseTailscale-User-Loginisn't inQA_ALLOWED_LOGINS(matched ignoring case), before anything else runs. The gate wraps each whole server, so no route, present or future, runs for such a request, upgrades included, and the403still carries the harness frame lock or the pane's frame policy.tailscale servesets that header from the device the request came from, strips any copy the client sent, and sets none for tagged devices, which are refused. An empty allowlist refuses everyone, soloadConfigrefuses one in tailscale mode (startConductoronly logs an error). The pane proxies drop everyTailscale-*header before the pane app sees the request. Other front-door headers pass through, such asX-Forwarded-For, whichtailscale servesets to the viewer's tailnet address: the pane app, PR code included, still sees which device is viewing.QA_EXPOSUREdefaults totailscalewhen anything the conductor answers to or listens on is off loopback: the harness origin, either pane origin,QA_PUBLIC_HOST, aQA_ALLOWED_HOSTSentry orQA_BIND_HOST(see Configuration). Loopback layouts, such asnpm run qaand the demo, stay ungated with no config.The gate trusts the header from anything that can reach loopback on this host. That is why tailscale mode requires a loopback bind: on any other address, a client could send its own login. It also means the gate keeps out other devices, not code on this one. With
provisioner-process, the pane processes run PR code on the same host, as the same user, so they can reach loopback and send any login they like: in tailscale mode the gate does not protect against PR code, and only the BuildConvention's trust gate does. Containers don't change that everywhere. On native Linux Docker, a container on a bridge network can't reach the host's loopback, but Docker Desktop (throughhost.docker.internal) and some rootless runtimes forward to it, and there a container pane can send any login too.The gate works only behind
tailscale serve --https, which setsTailscale-User-Loginand drops any copy the client sent. Never put Funnel in front of the conductor, which opens it to the internet, nortailscale serve --tcpor--tls-terminated-tcp, which pass the client's bytes through unchanged, so a header it writes itself reaches the gate. And never puttailscale servein front of a conductor in none mode (QA_EXPOSURE=none): with no gate, every device on the tailnet gets in.Loopback by default. All three servers listen on
QA_BIND_HOST, default127.0.0.1, and tailscale mode refuses any other address (loadConfigandstartConductorthrow). In none mode, anything other than loopback exposes an unauthenticated API that returns pane login URLs and posts verdicts withGITHUB_QA_TOKEN. A pane proxy is an authenticated pane session, because the proxy holds the pane's cookie jar. Widen the bind only in none mode, behind a firewall or an authenticating front door, and have viewers reach the harness and the panes over https (a plain-http origin must be loopback, see the pane request guard below).Host allowlist. Every server checks the
Hostheader before routing (the harness does so before it even parses the request target) and answers421unless its hostname (port ignored,[]stripped) is127.0.0.1,localhost,::1,QA_PUBLIC_HOST, the hostname of the harness origin or of either pane origin, or an entry inQA_ALLOWED_HOSTS. This defeats DNS rebinding. A front door must pass the viewer'sHostthrough, or rewrite it to a loopbackHost. A rewrittenHostthat is allowed but not loopback (qa-conductor:3100behind nginx, say) clears this check, but every browser/api/*call then gets403 not the harness origin(see Same-origin API).Pane request guard. The proxy holds each pane's cookie jar, so every request that reaches a pane acts as the reviewer, and the reviewer's browser sends requests for any page they have open. Loopback and the Host allowlist don't stop that. So a pane answers
403to:- a write, a preflight, a CORS read or a WebSocket that doesn't come from the pane's own pages (
sec-fetch-sitesame-originornone, else anOriginwhose host is the request'sHost); - a subresource load (
<img>,<script>, a no-cors fetch) from another site; - a navigation from another site, into a frame or top-level, unless its
Refereris the harness origin, as it is for the harness's own iframes and "Open in new tab" links. A redirect the app answers such a navigation with keeps thatRefererfor the next hop, unless the redirect's ownReferrer-Policydrops it, so the proxy removes that header from a redirect that stays on the pane.
same-sitecounts as another site: on loopback every other port is same-site, and so is every host in a tailnet. A request the guard admits gets the whole jar.The guard works from Fetch Metadata (
sec-fetch-site,sec-fetch-mode,sec-fetch-dest), which browsers send only to https and loopback origins. A request without it (curl, an older browser) is judged by itsOriginalone, and another page's<img>,<iframe>or link sends noOrigin. Over plain http on any other host, the guard couldn't tell those from curl, soloadConfigandstartConductorrefuse anhttp:harness or pane origin whose host isn't loopback (127.0.0.0/8,::1,localhost).The guard keeps out other pages, not the panes' own apps. A pane's app sees the harness's navigations, and when it answers one with a redirect, the next hop keeps the harness
Refererwherever it goes. So the PR pane's app can redirect the harness's frame, or a new tab, to any URL of the base pane, which serves it with the reviewer's session. The trust gate is the boundary against PR code.- a write, a preflight, a CORS read or a WebSocket that doesn't come from the pane's own pages (
The pane apps get only their jar's cookies. A pane proxy keeps the app's cookies in its jar and never passes the app's
Set-Cookieon to the browser. Nor does it pass the browser's ownCookieheader on to the app. Cookies ignore ports, so that header carries every cookie for the pane's hostname: another app's on another port, its session among them, and any the other pane's scripts set withdocument.cookie. And the app runs PR code. An app that reads a cookie its own scripts set (a locale, say) gets it only whenQA_FORWARD_CLIENT_COOKIESnames it. The jar's value wins on a name both have, and any page on the hostname, the other pane included, can set that cookie. So such a cookie passes only when its value is an RFC 6265 cookie-value. A browser sends back whatever a page wrote, so a value with a space, a comma, a backslash or a stray double quote could hide another cookie (locale=fr, sid=evil) for a lenient parser to read, and the proxy drops it. The jar's cookies come first in the header the app gets. And each jar holds one session's cookies: the conductor gives both panes fresh jars when a session is torn down, when the next one starts booting and when it is ready, and detaches both panes while a session boots, so no PR's app gets the cookies an earlier session's app set.The panes' pages still share the browser's cookies with every app on their hostname. A pane's scripts (PR code) run there, so they can read that hostname's cookies that aren't
HttpOnly, and set cookies the browser then sends to every app on it. They can also send those apps requests that carry their cookies,HttpOnlyandSameSite=Strictones included (a form post, ano-corsfetch), because every port of the hostname, and every host in a tailnet, is same-site: PR code can act on the reviewer's session in another app without reading it. Serve the panes on a hostname no other app uses. That stops the cookie sharing, not those requests, so another app the reviewer is signed in to must not rely onSameSitealone against the panes.Pane frame lock. Every pane response, the proxy's own
403,421,502and503included, carriesContent-Security-Policy: frame-ancestors 'self' <harness origin> <QA_FRAME_ANCESTORS…>andX-Content-Type-Options: nosniff. The policy replaces the app's ownframe-ancestorsdirective (its other directives are kept), and the app'sX-Frame-Optionsis dropped. A value that isn't an http(s) origin is left out, and so is an IPv6 literal, which a CSP source can't express.The mirror bridge talks only to the harness. The proxy puts the harness origin on the script tag it injects (
data-harness). The bridge posts only to its parent frame at that origin, applies replays only from its parent at that origin, and does nothing without one. So a page that frames a pane, the PR pane included, can neither hear nor drive its bridge. The PR pane's code can still drive the base pane through the harness, as mirroring does: while the mirror is on, the harness replays in the base pane every interaction the PR pane posts to it (a click, a key or a value on any selector), and PR code can post those itself.Harness frame lock. Every harness response, errors and refusals included, carries
frame-ancestors 'self',X-Frame-Options: SAMEORIGINandReferrer-Policy: strict-origin-when-cross-origin. No other page can frame the harness to trick a click that starts a session or posts a verdict, and the harness's frame loads and new tabs always send its origin as theRefererthe panes check.Open the harness at its origin. The panes trust exactly one harness origin,
QA_HARNESS_ORIGIN, which startup logs as[qa] harness at <origin>/qa/. Opened anywhere else that reaches it (localhostfor127.0.0.1, or another front-door port), the harness shows a banner linking to that origin, and the panes answer403. The harness and the panes may still use different hosts: only the URL the harness itself is opened at must match. An IPv6 literal can't be that origin, since a CSP source can't name one: with a::1bind, setQA_HARNESS_ORIGIN=http://localhost:<port>(startup logs an error until you do).Same-origin API. Every harness
/api/*request, reads and the progress stream included, gets403 cross-site request refusedwhensec-fetch-siteis present and isn'tsame-originornone, or, absent that, whenOriginis present and its host isn't the request'sHost. Another page can't read the answers, but its writes would run, and each read of/api/prsspends GitHub API calls. A browser (a request with either header) must also reach the API at the harness origin's host and port, else it gets403 not the harness origin(its body namesharnessOrigin, for the banner): a page served by another front-door handler that proxies to the harness, such as a staletailscale servemount on another port, is same-origin with that handler. LoopbackHosts are exempt from that second check, and non-browser clients, which send neither header, from both. Like the pane guard, this needs Fetch Metadata: over plain http off loopback, another page's<script src>GET sends neither header and passes as a non-browser client.JSON bodies.
POST /api/session,/api/verdictand/api/teardownrequirecontent-type: application/json(else415). A cross-site form can't send that type, and a cross-sitefetch()with it needs a CORS preflight the harness never grants.Inert rendering. The harness UI renders PR titles, build messages, blocked reasons, errors and log tails as text, and links a build run only when its URL is
https://.
The seams
A boot runs these five seams in order: ensureBuilt → per pane (provisionDatabase → seedPane → reserveServices) → derivePaneEnv (+ runMigrate) → launchServices → waitHealthy → establishSession. A sixth, Exposure, is optional and outside the boot: it publishes the conductor itself. The shapes they pass each other are under Types.
| Seam | Members | Owns |
|---|---|---|
| Provisioner | provisionDatabase({paneRef, databases, signal}) → {dsn, db}, reserveServices({paneRef, services, signal}) → {name: {url, port}}, launchServices({paneRef, services, env, reserved, signal}), waitHealthy({services, signal}), teardown({paneRef}); optional runMigrate({paneRef, migrate, env, signal}), sweep(), logs({paneRef, stage, lines}) → string (or a promise of one) | Where panes run: databases, processes or containers, ports, env at rest, cleanup |
| BuildConvention | migrationStrategy ('one-shot-image' \| 'on-boot' \| 'none'), ensureBuilt(pr, {signal}), resolvePrImages(pr), resolveBaseImages() → {services: {name: ref}, migrate?, label?}; optional subscribeBuild(cb) with cb({runUrl?, runStatus?, message?}), describePrs(prs) → [{number, status: 'built'\|'building'\|'none'\|'blocked', runUrl, reason?}] | What gets run for base and PR, whether it's ready, and whether it may run at all |
| Seed | databases, seedPane({paneRef, db, databases}) | Where each pane's data comes from and how it moves |
| EnvTransform | derivePaneEnv({prodEnv, pane}) → {service: env} (pure) | Pointing a pane at its own DB and origin, and neutralizing side effects (email, payments, storage) |
| AuthBootstrap | requiresDb, establishSession({pane, operator, db?}) → {landingUrl}; optional envContributions() → {service: env} | Getting the reviewer logged in to each pane |
The optional members degrade gracefully when absent:
- With no
sweep, nothing is cleaned up at startup. - With no
logs, failures show no log tail. - With no
describePrs, every PR shows asnone.
The one exception: a build that declares one-shot-image migrations with a Provisioner that can't runMigrate fails the boot with a clear error.
Types
What the seams pass each other. Any method above may return its result or a promise of it: the core awaits each one.
- PaneRef:
{ role, slug, publicOrigin }during a boot.roleis'base'or'pr',slugisqa-<pr>-<role>, andpublicOriginis the pane's origin fromcfg.paneOrigins.teardownandlogsget only{ role }, since they also run outside a boot. - ImageSet:
{ services: { name: ref }, migrate?: { image }, label? }, fromresolveBaseImages()andresolvePrImages(pr). Arefis whatever the Provisioner launches, such as an image name or a checkout directory. - Reserved:
{ name: { url, port } }, fromreserveServices: where each service will listen. Only the primary service is proxied,appwhen there is one, else the first: the pane proxy forwards to127.0.0.1:<port>of that service, so it must listen there. Any other service is the app's own business. - Pane:
{ ref, dsn, db, services, publicOrigin, env }.refis the PaneRef,dsnanddbcome fromprovisionDatabase,servicesis the Reserved map, andenvis the pane's env.derivePaneEnvgets the pane before it has anenv;establishSessiongets it whole. - PaneEnv:
{ service: { KEY: value } }, fromderivePaneEnv. EnvContributions, the same shape fromauth.envContributions(), is merged over it service by service.runMigrateandlaunchServicesget the result asenv. - SessionResult:
{ landingUrl }, fromestablishSession: the URL the harness loads in the pane's frame. The core reads nothing else from it, so a session cookie must reach the pane's jar as aSet-Cookiethe landing URL answers with (see below). - BuildEvent:
{ runUrl?, runStatus?, message? }, whatsubscribeBuild's callback gets. - Readiness:
{ number, status, runUrl, reason? }, one per PR fromdescribePrs, withstatusone of'built','building','none'and'blocked'.
Contracts between the seams
dbis opaque to the core. WhateverprovisionDatabasereturns asdbis passed unchanged toseedPaneand (whenrequiresDb) toestablishSession. Its shape is a contract among a consumer's own adapters.- Cancellation (
signal). Teardown and takeover abort the in-flight boot. The core checks the signal between stages, at the top of each pane's provisioning, before eachlaunchServicesand before thewaitHealthyloop. It also passes the signal toensureBuiltand to every Provisioner call above, so a long wait can stop early. Provisioners may ignore it (the Docker one does). An aborted boot never tears anything down, even when the abort lands while its failure log tail is being read: whoever aborted it already did. - Startup sweep. The conductor calls
sweep()once, at startup. A boot started before it settles waits for it beforeensureBuilt, so the sweep can't remove the new session's panes. While it waits, the harness showswaiting for startup cleanup…under the first boot step, thenstartup cleanup done, and counts the wait in that step's time and the boot's. There is no timeout. A failed sweep is logged and doesn't block boots, even one that throws synchronously or rejects with something other than anError. A teardown or takeover during the wait cancels the waiting boot, as it would at any other point. - Build progress.
subscribeBuild(cb)payloads are{runUrl?, runStatus?, message?}. The harness showsmessage(plain text, e.g.installing dependencies for #12 (abc1234)…) under the first boot step, else a summary of the run's status, and links the run whenrunUrlishttps://./api/statereturns the latest asbuildRun: {url, status, message}. blocked.describePrsmay report a PR asblocked, with a plain-textreason(for example, an untrusted author or a head branch in someone else's fork). The picker shows it ascan't boot: <reason>. Opening the PR is still allowed, becauseensureBuiltis the real gate.describePrsreceiveslistOpenPrs()items, or for/api/build-statusan item built fromgithub.prInfo(pr)({number, headSha, author, authorAssociation, headRepo, headOwner}), falling back to{number, headSha}fromprHeadwhengithubhas noprInfo.- Display
label.resolveBaseImages/resolvePrImagesmay return alabelstring, used as the pane's tag instead of the primary service's ref (app, else the first service). The label appears in the harness header and in the public verdict comment. Consumers whose service refs are local paths or objects must set one, so no path or object leaks into the PR. It must not contain:. - Failure log tails. When a boot fails at a pane stage (
cloning,migrating,starting), the core callslogs({paneRef: {role}, stage, lines: 40})for the failing pane before tearing the panes down, and attaches the result to the error aserr.logTail(with the pane's role aserr.failedRole). An error that already carries a stringlogTailkeeps it, so a BuildConvention can attach its own tail to anensuring-imagefailure (for example, installer output). The harness shows the tail under the error. - Landing flows stay on the pane origin (AuthBootstrap). The harness loads each
landingUrlas given; the bridge learns the harness origin from the proxy, not from the URL. A pane serves a navigation from another site only when itsRefereris the harness origin, so a sign-in step on another site, such as an external identity provider's form, comes back with that site'sRefererand is refused. Keep landing flows on the pane origin. Redirects within it are fine, whatever theirReferrer-Policy: the proxy drops that header from a redirect that stays on the pane, so the next hop still carries the harnessReferer. - Sessions live in the jar (AuthBootstrap). The pane app gets the cookies its own responses set this session, which the proxy keeps, and not the browser's. So the session must reach the jar in a
Set-Cookie: have thelandingUrl, or a redirect it makes, set it server-side. A session cookie the app's scripts write can't be kept apart for two panes on one hostname,QA_FORWARD_CLIENT_COOKIESor not. Cookies ignore ports, so both panes' pages write the same browser cookie, whichever pane signs in last overwrites it, and both proxies would pass that one value on: one pane's app would get the other pane's session (see Security).
Reference consumers: this repository's self-QA (qa/self.mjs: build-worktree and provisioner-process, described in CONTRIBUTING.md), the demo's fake adapters (demo/fake-adapters.mjs), and agent-identity's packages/qa, a 0.2-era consumer that seeds each pane's DynamoDB Local from a redacted snapshot.
Built in: adapters/build-worktree (git-worktree BuildConvention)
A BuildConvention for apps that run a PR from its source rather than from CI-built images. It fetches the base branch and the PR head into a bare repository, checks each SHA out into its own git worktree, optionally installs dependencies, and hands each worktree directory to your Provisioner through servicesFor.
import os from 'node:os'
import path from 'node:path'
import { createWorktreeBuild } from '@critical-labs/qa-conductor/adapters/build-worktree'
const build = createWorktreeBuild({
repo: 'acme/widget',
cacheDir: path.join(os.homedir(), '.cache/qa-conductor/acme-widget'),
github, // needs prInfo(num) and authorPermission(login)
servicesFor: (dir, { role, sha }) => ({ app: dir }),
install: { cmd: 'pnpm', args: ['install', '--frozen-lockfile', '--ignore-scripts', '--ignore-pnpmfile'] },
})| Option | Default | |
|---|---|---|
| cloneUrl | https://github.com/<repo>.git | must not contain credentials (construction throws); for a private repo, configure a git credential helper |
| install | null (no install) | { cmd, args, env? }, run in the worktree |
| baseRef | 'main' | the branch the base pane runs |
| trust | { logins: [], associations: ['OWNER', 'MEMBER', 'COLLABORATOR'], requirePush: true, allowForks: true } | see below; omitted keys keep their defaults. logins and associations must be arrays of strings (split a comma-separated env value first); anything else throws at construction |
| keep | 6 | the newest keep builds survive pruning; the two SHAs just built are never removed, even when they fall outside that number |
| migrationStrategy | 'on-boot' | |
How it works:
- Layout under
cacheDir. The bare repository isrepo.git, created on first use, and each checkout isworktrees/<sha>. Markers live atbuilt/<sha>.json, outside the checkouts, so a PR's committed files can't supply one. A marker records a fingerprint of the install command, args and env keys. If it doesn't match, or the checkout is gone, the marker is dropped and the SHA is rebuilt from a clean checkout. - The cache must be yours alone.
cacheDir,built/andworktrees/are created0700. Each build refuses to run unless each of them is a real directory (not a symlink), owned by the current user, and not writable by group or others: anyone who can write there can plant a marker and a tree for the base SHA. - One build at a time per instance. A second caller waits, then runs its own build, re-checking the markers.
- Progress goes to
subscribeBuildas{ message }(fetching acme/widget…,installing dependencies for #12 (1a2b3c4)…,#12 (1a2b3c4) already built). EachensureBuiltreports to the subscriber that was current when it was called, and says nothing more once its signal aborts. An install failure's error carries alogTail: the last 40 lines of its output, at most 8 KB. resolvePrImages/resolveBaseImagesreturnservicesFor(dir, …),migrate: null, and alabelsuch as#12@1a2b3c4ormain@9f8e7d6, which never contains a path.describePrsreportsblocked(with a reason) for a PR that fails the trust gate, thenbuilding,builtornone.- Pruning runs after each build and is best-effort. The newest
keepbuilds are kept, and the two SHAs just built are never removed, even when they fall outside that number. Every other checkout is removed, including ones whose build never finished.
Security. This adapter checks out and installs a PR's code on the reviewer's machine, as the reviewer, and your Provisioner then runs it. The trust gate is the only real boundary between a PR's code and the reviewer's machine. Env scrubbing and loopback binding are defence in depth.
- The trust gate.
ensureBuiltrefuses a PR, before any git call, unless all of these hold:- its author is in
trust.logins, or has an association intrust.associationsand, whenrequirePushis set (the default),writeoradminpermission on the repo; - its head repository still exists;
- its head branch is in the repo itself or in the author's own fork, not in someone else's fork. With
allowForks: false, only the repo itself.
- its author is in
- Association isn't access.
author_associationalone is not an access check.COLLABORATORincludes read-only outside collaborators, andMEMBERincludes org members with no push access. That's whyrequirePushdefaults on. - The gate vouches for the author, not for every commit. It checks who opened the PR and where its head lives, not who pushed the head commit. Anyone with push access to the author's fork (collaborators they added, bots or Actions with write access to it) can move the head, and the moved head passes as the author's. Set
allowForks: falseto require heads in the repo itself, where everyone who can push has write access to the repo. - Only the gated SHA runs. Only the exact SHA that passed the gate is ever checked out or installed. If the head moves between the check and the fetch, the new SHA is gated again, or the build fails with
head moved during fetch; retry. - It fails closed. Any error while checking (a GitHub error, a missing field) refuses the PR, and a malformed
trustconfig throws at construction. - Defence in depth, not a sandbox.
- The installer's environment is exactly
PATH,HOMEandinstall.env, so credentials in environment variables (such asGITHUB_QA_TOKEN) don't reach it. Files underHOMEdo: it can read~/.npmrctokens,~/.config/gh,~/.git-credentialsand~/.ssh. - git runs with hooks disabled and prompts off.
- A PR that passes the gate still runs with the reviewer's full user access. Code it runs (install scripts, a package manager it selects, the server your Provisioner launches) can rewrite this cache, including markers and other checkouts such as the base, like anything else the reviewer can write.
- The installer's environment is exactly
- Skip install scripts. Install with
--ignore-scripts(with pnpm, also--ignore-pnpmfile) so dependency lifecycle scripts and pnpmfiles don't run.
Built in: adapters/provisioner-process (process Provisioner)
A Provisioner for local processes. It runs each pane's services, and optionally a per-pane database, as process groups on 127.0.0.1, with a scrubbed env, a pidfile and a start-time-checked orphan sweep. Its launch contract is below.
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import { createProcessProvisioner } from '@critical-labs/qa-conductor/adapters/provisioner-process'
const ddbDir = '/opt/dynamodb-local' // where DynamoDBLocal.jar is unpacked
// Resolves once something accepts connections on the port.
async function waitForPort(port, { signal }) {
while (!signal.aborted) {
const open = await new Promise(resolve => {
const socket = net.connect(port, '127.0.0.1', () => { socket.end(); resolve(true) })
socket.on('error', () => resolve(false))
})
if (open) return
await sleep(250)
}
}
const provisioner = createProcessProvisioner({
stateDir: path.join(os.homedir(), '.cache/qa-conductor/acme-widget'), // holds pids.json
// `ref` is the service's entry from the BuildConvention, e.g. a checkout directory
command: ({ name, ref, port, env, paneRef }) => ({
cmd: path.join(ref, 'node_modules/.bin/tsx'),
args: ['packages/api/src/dev.ts'],
cwd: ref,
env: { HOST: '127.0.0.1', PORT: String(port) },
}),
database: { // optional: one per pane
command: ({ paneRef, port }) => ({ cmd: '/usr/bin/java', args: ['-jar', 'DynamoDBLocal.jar', '-inMemory', '-port', String(port)], cwd: ddbDir }),
ready: ({ port, signal }) => waitForPort(port, { signal }),
handle: ({ paneRef, port }) => ({ dsn: `http://127.0.0.1:${port}`, db: { endpoint: `http://127.0.0.1:${port}` } }),
},
healthPath: '/ui/', // or (serviceName) => path; default '/'
healthy: status => status === 200, // default: status < 500
})The other options are healthTimeoutMs (60000, per service), host (127.0.0.1: the address in reserved urls, health checks and the free-port lookup), graceMs (5000, from SIGTERM to SIGKILL), logLines (200 per pane) and log (console). Every effect is injectable: spawnFn, killFn, freePortFn, fetchFn, fsx, psFn, pgroupFn, bootIdFn, sleepFn, nowFn, onExitFn and baseEnv.
Launch contract. command and database.command must start the server binary directly, never through a package manager (pnpm dev, pnpm exec, npm run, npx). The child has no TTY and only PATH plus the env you declare, and the package manager on that PATH may not be the one that ran install: pnpm 10 and later verify dependencies before run/exec and, with no TTY, fail or reinstall. So use, for example, { cmd: join(dir, 'node_modules/.bin/tsx'), args: ['packages/api/src/dev.ts'], cwd: dir } or { cmd: process.execPath, args: ['demo/server.mjs'], cwd: dir }. The process you start is the server: if it exits, the pane has failed.
- Env. A service gets exactly
{ PATH, ...env[name], ...spec.env }: the conductor'sPATH, the pane env from your EnvTransform, then the command's ownenv. The database gets{ PATH, ...spec.env }. Nothing else is inherited: no credentials, and noHOMEunless you declare it. - Ports.
reserveServicespicks a free port per service onhost, and never hands out a port another pane or service still holds. Nothing starts untillaunchServices. Your command must make the server listen on that port, bound tohost. - Health.
waitHealthypolls<url><healthPath>untilhealthy(status), for up tohealthTimeoutMs. A process that exits first fails the boot at once, with its log tail.healthy(status)gets the service's own response status: redirects aren't followed (an app often redirects to its public origin, the pane proxy, which answers503until the boot is done), so under the defaultstatus < 500a3xxcounts as up. A strict predicate such asstatus === 200needs ahealthPaththat doesn't redirect. - Logs. Each pane keeps its last
logLineslines of stdout and stderr, prefixed[name], andlogs()returns the tail. The buffer survives teardown, and resets when the pane's next boot starts. - Teardown signals process groups, never bare pids, because a wrapper may exit at once while its server lives on. Services go first, then the database: SIGTERM, then SIGKILL after
graceMs. A group that survives SIGKILL is logged and left for the next sweep. Once a leader has exited, its pid can be reissued, so teardown checks it withpsfirst: a pid held by another process means our group is gone, and it isn't signalled. - Crash cleanup. Each process is recorded in
<stateDir>/pids.jsonwith its start time frompsand the boot it ran in. The directory must be a real directory owned by you and not writable by group or others (it's created0700; one that others could write to is refused, since its contents may be planted), andpids.jsonmust be a regular file owned by you (written0600).sweep(), which the conductor runs at startup, kills the groups a previous run left behind. It skips any pid that now belongs to another process, drops entries from an earlier boot without signalling anything, and leaves in place entries it can't check (no start time). Give each conductor its ownstateDir. Aprocess.on('exit')hook SIGKILLs every live group; signals don't run it, so call the conductor'sshutdown()from your signal handlers. - There's no
runMigrate: process consumers migrate on boot.
Security. A PR's code runs as your user, on your machine. The trust gate is the only real boundary between a PR's code and the reviewer's machine: the BuildConvention must refuse to build a PR it doesn't trust. Env scrubbing and loopback binding are defence in depth. The Tailscale identity gate is no boundary against PR code either: a pane process can reach the conductor on loopback and send any Tailscale-User-Login it likes (see Security).
Built in: adapters/provisioner-docker (Docker Provisioner)
A Provisioner that runs each pane as two containers on the host's Docker daemon, a postgres one and an app one, on a network both panes share. It writes each pane's env to an owner-only file for docker run --env-file, logs in to the registry, runs one-shot migrations, and at startup removes whatever an earlier run left behind.
import fs from 'node:fs'
import { createDocker } from '@critical-labs/qa-conductor/docker'
import { makeExecFileFn } from '@critical-labs/qa-conductor/exec'
import { createDockerProvisioner } from '@critical-labs/qa-conductor/adapters/provisioner-docker'
const provisioner = createDockerProvisioner({
docker: createDocker({ execFileFn: makeExecFileFn() }),
fsx: fs.promises,
workDir: '/var/lib/qa-conductor',
registry: { user: 'acme-bot', token: cfg.ghcrToken },
})| Option | Default | |
|---|---|---|
| docker | (required) | a createDocker(...) (Effect wrappers) |
| fsx | (required) | { writeFile, unlink }, such as fs.promises: writes the env files, mode 0600, and removes them at teardown |
| workDir | (required) | the directory the env files go in, as .env.qa-base and .env.qa-pr. The docker CLI reads them by path, so it must see the same directory |
| network | 'qa-session' | the Docker network both panes' containers join |
| postgres | { user: 'qa', password: 'qa', db: 'postgres' } | the credentials and admin database in the dsn it returns: match createDocker's postgres |
| hostPorts | { base: 3111, pr: 3112 } | the loopback host port each pane's app container is published on |
| registry | null | { user, token }: log in to ghcr.io before pulling, and again before each retry |
For each pane:
provisionDatabasestartsqa-pg-<role>on the network, waits until it answers twice in a row, creates each of the Seed'sdatabases, and returns thedsnpostgresql://<user>:<password>@qa-pg-<role>:5432and adbof{ dsn, query(sql, { database }) }, which runspsqlin the container.runMigraterunsmigrate.imageonce on the network, with the pane'sappenv (else its first service's), and removes the container after.launchServicespulls each image that's missing (three tries), then runs it asqa-app-<role>, with its port3000published on127.0.0.1:<hostPorts[role]>.waitHealthypollshttp://127.0.0.1:<port>/api/healthuntil it answers200, for up to a minute.logstails the app container atstarting, else the postgres one.teardownremoves both containers and their volumes, the network once no pane uses it, and the env file.sweepremoves every container withcreateDocker'slabel, and the network.
Its limits:
- One service per pane. Every service would run as
qa-app-<role>with the same env file. - Fixed names and ports. The containers, the network and the host ports are the same on every run, so run one conductor per Docker host.
- The app's side: listen on port
3000in the container, and answerGET /api/healthwith200. - The registry login is to
ghcr.ioonly. - It ignores
signal, so a teardown waits for a Docker call in flight.
Effect wrappers
github. The core calls only these, so anything that has them will do:
listOpenPrs()→[{ number, title, headSha, headRef, author, authorAssociation, headRepo, headOwner }], for the picker;prInfo(num)→{ number, headSha, author, authorAssociation, isDraft, headRepo, headOwner }, or without itprHead(num)→headSha, for/api/build-status;postComment(num, body)→ the comment's URL, andsetQaLabel(num, label), for the verdict.
adapters/build-worktree also needs prInfo and authorPermission(login). createGithub(options) makes one on GitHub's REST API:
| Option | Default | |
|---|---|---|
| token | (required) | every call but the package listing (Tokens) |
| repo | (required) | owner/name |
| qaLabels | ['qa-approved', 'qa-changes-requested'] | the accept and reject labels, [cfg.verdictLabels.accept, cfg.verdictLabels.reject]: setQaLabel adds one and removes the other |
| packagesToken | token | the GHCR package version listing only (cfg.ghcrToken) |
| packageName | null | the container package the GHCR helpers read; they throw without it |
| rcTagPattern | /-rc\.\d+$/ | which tags latestRcTag() counts as release candidates |
| previewWorkflow | 'pr-preview.yml' | the workflow dispatchPreviewBuild(num) runs, with the input pr, and findPreviewRun(num) reads |
| previewRef | 'main' | the ref that workflow is dispatched on |
| fetchFn | fetch | |
listOpenPrs()returns at most the 50 most recently opened PRs: it reads one page.headRepo(owner/name) andheadOwnerarenullwhen the head repository was deleted.authorPermission(login)returns the login'sadmin,write,readornonepermission on the repo, and throws on a non-2xx response.author_associationalone is no access check:COLLABORATORincludes read-only outside collaborators.- The GHCR and preview-build helpers are for a BuildConvention that runs CI-built images:
ghcrTagExists(tag),latestRcTag(),listPrImageTags(),awaitPreviewImage(num, sha, { timeoutMs, pollMs, signal }), which waits for the tagpr-<num>-<the sha's first 12 characters>,dispatchPreviewBuild(num)andfindPreviewRun(num). They read a package that the token's own user owns, not an organization's.
docker. createDocker({ execFileFn, label, postgres }) runs the docker CLI for the Docker Provisioner, and for a Seed that copies databases between containers:
| Option | Default | |
|---|---|---|
| execFileFn | (required) | makeExecFileFn() from ./exec |
| label | 'qa-conductor-session' | put on every container and network it creates; sweepQaContainers() removes whatever carries it |
| postgres | { image: 'postgres:16', user: 'qa', password: 'qa', db: 'postgres' } | the pane database containers; keys you leave out keep these values |
Its members are run(argv), login(user, token) (to ghcr.io, with the token in the environment, never in argv), imagePresent, ensureImage(image, { retries, relogin }), runPg, waitHealthyPg, createDatabase, pipeDump(from, to, db) and cloneDb(from, to, db) (a host-side pg_dump | psql between two containers), runMigrate, runApp, waitHealthyApp, psql, rmForce, createNetwork, rmNetwork, sweepQaContainers, inspectImageOf and logsTail. login, createDatabase, pipeDump, cloneDb and logsTail throw on a name that isn't letters, digits, _ and -.
exec. makeExecFileFn({ maxBuffer }) returns an execFileFn(cmd, args, opts) that runs execFile with maxBuffer (default 64 MB, which opts can override) and resolves { stdout }. It rejects with an Error whose message is <cmd> <first arg>: <execFile's message>, then, on the next line, up to 2000 characters of stderr. The original error is its cause, and it also carries the whole stdout and stderr and the exit code.
Exposure (optional)
An Exposure adapter publishes the conductor's three servers on a front door, such as tailscale serve, and reports when they drift. It is optional, and the core never constructs one: a platform builds it and passes it as adapters.exposure, as it does the other adapters. Only a gated conductor may have one: in none mode, startConductor throws adapters.exposure needs QA_EXPOSURE=tailscale: an ungated conductor must not publish itself.
import { createTailscaleExposure } from '@critical-labs/qa-conductor/adapters/exposure-tailscale'
import { makeExecFileFn } from '@critical-labs/qa-conductor/exec'
const conductor = startConductor({
cfg, // in tailscale mode
github, fsx, readBaseEnv,
adapters: { provisioner, build, seed, envTransform, auth, exposure: createTailscaleExposure({ execFileFn: makeExecFileFn() }) },
})
const state = await conductor.exposure.ready // { mode, managed, ok, checkedAt, drift, added, error }The reconcile loop. With an adapter, the conductor owns its mounts:
- It reconciles once all three servers listen, so the targets are the bound ports, then every
QA_EXPOSURE_INTERVAL_MINUTES(cfg.exposureIntervalMinutes, default 5) on anunref()ed timer. Each pass derives the mounts afresh withmountsFor, sincecfg.paneOriginsmay be assigned after start, then runsreconcileExposure. - One pass runs at a time. A tick while a pass is still running, on a hung CLI say, starts nothing, so CLI processes never stack.
- Once
stop()orshutdown()begins, no pass runs and the timer is cleared. Neither waits for a pass in flight. Nothing ever removes a mount, so after a stop the front door answers502until the conductor is back. - Nothing a pass does takes the conductor down. Each mount it writes is logged as
[qa] exposure mounted <port><path> -> <target>, orrestoredwhen this conductor had it in place before.[qa] exposure failed: …,[qa] exposure drift remains: <port><path>, …and[qa] exposure okare logged only when they change. GET /api/exposureandconductor.exposure.state()report the last pass as{ mode, managed, ok, checkedAt, drift, added, error }. Neither calls the front door. The mounts indriftandaddedare copied from what the adapter returned, keeping only the Mount type's values: a field that isn't a string (forport, an integer), such as aURLobject as thetarget, is reported asnull.- So a deploy needn't touch the mounts: the restarted conductor's first pass restores any that are missing or wrong.
In tailscale mode without an adapter, the mounts are someone else's, such as a deploy script's: startup logs [qa] exposure: tailscale serve mounts are managed outside the conductor, and /api/exposure reports managed: false. In none mode it reports managed: false too.
To run a pass yourself, outside a conductor, use the expose CLI from a shell, or from code:
import { mountsFor, reconcileExposure } from '@critical-labs/qa-conductor/exposure'
const exposure = createTailscaleExposure({ execFileFn: makeExecFileFn() })
const result = await reconcileExposure(exposure, mountsFor(cfg), { checkOnly: true }) // { ok, checkedAt, drift, added, error }The contract:
Mount = { name, host, port, path, target }, one each forharness,baseandpr.hostandportare the mount's public side: the hostname and port of its own origin (cfg.harnessOrigin, or the pane's incfg.paneOrigins). A port-less https origin means443.hostis notcfg.host.cfg.hostis the address the conductor binds, and onlytargetuses it.pathis/qafor the harness and/for each pane.targetishttp://<cfg.host>:<listen port>, with brackets for IPv6.
Drift = { mount, actual }:actualis the proxy target the front door serves at that mount over https now, ornullwhen there is none: nothing at that path, no https on the port, or a handler that isn't a proxy. A mount also drifts once for each other handler that takes some of its requests, andactualthen names that handler, as<path> -> <target>or<path> (not a proxy).ensure(mounts) → Promise<{ added: Mount[], ok: Mount[] }>creates the missing or mismatched mounts, and only those:addedlists the mounts it wrote,okthe ones already in place.check(mounts) → Promise<{ ok: boolean, drift: Drift[] }>changes nothing. Either rejects on failure. Anensurethat fails part way may list the mounts it did write on its error, asadded.- An adapter owns exactly the
(port, path)pairs it is given, and never touches another handler.
mountsFor(cfg, { ports = cfg.ports }) derives the mounts from the origins viewers open, so the URL a viewer sees and the mount behind it can't disagree. Pass the bound ports when cfg.ports holds 0. It throws on a layout no front door can publish:
- no harness origin;
- a missing or unparseable pane origin;
- an origin that isn't https, or is on port
0; - two mounts on one port, which would share an origin;
- a listen port that isn't an integer from 1 to 65535.
reconcileExposure(exposure, mounts, { checkOnly }) runs ensure then check, or only check with checkOnly. It resolves { ok, checkedAt, drift, added, error } and never rejects, whatever the adapter throws: a failure comes back as ok: false with its message in error, and added still lists what ensure wrote.
runExpose({ cfg, exposure, checkOnly = false, log = console }) is the expose CLI's pass: mountsFor(cfg) then reconcileExposure, printed through log, resolving the CLI's exit code (0, 1 or 2). A cfg without exposure or harnessOrigin resolves both as startConductor does.
Built in: adapters/exposure-tailscale. createTailscaleExposure({ execFileFn, bin = 'tailscale', socket = null, timeoutMs = 30000 }) drives tailscale serve on the host it runs on:
checkrunstailscale serve status --json. A mount is in place when its port serves https and its path proxies to its target (one trailing/ignored). It reads the status entry for the mount'shost:port, else the first entry on that port.- tailscaled hands a request to the deepest handler path that holds it, so a handler under a mount's path takes some of its requests:
/qa/or/qa/apibeside the harness's/qa, or any other path on a pane's port.checkreports each one that doesn't proxy to the mount's target as drift. ensurereads the same status, then runstailscale serve --bg --https=<port> [--set-path=<path>] <target>for each mount whose own handler is missing or points elsewhere, and only those.--set-pathis left out for/. A handler that shadows a mount stays drift until you remove it:ensurenever writes or removes it. A mount that fails doesn't stop the others, andensurethen rejects, naming each failure, with the mounts it did write as the error'sadded.- Every call goes through
execFileFn(makeExecFileFn()from./exec) with atimeoutMstimeout. Withsocketset,--socket=<socket>comes before the subcommand, for a CLI whose daemon's socket is somewhere else, such as one mounted into a container. binis the CLI to run, and it should be no older than the daemon. On macOS, thetailscaleonPATHmay lag behind the app's daemon: use the app's bundled CLI,/Applications/Tailscale.app/Contents/MacOS/Tailscale.
What it never touches. It never runs tailscale serve reset or off, so it never removes a handler, and it never changes one at a (port, path) it wasn't given:
- Another app's
/handler on the harness's port stays beside the harness's/qa, sincetailscale servekeeps a port's other paths. Give the harness a port of its own all the same: that app's pages would share the harness origin, so the panes and the harness API would take them for the harness. - The flip side: a declared
(port, path)is the conductor's. A pane origin on a port where another app serves/replaces that app's handler. - **A changed origin or port leaves the old handler
