@critical-labs/qa-conductor 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/server.mjs ADDED
@@ -0,0 +1,721 @@
1
+ // PR-QA conductor core: harness UI/API + pane proxies, composed over the five
2
+ // adapter seams. This module owns NO app, infra or registry policy: a
3
+ // consuming platform constructs {cfg, github, fsx, adapters, readBaseEnv} and
4
+ // calls startConductor. Containers, env files and log tails belong to the
5
+ // Provisioner; PR readiness belongs to the BuildConvention (describePrs);
6
+ // origins, ports and verdict labels are config. `github` is used for the PR
7
+ // list, PR head/trust lookups and the verdict comment + label.
8
+ //
9
+ // Browser-facing hardening (defence in depth, not a trust boundary):
10
+ // - in tailscale mode (cfg.exposure, else derived as loadConfig does), all
11
+ // three servers answer only Tailscale-User-Login values in
12
+ // cfg.allowedLogins (403 before anything else runs) and must listen on
13
+ // loopback, so tailscale serve on this host is the only way in;
14
+ // - all three servers listen on cfg.host (loopback by default) and answer 421
15
+ // to a Host header outside the allowlist, so a DNS-rebound page can't reach
16
+ // them;
17
+ // - every /api/* request must come from the harness page itself, at the
18
+ // harness origin's host:port (403), and POST bodies must be JSON (415), so
19
+ // another page in the reviewer's browser can't drive or read the API;
20
+ // - no other page may frame the harness, only the harness origin (plus
21
+ // cfg.frameAncestors) may frame a pane, and the pane proxies refuse other
22
+ // pages' requests (lib/request-guard.mjs).
23
+ //
24
+ // The harness origin is cfg.harnessOrigin, else derived as loadConfig does:
25
+ // :8444 on cfg.publicHost, else the loopback address the harness listens on
26
+ // (per request, so a harness on port 0 uses its bound port).
27
+ //
28
+ // An optional sixth adapter, adapters.exposure (lib/exposure.mjs), lets the
29
+ // conductor publish itself: a reconcile loop keeps its three mounts on the
30
+ // front door, and GET /api/exposure reports the last pass. Only a gated
31
+ // conductor may have one.
32
+ import http from 'node:http'
33
+ import { fileURLToPath } from 'node:url'
34
+ import path from 'node:path'
35
+
36
+ import {
37
+ defaultExposure, defaultHarnessOrigin, EXPOSURE_INTERVAL_RULE, EXPOSURE_MODES, HARNESS_PATH, isExposureInterval,
38
+ } from './config.mjs'
39
+ import { mountsFor, reconcileExposure } from './exposure.mjs'
40
+ import { identityGate, normalizeLogins } from './identity.mjs'
41
+ import { hostPort, isLoopbackHost, requireFetchMetadata, webOrigin } from './net.mjs'
42
+ import { createPaneProxy, isAllowedHost, misdirected, panePolicy } from './proxy.mjs'
43
+ import { isHarnessHost, isSameOriginRequest } from './request-guard.mjs'
44
+ import { formatVerdict } from './verdict.mjs'
45
+ import {
46
+ createSession, reduce, touch, isIdle, bootSession, teardownSession, PANE_STAGES,
47
+ } from './session.mjs'
48
+
49
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
50
+ const DEFAULT_PUBLIC_DIR = path.join(HERE, '..', 'public')
51
+ const DEFAULT_HOST = '127.0.0.1'
52
+ // Harness POST routes whose body must be JSON. A cross-site HTML form can't
53
+ // send that type, and a cross-site fetch() with it needs a CORS preflight.
54
+ const JSON_ROUTES = new Set(['/api/session', '/api/verdict', '/api/teardown'])
55
+ // The harness page starts sessions and posts verdicts in one click, so no
56
+ // other page may frame it (clickjacking); a browser that ignores
57
+ // frame-ancestors still honours X-Frame-Options. The Referrer-Policy pins
58
+ // what the panes' Referer check relies on: the harness origin, sent on its
59
+ // iframe loads and new tabs whatever the browser's default.
60
+ const HARNESS_FRAME_HEADERS = {
61
+ 'content-security-policy': "frame-ancestors 'self'",
62
+ 'x-frame-options': 'SAMEORIGIN',
63
+ 'referrer-policy': 'strict-origin-when-cross-origin',
64
+ }
65
+
66
+ function isJsonRequest(headers) {
67
+ return String(headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() === 'application/json'
68
+ }
69
+
70
+ // cfg.host, the address all three servers listen on. listen() takes an IPv6
71
+ // literal bare (::1); in brackets it would pass the loopback check below,
72
+ // then fail to listen and take the process down. loadConfig unwraps it.
73
+ function startHost(cfg) {
74
+ const host = cfg.host ?? DEFAULT_HOST
75
+ if (/^\[.*\]$/.test(host)) {
76
+ throw new Error(`startConductor: cfg.host is a listen address, which takes an IPv6 literal without brackets: use ${JSON.stringify(host.slice(1, -1))}, not ${JSON.stringify(host)}`)
77
+ }
78
+ return host
79
+ }
80
+
81
+ // The harness origin as configured, or as derivable at start, after checking
82
+ // the origins in cfg. Throws on what would otherwise fail open: a harness
83
+ // origin or extra frame ancestor that isn't an http(s) origin, a harness or
84
+ // pane origin set at start that browsers send no Fetch Metadata to (plain
85
+ // http off loopback: the request guards would take other pages for curl),
86
+ // and a harness origin equal to a pane origin (PR code would be same-origin
87
+ // with the harness). Equal pane origins are left to loadConfig: the demo's
88
+ // placeholders are equal until its proxies listen.
89
+ function startHarnessOrigin(cfg, host) {
90
+ const configured = cfg.harnessOrigin == null ? null : webOrigin(cfg.harnessOrigin, 'cfg.harnessOrigin')
91
+ for (const value of [].concat(cfg.frameAncestors ?? [])) webOrigin(value, 'cfg.frameAncestors')
92
+ // A derived origin is https or loopback.
93
+ if (configured !== null) requireFetchMetadata(configured, 'cfg.harnessOrigin', 'harness API guard')
94
+ const origin = configured ?? defaultHarnessOrigin({ publicHost: cfg.publicHost, host, port: cfg.ports.harness })
95
+ for (const [role, value] of Object.entries(cfg.paneOrigins ?? {})) {
96
+ let pane
97
+ try { pane = webOrigin(value) } catch { continue } // not an origin yet: nothing to check
98
+ requireFetchMetadata(pane, `cfg.paneOrigins.${role}`, 'pane request guard')
99
+ if (pane === origin) {
100
+ throw new Error(`startConductor: the harness origin ${origin} is also the ${role} pane's origin; the harness and each pane need an origin of their own`)
101
+ }
102
+ }
103
+ return origin
104
+ }
105
+
106
+ // cfg.exposure, else the default loadConfig would give this layout, fixed at
107
+ // start: a cfg whose non-loopback origins arrive later must set it. Throws on
108
+ // an unknown mode, and on tailscale mode off loopback, where anything that
109
+ // reaches the port could send its own Tailscale-User-Login. `because` names
110
+ // what made a defaulted mode tailscale, for the startup log; else null.
111
+ function startExposure(cfg, host, startOrigin) {
112
+ const fallback = defaultExposure({
113
+ harnessOrigin: startOrigin, paneOrigins: cfg.paneOrigins,
114
+ publicHost: cfg.publicHost, allowedHosts: cfg.allowedHosts ?? [], host,
115
+ })
116
+ const mode = cfg.exposure ?? fallback.mode
117
+ if (!EXPOSURE_MODES.includes(mode)) {
118
+ throw new Error(`startConductor: cfg.exposure must be 'none' or 'tailscale', got ${JSON.stringify(mode)}`)
119
+ }
120
+ if (mode === 'tailscale' && !isLoopbackHost(host)) {
121
+ const why = cfg.exposure == null ? `exposure defaults to tailscale because ${fallback.because} is not loopback` : "cfg.exposure is 'tailscale'"
122
+ throw new Error(
123
+ `startConductor: ${why}, so cfg.host must be a loopback address: tailscale serve on this host is the only supported front ` +
124
+ `(got ${JSON.stringify(host)}), or set exposure: 'none' if another front door authenticates`,
125
+ )
126
+ }
127
+ return { mode, because: cfg.exposure == null ? fallback.because : null }
128
+ }
129
+
130
+ // adapters.exposure, if any, and the milliseconds between its passes. Only a
131
+ // gated conductor may publish itself: in none mode, anyone a mount reaches
132
+ // would reach the API and the panes. The interval is held to loadConfig's
133
+ // rule, so a code-built cfg can't make the loop fire every 1 ms (above
134
+ // 2^31-1 ms, setInterval does).
135
+ function startExposureAdapter(adapters, mode, cfg) {
136
+ const adapter = adapters.exposure ?? null
137
+ if (adapter !== null && mode !== 'tailscale') {
138
+ throw new Error('adapters.exposure needs QA_EXPOSURE=tailscale: an ungated conductor must not publish itself')
139
+ }
140
+ const minutes = cfg.exposureIntervalMinutes ?? 5
141
+ if (!isExposureInterval(minutes)) {
142
+ const shown = typeof minutes === 'string' ? JSON.stringify(minutes) : String(minutes)
143
+ throw new Error(`startConductor: cfg.exposureIntervalMinutes ${EXPOSURE_INTERVAL_RULE}, got ${shown}`)
144
+ }
145
+ return { adapter, intervalMs: minutes * 60_000 }
146
+ }
147
+
148
+ // A Mount as plain data. The adapter is platform code, so what it reports is
149
+ // copied before /api/exposure serializes it or state() clones it, keeping
150
+ // only the Mount type's own values: a string, or an integer port. Anything
151
+ // else (a URL object as the target, a function, a Symbol) becomes null, since
152
+ // structuredClone throws on some of those and JSON drops or garbles others.
153
+ const stringOrNull = v => (typeof v === 'string' ? v : null)
154
+ const plainMount = m => ({
155
+ name: stringOrNull(m?.name), host: stringOrNull(m?.host), port: Number.isInteger(m?.port) ? m.port : null,
156
+ path: stringOrNull(m?.path), target: stringOrNull(m?.target),
157
+ })
158
+ const mountKey = m => `${m.port}${m.path} -> ${m.target}`
159
+
160
+ export function startConductor({ cfg, github, fsx, adapters, readBaseEnv, publicDir = DEFAULT_PUBLIC_DIR, log = console }) {
161
+ const host = startHost(cfg)
162
+ const startOrigin = startHarnessOrigin(cfg, host)
163
+ const { mode: exposure, because } = startExposure(cfg, host, startOrigin)
164
+ const { adapter: exposureAdapter, intervalMs: exposureIntervalMs } = startExposureAdapter(adapters, exposure, cfg)
165
+ // In tailscale mode every server answers only these logins
166
+ // (lib/identity.mjs); an empty list refuses everyone. The gate wraps each
167
+ // whole server handler, so no route runs for an unidentified request.
168
+ // Nothing registers an 'upgrade' listener, so upgrades reach it too.
169
+ const allowedLogins = normalizeLogins(cfg.allowedLogins)
170
+ const gate = exposure === 'tailscale' ? handler => identityGate(handler, allowedLogins) : handler => handler
171
+ if (exposure === 'tailscale') {
172
+ // A cfg that names no mode says what gated it: a pane origin left unset
173
+ // at start counts as off loopback, which a code-built cfg may not expect.
174
+ const why = because ? ` (exposure defaults to tailscale because ${because} is not loopback)` : ''
175
+ const n = allowedLogins.length
176
+ log.log(`[qa] identity gate on${why}: ${n} allowed login${n === 1 ? '' : 's'}`)
177
+ if (n === 0) {
178
+ log.error("[qa] QA_ALLOWED_LOGINS is empty: every request will be refused; set cfg.allowedLogins, or exposure: 'none' if another front door authenticates")
179
+ }
180
+ // Gated, but published by someone else (homefree's apply script, say).
181
+ if (exposureAdapter === null) log.log('[qa] exposure: tailscale serve mounts are managed outside the conductor')
182
+ } else {
183
+ log.log('[qa] identity gate off (QA_EXPOSURE=none)')
184
+ }
185
+ let session = createSession()
186
+ let buildRun = null
187
+ // AbortController for the in-flight boot. Teardown/takeover abort it so a
188
+ // stale boot can never write state or tear down a newer session's panes.
189
+ let bootAbort = null
190
+ // Each pane's reserved primary-service port for the ready session; the pane
191
+ // proxies route to it and answer 503 while it is null.
192
+ let upstreams = { base: null, pr: null }
193
+ const sseClients = new Set()
194
+ const progressLog = []
195
+ // Set by shutdown(): writes are refused, no boot starts, and a server whose
196
+ // listen completes afterwards closes at once.
197
+ let closing = false
198
+
199
+ // Remove orphans a previous conductor run left behind (e.g. a deploy restarted
200
+ // the conductor mid-session), if the Provisioner can. Boots wait for it: a
201
+ // session started while the sweep is still running would otherwise collide
202
+ // with the orphans' fixed names, or have its fresh containers and network
203
+ // removed by the sweep. Never rejects (a sweep that throws synchronously, or
204
+ // rejects with a non-Error, included), so a failed sweep doesn't block boots.
205
+ // No timeout.
206
+ let sweeping = typeof adapters.provisioner.sweep === 'function'
207
+ const startupSweep = sweeping
208
+ ? (async () => adapters.provisioner.sweep())()
209
+ .then(() => log.log('[qa] startup sweep complete'))
210
+ .catch(err => log.error('[qa] startup sweep failed:', err?.message ?? String(err)))
211
+ .finally(() => { sweeping = false })
212
+ : Promise.resolve()
213
+
214
+ function broadcast(event) {
215
+ const stamped = { at: Date.now(), ...event }
216
+ progressLog.push(stamped)
217
+ const line = `data: ${JSON.stringify(stamped)}\n\n`
218
+ for (const res of sseClients) res.write(line)
219
+ }
220
+
221
+ // The origin viewers open the harness at. Without one at start, a loopback
222
+ // harness derives it from its bound port once listening; until then, and off
223
+ // loopback with no public host, it is null.
224
+ function harnessOrigin() {
225
+ if (startOrigin !== null) return startOrigin
226
+ return defaultHarnessOrigin({ publicHost: null, host, port: harness.listening ? harness.address().port : 0 })
227
+ }
228
+
229
+ // Hostnames (beyond loopback) the servers answer to. Read per request: a
230
+ // platform may assign cfg.paneOrigins after the proxies are listening.
231
+ function allowedHostnames() {
232
+ const out = []
233
+ if (cfg.publicHost) out.push(cfg.publicHost)
234
+ const harnessAt = harnessOrigin()
235
+ if (harnessAt) out.push(new URL(harnessAt).hostname)
236
+ for (const origin of Object.values(cfg.paneOrigins ?? {})) {
237
+ try { out.push(new URL(origin).hostname) } catch { /* not a URL: contributes nothing */ }
238
+ }
239
+ return out.concat(cfg.allowedHosts ?? [])
240
+ }
241
+
242
+ // loginUrls are per-pane SessionResults from the AuthBootstrap adapter; the
243
+ // landingUrl carries the magic-link token (homefree's degenerate case — no
244
+ // cookies/replay needed by the proxy yet).
245
+ function paneUrls(loginUrls) {
246
+ return {
247
+ base: loginUrls.base.landingUrl,
248
+ pr: loginUrls.pr.landingUrl,
249
+ baseOrigin: cfg.paneOrigins.base,
250
+ prOrigin: cfg.paneOrigins.pr,
251
+ }
252
+ }
253
+
254
+ // `lastStep` is the step this boot already broadcast (startBoot sends
255
+ // ensuring-image before the sweep wait). A repeat isn't sent again: the
256
+ // harness times each step, and the boot, from its step event's `at`.
257
+ function sessionDeps(signal, lastStep = null) {
258
+ return {
259
+ signal,
260
+ adapters,
261
+ readBaseEnv,
262
+ env: { operatorEmail: cfg.operatorEmail, paneOrigins: cfg.paneOrigins },
263
+ onProgress: step => {
264
+ if (signal.aborted) return
265
+ session = reduce(session, { type: 'step', step })
266
+ if (step === lastStep) return
267
+ lastStep = step
268
+ broadcast({ kind: 'step', step })
269
+ },
270
+ // Build progress: a CI run (runUrl/runStatus) and/or a plain-text message.
271
+ onBuild: ({ runUrl = null, runStatus = null, message = null } = {}) => {
272
+ if (signal.aborted) return
273
+ buildRun = { url: runUrl, status: runStatus, message }
274
+ broadcast({ kind: 'build', runUrl, runStatus, message })
275
+ },
276
+ }
277
+ }
278
+
279
+ async function startBoot(pr) {
280
+ if (closing) return
281
+ bootAbort?.abort()
282
+ const ac = new AbortController()
283
+ bootAbort = ac
284
+ progressLog.length = 0
285
+ buildRun = null
286
+ broadcast({ kind: 'step', step: 'ensuring-image' })
287
+ const deps = sessionDeps(ac.signal, 'ensuring-image')
288
+ try {
289
+ // Say so while the startup sweep holds the boot back, and once it's done,
290
+ // so the message isn't left standing while the build runs.
291
+ const waited = sweeping
292
+ if (waited) deps.onBuild({ message: 'waiting for startup cleanup…' })
293
+ await startupSweep
294
+ if (ac.signal.aborted) return // torn down or taken over while waiting
295
+ if (waited) deps.onBuild({ message: 'startup cleanup done' })
296
+ const out = await bootSession(deps, pr)
297
+ if (ac.signal.aborted) return // superseded: result belongs to no session
298
+ session = reduce(session, { type: 'tags', baseTag: out.baseTag, prTag: out.prTag })
299
+ session = reduce(session, { type: 'ready', now: Date.now(), tokens: out.loginUrls })
300
+ upstreams = { base: out.upstreams?.base ?? null, pr: out.upstreams?.pr ?? null }
301
+ broadcast({ kind: 'ready', pr, panes: paneUrls(out.loginUrls), baseTag: out.baseTag, prTag: out.prTag })
302
+ } catch (err) {
303
+ if (ac.signal.aborted) return // superseded: must not touch the current session
304
+ const step = session.status
305
+ session = reduce(session, { type: 'error', step, message: String(err.message ?? err) })
306
+ // bootSession read the failing pane's tail before tearing down; a
307
+ // BuildConvention may attach one too. Else ask the Provisioner now.
308
+ const logTail = typeof err?.logTail === 'string' ? err.logTail : await tailForStep(step).catch(() => '')
309
+ broadcast({ kind: 'error', step, message: session.error?.message, logTail, runUrl: buildRun?.url ?? null })
310
+ }
311
+ }
312
+
313
+ // Best-effort log tail for a failed pane stage, if the Provisioner offers one.
314
+ // async so a synchronous logs() (return or throw) still yields a promise.
315
+ async function tailForStep(step) {
316
+ if (!PANE_STAGES.includes(step) || typeof adapters.provisioner.logs !== 'function') return ''
317
+ const tail = await adapters.provisioner.logs({ paneRef: { role: 'pr' }, stage: step, lines: 40 })
318
+ return typeof tail === 'string' ? tail : ''
319
+ }
320
+
321
+ async function doTeardown() {
322
+ bootAbort?.abort()
323
+ bootAbort = null
324
+ upstreams = { base: null, pr: null }
325
+ session = reduce(session, { type: 'teardown' })
326
+ await teardownSession({ provisioner: adapters.provisioner }).catch(() => {})
327
+ session = reduce(session, { type: 'torn-down' })
328
+ buildRun = null
329
+ broadcast({ kind: 'torn-down' })
330
+ }
331
+
332
+ function sessionSummary() {
333
+ return { pr: session.pr, status: session.status, startedAt: session.startedAt, lastActivity: session.lastActivity }
334
+ }
335
+
336
+ async function readBody(req) {
337
+ const chunks = []
338
+ for await (const c of req) chunks.push(c)
339
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') } catch { return {} }
340
+ }
341
+
342
+ function json(res, status, body) {
343
+ const buf = JSON.stringify(body)
344
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(buf) })
345
+ res.end(buf)
346
+ }
347
+
348
+ async function serveStatic(res, file, type) {
349
+ try {
350
+ const buf = await fsx.readFile(path.join(publicDir, file))
351
+ res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' })
352
+ res.end(buf)
353
+ } catch {
354
+ res.writeHead(404).end('not found')
355
+ }
356
+ }
357
+
358
+ // Build readiness per PR from the BuildConvention, if it can describe it.
359
+ // Failures and a missing describePrs degrade to 'none' (the picker still works).
360
+ async function describePrs(prs) {
361
+ if (typeof adapters.build.describePrs !== 'function') return new Map()
362
+ try {
363
+ return new Map((await adapters.build.describePrs(prs)).map(d => [d.number, d]))
364
+ } catch {
365
+ return new Map()
366
+ }
367
+ }
368
+
369
+ // The open-PR list, enriched with build readiness. `reason` explains a
370
+ // 'blocked' status (e.g. an untrusted head) in plain text.
371
+ async function enrichedPrs() {
372
+ const prs = await github.listOpenPrs()
373
+ const readiness = await describePrs(prs)
374
+ return prs.map(pr => ({
375
+ number: pr.number, title: pr.title, headRef: pr.headRef, author: pr.author,
376
+ imageStatus: readiness.get(pr.number)?.status ?? 'none',
377
+ runUrl: readiness.get(pr.number)?.runUrl ?? null,
378
+ reason: readiness.get(pr.number)?.reason ?? null,
379
+ }))
380
+ }
381
+
382
+ // The describePrs item for one PR: with trust data when github can give it.
383
+ async function prForDescribe(pr) {
384
+ if (typeof github.prInfo !== 'function') return { number: pr, headSha: await github.prHead(pr) }
385
+ const { headSha, author, authorAssociation, headRepo, headOwner } = await github.prInfo(pr)
386
+ return { number: pr, headSha, author, authorAssociation, headRepo, headOwner }
387
+ }
388
+
389
+ // Refusals that apply before any harness route runs (the Host check runs
390
+ // earlier still, before the URL is parsed). Returns true when the request was
391
+ // answered.
392
+ function refused(req, res, p) {
393
+ // Every API request, reads and the event stream too, must come from the
394
+ // harness page itself: another page can't read the answers, but its writes
395
+ // would run, and each /api/prs or /api/build-status spends GitHub API calls
396
+ // on the conductor's token. The page itself (/ and /harness.js) stays open
397
+ // to any navigation.
398
+ if (p.startsWith('/api/')) {
399
+ if (!isSameOriginRequest(req.headers)) {
400
+ json(res, 403, { error: 'cross-site request refused' })
401
+ return true
402
+ }
403
+ // The harness page shows its banner from harnessOrigin. No other page can
404
+ // read the answer, and the origin is no secret: every pane sends it.
405
+ if (!isHarnessHost(req.headers, harnessOrigin())) {
406
+ json(res, 403, { error: 'not the harness origin', harnessOrigin: harnessOrigin() })
407
+ return true
408
+ }
409
+ }
410
+ const write = req.method !== 'GET' && req.method !== 'HEAD'
411
+ // A write accepted during shutdown could start a boot nothing tears down.
412
+ if (write && closing) {
413
+ json(res, 503, { error: 'shutting down' })
414
+ return true
415
+ }
416
+ if (req.method === 'POST' && JSON_ROUTES.has(p) && !isJsonRequest(req.headers)) {
417
+ json(res, 415, { error: 'content-type must be application/json' })
418
+ return true
419
+ }
420
+ return false
421
+ }
422
+
423
+ // Set before any handler runs, so every harness response carries them: the
424
+ // identity gate's 403s, the 421s and other 403s, errors and the event stream.
425
+ // No harness response sets its own Content-Security-Policy to merge with.
426
+ const harnessHeaders = handler => (req, res) => {
427
+ for (const [name, value] of Object.entries(HARNESS_FRAME_HEADERS)) res.setHeader(name, value)
428
+ return handler(req, res)
429
+ }
430
+
431
+ // Likewise for each pane, so a response the proxy doesn't make itself (the
432
+ // identity gate's 403) still carries the pane's frame policy. The proxy's own
433
+ // responses send the policy through writeHead, whose headers take precedence.
434
+ const paneHeaders = handler => (req, res) => {
435
+ res.setHeader('content-security-policy', panePolicy(harnessOrigin(), cfg.frameAncestors ?? []))
436
+ res.setHeader('x-content-type-options', 'nosniff')
437
+ return handler(req, res)
438
+ }
439
+
440
+ const harness = http.createServer(harnessHeaders(gate(async (req, res) => {
441
+ if (!isAllowedHost(req.headers.host, allowedHostnames())) return misdirected(res)
442
+ // A request target like `//x:99999` is not a parseable URL; left uncaught it
443
+ // rejects this async handler and takes the process down.
444
+ let url
445
+ try { url = new URL(req.url, 'http://x') } catch { return json(res, 400, { error: 'bad request target' }) }
446
+ // tailscale serve --set-path may or may not strip the /qa prefix; accept both.
447
+ const p = url.pathname.replace(/^\/qa(?=\/|$)/, '') || '/'
448
+ try {
449
+ if (refused(req, res, p)) return
450
+ if (req.method === 'GET' && p === '/') return await serveStatic(res, 'index.html', 'text/html; charset=utf-8')
451
+ if (req.method === 'GET' && p === '/harness.js') return await serveStatic(res, 'harness.js', 'text/javascript')
452
+ if (req.method === 'GET' && p === '/api/state') {
453
+ return json(res, 200, {
454
+ status: session.status, pr: session.pr, error: session.error,
455
+ baseTag: session.baseTag, prTag: session.prTag,
456
+ startedAt: session.startedAt, lastActivity: session.lastActivity, idleMinutes: cfg.idleMinutes,
457
+ buildRun,
458
+ panes: session.status === 'ready' && session.tokens ? paneUrls(session.tokens) : null,
459
+ harnessOrigin: harnessOrigin(),
460
+ })
461
+ }
462
+ if (req.method === 'GET' && p === '/api/prs') {
463
+ const prs = await enrichedPrs()
464
+ return json(res, 200, { prs, session: sessionSummary() })
465
+ }
466
+ // The last reconcile pass, as recorded: it never calls the front door.
467
+ if (req.method === 'GET' && p === '/api/exposure') return json(res, 200, exposureView())
468
+ if (req.method === 'GET' && p === '/api/build-status') {
469
+ const pr = Number(url.searchParams.get('pr'))
470
+ if (!Number.isInteger(pr)) return json(res, 400, { error: 'pr required' })
471
+ let d = null
472
+ try {
473
+ d = (await describePrs([await prForDescribe(pr)])).get(pr) ?? null
474
+ } catch { /* return what we have */ }
475
+ const status = d?.status ?? 'none'
476
+ const body = { pr, status, exists: status === 'built', runUrl: d?.runUrl ?? null }
477
+ // Only a blocked status adds a field, so the shape is otherwise unchanged.
478
+ if (status === 'blocked') body.reason = String(d.reason ?? '')
479
+ return json(res, 200, body)
480
+ }
481
+ if (req.method === 'GET' && p === '/api/progress') {
482
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' })
483
+ for (const e of progressLog) res.write(`data: ${JSON.stringify(e)}\n\n`)
484
+ sseClients.add(res)
485
+ req.on('close', () => sseClients.delete(res))
486
+ return
487
+ }
488
+ if (req.method === 'POST' && p === '/api/session') {
489
+ const { pr, takeover } = await readBody(req)
490
+ if (!Number.isInteger(pr)) return json(res, 400, { error: 'pr (number) required' })
491
+ const active = session.status !== 'idle' && session.status !== 'error'
492
+ if (active && session.pr !== pr && !takeover) {
493
+ return json(res, 409, { error: `session already active (pr #${session.pr}, ${session.status})`, session: sessionSummary() })
494
+ }
495
+ if (active && takeover) await doTeardown()
496
+ // shutdown() may have begun while the takeover teardown ran
497
+ if (closing) return json(res, 503, { error: 'shutting down' })
498
+ session = reduce(session, { type: 'open', pr })
499
+ startBoot(pr)
500
+ return json(res, 202, { ok: true })
501
+ }
502
+ if (req.method === 'GET' && p === '/api/verdict/preview') {
503
+ if (session.pr == null) return json(res, 409, { error: 'no session' })
504
+ const verdict = url.searchParams.get('verdict')
505
+ if (verdict !== 'accept' && verdict !== 'reject') return json(res, 400, { error: 'verdict must be accept|reject' })
506
+ const notes = url.searchParams.get('notes') ?? ''
507
+ const durationMin = session.startedAt ? Math.round((Date.now() - session.startedAt) / 60000) : 0
508
+ const body = formatVerdict({ verdict, notes, pr: session.pr, baseTag: session.baseTag, prTag: session.prTag, durationMin })
509
+ const { accept, reject } = cfg.verdictLabels
510
+ const applies = verdict === 'accept' ? accept : reject
511
+ const removes = verdict === 'accept' ? reject : accept
512
+ return json(res, 200, { body, applies, removes })
513
+ }
514
+ if (req.method === 'POST' && p === '/api/verdict') {
515
+ const { verdict, notes } = await readBody(req)
516
+ if (session.pr == null) return json(res, 409, { error: 'no session' })
517
+ if (verdict !== 'accept' && verdict !== 'reject') return json(res, 400, { error: 'verdict must be accept|reject' })
518
+ const durationMin = session.startedAt ? Math.round((Date.now() - session.startedAt) / 60000) : 0
519
+ const body = formatVerdict({ verdict, notes: notes ?? '', pr: session.pr, baseTag: session.baseTag, prTag: session.prTag, durationMin })
520
+ const url2 = await github.postComment(session.pr, body)
521
+ await github.setQaLabel(session.pr, verdict === 'accept' ? cfg.verdictLabels.accept : cfg.verdictLabels.reject)
522
+ return json(res, 200, { url: url2 })
523
+ }
524
+ if (req.method === 'POST' && p === '/api/teardown') {
525
+ await doTeardown()
526
+ return json(res, 200, { ok: true })
527
+ }
528
+ res.writeHead(404).end('not found')
529
+ } catch (err) {
530
+ json(res, 500, { error: String(err.message ?? err) })
531
+ }
532
+ })))
533
+
534
+ const onActivity = () => { session = touch(session, Date.now()) }
535
+ const bridgePath = path.join(publicDir, 'bridge.js')
536
+ const paneProxy = role => http.createServer(paneHeaders(gate(createPaneProxy({
537
+ upstreamPort: () => upstreams[role],
538
+ bridgePath,
539
+ onActivity,
540
+ httpMod: http,
541
+ allowedHosts: allowedHostnames,
542
+ harnessOrigin,
543
+ frameAncestors: cfg.frameAncestors ?? [],
544
+ }))))
545
+ const baseProxy = paneProxy('base')
546
+ const prProxy = paneProxy('pr')
547
+ const servers = { harness, baseProxy, prProxy }
548
+
549
+ const reaper = setInterval(() => {
550
+ if (isIdle(session, Date.now(), cfg.idleMinutes * 60_000)) {
551
+ log.log('[qa] idle session — tearing down')
552
+ doTeardown().catch(err => log.error('[qa] teardown failed:', err.message))
553
+ }
554
+ }, 60_000)
555
+
556
+ // --- Exposure: the reconcile loop -------------------------------------------
557
+ // With an adapter, the conductor owns its front-door mounts. It reconciles
558
+ // once all three servers listen, so the targets are the bound ports, then
559
+ // every exposureIntervalMinutes on an unref()ed timer. One pass at a time,
560
+ // so a hung CLI never stacks processes; none once stop() or shutdown()
561
+ // begins, and neither waits for one in flight. Nothing ever removes a
562
+ // mount. A pass never throws: its result is recorded for /api/exposure and
563
+ // state(), and logged only when it changes.
564
+ let exposureState = { mode: exposure, managed: exposureAdapter !== null, ok: null, checkedAt: null, drift: [], added: [], error: null }
565
+ const exposureView = () => structuredClone(exposureState)
566
+ let exposureStarted = false // all three servers are listening
567
+ let exposureEnded = false // stop() or shutdown() began
568
+ let exposureTimer = null
569
+ let reconciling = null // the pass in flight
570
+ let exposureStatus = null // the last ok / drift / failed line
571
+ // Mounts this conductor has had in place, so a rewrite of one is a restore.
572
+ const mountsInPlace = new Set()
573
+ let resolveExposureReady
574
+ const exposureReady = new Promise(resolve => { resolveExposureReady = resolve })
575
+ if (exposureAdapter === null) resolveExposureReady(exposureView())
576
+
577
+ // mountsFor runs every pass: a platform may assign cfg.paneOrigins after
578
+ // start. Its throw is a layout no front door can publish, recorded like an
579
+ // adapter error (reconcileExposure never rejects).
580
+ async function exposurePass() {
581
+ const ports = { harness: harness.address()?.port, base: baseProxy.address()?.port, pr: prProxy.address()?.port }
582
+ let mounts = []
583
+ let result
584
+ try {
585
+ mounts = mountsFor({ ...cfg, harnessOrigin: harnessOrigin() }, { ports })
586
+ } catch (err) {
587
+ result = { ok: false, checkedAt: Date.now(), drift: [], added: [], error: String(err.message).replace(/^exposure: /, '') }
588
+ }
589
+ result ??= await reconcileExposure(exposureAdapter, mounts)
590
+ recordExposure(mounts, result)
591
+ }
592
+
593
+ function recordExposure(mounts, result) {
594
+ const added = result.added.map(plainMount)
595
+ const drift = result.drift.map(d => ({ mount: plainMount(d?.mount), actual: typeof d?.actual === 'string' ? d.actual : null }))
596
+ exposureState = { mode: exposure, managed: true, ok: result.ok === true, checkedAt: result.checkedAt, drift, added, error: result.error }
597
+ for (const m of added) {
598
+ log.log(`[qa] exposure ${mountsInPlace.has(mountKey(m)) ? 'restored' : 'mounted'} ${mountKey(m)}`)
599
+ mountsInPlace.add(mountKey(m))
600
+ }
601
+ // ensure wrote every mount that wasn't in place, or failed: after an
602
+ // error-free pass, all of them were.
603
+ if (result.error === null) for (const m of mounts) mountsInPlace.add(mountKey(m))
604
+ const status = result.error !== null ? `failed: ${result.error}`
605
+ : exposureState.ok ? 'ok'
606
+ : `drift remains: ${[...new Set(drift.map(d => `${d.mount.port}${d.mount.path}`))].join(', ') || '(the adapter named no mount)'}`
607
+ if (status === exposureStatus) return
608
+ exposureStatus = status
609
+ if (status === 'ok') log.log('[qa] exposure ok')
610
+ else log.error(`[qa] exposure ${status}`)
611
+ }
612
+
613
+ // One pass, or the one in flight. Before the servers listen there are no
614
+ // targets yet: that is the first pass, so it returns `ready`, which waits
615
+ // for it (or settles when stop() or shutdown() begins first).
616
+ function reconcileNow() {
617
+ if (exposureAdapter === null || exposureEnded) return Promise.resolve(exposureView())
618
+ if (!exposureStarted) return exposureReady
619
+ // A pass rejects only when the platform's logger throws, and then there
620
+ // is nowhere to report it; the next tick tries again.
621
+ reconciling ??= exposurePass().catch(() => {}).then(() => {
622
+ reconciling = null
623
+ return exposureView()
624
+ })
625
+ return reconciling
626
+ }
627
+
628
+ // The loop's own calls end their chains, so nothing a pass does can become
629
+ // an unhandled rejection, which would take the process down.
630
+ function beginReconcileLoop() {
631
+ exposureStarted = true
632
+ if (exposureAdapter === null || exposureEnded) return
633
+ reconcileNow().then(resolveExposureReady, () => resolveExposureReady(exposureState))
634
+ exposureTimer = setInterval(() => { reconcileNow().catch(() => {}) }, exposureIntervalMs)
635
+ exposureTimer.unref()
636
+ }
637
+
638
+ // `ready` settles here when no pass has: before the servers listen, or
639
+ // with the first pass still in flight.
640
+ function endExposure() {
641
+ exposureEnded = true
642
+ clearInterval(exposureTimer)
643
+ resolveExposureReady(exposureView())
644
+ }
645
+
646
+ let listening = 0
647
+ function listen(server, port, label) {
648
+ server.listen(port, host, () => {
649
+ if (closing) return server.close()
650
+ log.log(`[qa] ${label} on ${hostPort(host, server.address().port)}`)
651
+ if (server === harness) logHarnessOrigin()
652
+ if (++listening === Object.keys(servers).length) beginReconcileLoop()
653
+ })
654
+ }
655
+
656
+ function logHarnessOrigin() {
657
+ const origin = harnessOrigin()
658
+ if (origin) log.log(`[qa] harness at ${origin}${HARNESS_PATH}/`)
659
+ else log.error('[qa] no harness origin (set QA_HARNESS_ORIGIN): only a pane can frame itself and the mirror is off')
660
+ // panePolicy leaves an IPv6 literal out (a CSP source can't name one), so
661
+ // no harness could frame the panes. A `::1` bind derives one.
662
+ const hostname = origin ? new URL(origin).hostname : ''
663
+ if (hostname.startsWith('[')) {
664
+ log.error(
665
+ `[qa] the harness origin ${origin} is an IPv6 literal, which frame-ancestors cannot name: ` +
666
+ `the panes will stay blank; set QA_HARNESS_ORIGIN=${origin.replace(hostname, 'localhost')}`,
667
+ )
668
+ }
669
+ }
670
+ listen(harness, cfg.ports.harness, 'harness')
671
+ listen(baseProxy, cfg.ports.base, 'base pane proxy')
672
+ listen(prProxy, cfg.ports.pr, 'pr pane proxy')
673
+
674
+ // Stop accepting, drop keep-alive and SSE sockets, resolve once closed.
675
+ function closeServer(server) {
676
+ return new Promise(resolve => {
677
+ server.close(() => resolve())
678
+ server.closeAllConnections()
679
+ })
680
+ }
681
+
682
+ let shuttingDown = null
683
+ async function shutdown() {
684
+ closing = true
685
+ clearInterval(reaper)
686
+ endExposure()
687
+ await doTeardown()
688
+ // closing keeps new boots out; this catches any that slipped in anyway.
689
+ if (bootAbort) await doTeardown()
690
+ for (const res of sseClients) res.end()
691
+ sseClients.clear()
692
+ await Promise.all(Object.values(servers).map(closeServer))
693
+ }
694
+
695
+ return {
696
+ servers,
697
+ stop() {
698
+ clearInterval(reaper)
699
+ endExposure()
700
+ harness.close()
701
+ baseProxy.close()
702
+ prProxy.close()
703
+ },
704
+ // Graceful exit for CLIs (call it on SIGINT/SIGTERM): tears the session
705
+ // down (aborting an in-flight boot) and closes all three servers.
706
+ shutdown() {
707
+ shuttingDown ??= shutdown()
708
+ return shuttingDown
709
+ },
710
+ // The reconcile loop. `ready` resolves with the state after the first
711
+ // pass, or as it stands once stop() or shutdown() begins first (at once
712
+ // with no adapter); state() is the last pass, as GET /api/exposure
713
+ // reports it; reconcile() runs a pass now, or joins the one in flight,
714
+ // and before the servers listen returns `ready`.
715
+ exposure: {
716
+ ready: exposureReady,
717
+ state: exposureView,
718
+ reconcile: reconcileNow,
719
+ },
720
+ }
721
+ }