flowviant 0.125.20 → 0.125.21

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.
@@ -0,0 +1,305 @@
1
+ /** A held builder's preview/browser supervisor. No public share or API route. */
2
+ import { spawn, execFileSync } from 'node:child_process';
3
+ import { createServer, request as httpRequest } from 'node:http';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { lstatSync, readFileSync, readlinkSync, realpathSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { join, resolve, relative, isAbsolute } from 'node:path';
7
+ import { tmpdir, platform } from 'node:os';
8
+ import { listenHeldTool, heldToolArgv } from './heldTool.mjs';
9
+ import { ensureArtifactDir, ARTIFACT_DIR } from './artifacts.mjs';
10
+ import { measureListeners, originFor, listenersSupported } from './listeners.mjs';
11
+ import { procStat } from './procStat.mjs';
12
+ import { killGroup } from './processGroup.mjs';
13
+ import { unattendedEnv } from './noWindowEnv.mjs';
14
+ import { childEnv } from './childEnv.mjs';
15
+ import { gitRaw } from './git.mjs';
16
+ import { serveMcpStdio } from './mcpStdio.mjs';
17
+ import { scrub, learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs';
18
+ import { captureLocalReview, reviewDiagnostic } from './uiReviewBrowser.mjs';
19
+ import { validateReviewPlan, reviewPath, uiReviewInstructions, UI_REVIEW_MAX_RUNS, UI_REVIEW_TIMEOUT_MS } from './uiReviewContract.mjs';
20
+
21
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
22
+ const gitRead = (wt, args, encoding = 'utf8') => gitRaw(args, wt, { encoding, timeout: 10_000, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
23
+
24
+ /** Hash the actual tracked/nonignored working bytes, not just a dirty flag.
25
+ * Ignored runtime/build output is explicitly outside this source measurement. */
26
+ export function reviewSourceState(wt) {
27
+ try {
28
+ const head = gitRead(wt, ['rev-parse', 'HEAD']).trim();
29
+ const names = [...new Set(gitRead(wt, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']).split('\0').filter(Boolean))].sort();
30
+ if (names.length > 30_000) throw new Error('Source snapshot file bound exceeded.');
31
+ const hash = createHash('sha256');
32
+ let total = 0;
33
+ for (const name of names) {
34
+ if (name === ARTIFACT_DIR || name.startsWith(ARTIFACT_DIR + '/')) continue;
35
+ const path = resolve(wt, name);
36
+ const rel = relative(wt, path);
37
+ if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('Source path left worktree.');
38
+ hash.update(JSON.stringify(name));
39
+ let stat;
40
+ try { stat = lstatSync(path); } catch (error) { if (error.code === 'ENOENT') { hash.update('deleted'); continue; } throw error; }
41
+ if (stat.isSymbolicLink()) hash.update('link:' + readlinkSync(path));
42
+ else if (stat.isFile()) {
43
+ total += stat.size;
44
+ if (total > 512 * 1024 * 1024 || stat.size > 32 * 1024 * 1024) throw new Error('Source snapshot byte bound exceeded.');
45
+ // No symlinked parent (including a replaced tracked directory).
46
+ if (realpathSync(path) !== path) throw new Error('Source snapshot encountered a linked parent.');
47
+ hash.update(String(stat.mode & 0o777)); hash.update(readFileSync(path));
48
+ } else hash.update('non-file');
49
+ }
50
+ const status = gitRead(wt, ['status', '--porcelain=v1', '--untracked-files=all']).trim();
51
+ return { head, digest: hash.digest('hex'), dirty: Boolean(status), scope: 'Tracked and nonignored source files; ignored build/runtime files are not measured.' };
52
+ } catch (error) { return { unavailable: reviewDiagnostic(error.message) }; }
53
+ }
54
+
55
+ export function sameReviewSource(a, b) {
56
+ return Boolean(a?.head && a?.digest && a.head === b?.head && a.digest === b?.digest);
57
+ }
58
+
59
+ function processGroup(pid) {
60
+ if (platform() === 'linux') return procStat(pid)?.pgrp;
61
+ try { return Number(execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8', timeout: 1000 }).trim()); } catch { return null; }
62
+ }
63
+
64
+ /** Both cwd attribution and our own process group must agree. */
65
+ export function measuredReviewOrigin(wt, port, child) {
66
+ if (!listenersSupported() || !child?.pid || child.exitCode != null || child.signalCode != null) return null;
67
+ const rows = measureListeners(wt).rows.filter(row => row.port === port);
68
+ if (!rows.length || rows.some(row => processGroup(row.pid) !== child.pid)) return null;
69
+ const picked = originFor(wt, port);
70
+ if (!['127.0.0.1', '::1'].includes(picked.host)) return null;
71
+ return `http://${picked.host === '::1' ? '[::1]' : picked.host}:${port}`;
72
+ }
73
+
74
+ /** One proxy, one owned preview. Recheck the owner on EVERY request. No
75
+ * upgrade, remote redirect following, inherited auth or arbitrary host dialing.
76
+ * CSP blocks sockets/workers; the browser bootstrap disables WebRTC, which
77
+ * Chrome's CSP does not implement. Fetch alone cannot fence those transports.
78
+ * The app sees a new private origin and cannot borrow personal cookies. */
79
+ export async function createReviewProxy({ upstream, stillOwned, signal }) {
80
+ let proxyOrigin;
81
+ const server = createServer((req, res) => {
82
+ let path;
83
+ try { path = reviewPath(req.url); } catch { res.writeHead(403).end(); return; }
84
+ if (signal?.aborted || !stillOwned() || req.headers.host !== new URL(proxyOrigin).host) { res.writeHead(503).end('Preview ownership is unavailable.'); return; }
85
+ const url = new URL(path, upstream);
86
+ const headers = { ...req.headers, host: new URL(upstream).host };
87
+ for (const key of ['authorization', 'proxy-authorization', 'connection', 'upgrade', 'forwarded', 'x-forwarded-for', 'x-forwarded-host']) delete headers[key];
88
+ // Same-UID disposable app code still owns its backend; the fixture
89
+ // declaration is required before this runner executes repository code.
90
+ const out = httpRequest(url, { method: req.method, headers, signal, timeout: 10_000 }, reply => {
91
+ const responseHeaders = { ...reply.headers };
92
+ for (const key of ['content-security-policy', 'content-security-policy-report-only', 'report-to', 'reporting-endpoints', 'nel', 'refresh', 'alt-svc', 'connection']) delete responseHeaders[key];
93
+ // No response may direct the browser off the measured app. Relative
94
+ // redirects stay local; same-upstream absolute redirects use the proxy.
95
+ if (responseHeaders.location) {
96
+ const target = new URL(responseHeaders.location, upstream);
97
+ if (target.origin !== upstream || target.username || target.password) { reply.resume(); res.writeHead(403).end('Off-preview redirect blocked.'); return; }
98
+ responseHeaders.location = target.pathname + target.search + target.hash;
99
+ }
100
+ responseHeaders['content-security-policy'] = `default-src ${proxyOrigin} data: blob:; script-src ${proxyOrigin} 'unsafe-inline' 'unsafe-eval'; style-src ${proxyOrigin} 'unsafe-inline'; connect-src ${proxyOrigin}; img-src ${proxyOrigin} data: blob:; media-src ${proxyOrigin} data: blob:; font-src ${proxyOrigin} data:; frame-src 'none'; child-src 'none'; worker-src 'none'; object-src 'none'; form-action ${proxyOrigin}; base-uri ${proxyOrigin}`;
101
+ responseHeaders['cache-control'] = 'no-store';
102
+ res.writeHead(reply.statusCode ?? 502, responseHeaders); reply.pipe(res);
103
+ });
104
+ out.on('timeout', () => out.destroy());
105
+ out.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end('Owned preview request failed.'); });
106
+ req.on('aborted', () => out.destroy()); req.pipe(out);
107
+ });
108
+ server.on('upgrade', (_req, socket) => socket.destroy());
109
+ await new Promise((r, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', r); });
110
+ proxyOrigin = `http://127.0.0.1:${server.address().port}`;
111
+ return { origin: proxyOrigin, close() { server.close(); server.closeAllConnections(); } };
112
+ }
113
+
114
+ export function reviewEvidence(reports, finalState) {
115
+ if (!reports.length) return 'Local UI self-review: no browser run was requested in this turn. Browser, visual inspection and interactions are unverified; see the builder’s applicability/skipped-check explanation.';
116
+ const last = reports.at(-1);
117
+ const fresh = sameReviewSource(last.before, last.after) && sameReviewSource(last.after, finalState);
118
+ const lines = [
119
+ 'Local UI self-review (runner measurements; visual interpretation remains the builder’s claim).',
120
+ `Final source HEAD: ${finalState.head ?? 'unavailable'}; source SHA-256: ${finalState.digest ?? 'unavailable'}; dirty: ${finalState.dirty ?? 'unknown'}. ${finalState.scope ?? finalState.unavailable ?? ''}`,
121
+ `Latest attempt: ${last.status}. Evidence ${fresh ? 'matches the final measured source' : 'is STALE or source identity is unavailable; earlier passing checks do not establish the final code'}.`,
122
+ ...reports.map((r, i) => `Attempt ${i + 1}: ${r.status}${r.reason ? ' — ' + r.reason : ''}; report: ${r.artifact ?? 'not retained'}.`),
123
+ `Preview basis: ${last.basis ?? 'unavailable'}. Fixture: ${last.fixture ?? 'unavailable'}.`,
124
+ `Tested HEAD: ${last.before?.head ?? 'unavailable'}; source SHA-256: ${last.before?.digest ?? 'unavailable'}.`,
125
+ ];
126
+ for (const requested of last.requestedScenarios ?? []) lines.push(`Requested at desktop 1440×900 and mobile 390×844: ${requested.path} — ${requested.name}; ordered checks: ${requested.actions.map(a => a.type + (a.selector ? ' ' + a.selector : '')).join(' → ') || 'captures only'}. Checks without an observed row below are unrun${last.reason ? ': ' + last.reason : ' (review ended before they completed)'}.`);
127
+ for (const row of last.scenarios ?? []) {
128
+ lines.push(`${row.viewport.name} ${row.viewport.width}×${row.viewport.height} ${row.path} — ${row.name}: ${row.status}${row.reason ? ' — ' + row.reason : ''}.`);
129
+ row.actions.forEach((a, i) => lines.push(` ${i + 1}. ${a.type}${a.selector ? ' ' + a.selector : ''}: ${a.status}; ${a.outcome ?? 'expected outcome not observed'}.`));
130
+ for (const name of row.screenshots ?? []) lines.push(` Screenshot: ${last.folder}/${name} (capture only).`);
131
+ if (row.skipped) lines.push(` Skipped: ${row.skipped}`);
132
+ if (row.networkSettle) lines.push(` Unverified: ${row.networkSettle}`);
133
+ }
134
+ lines.push(`Console errors: ${last.consoleErrors?.length ?? 0}; failed/blocked requests: ${last.failedRequests?.length ?? 0}.`);
135
+ lines.push(`Screenshots supplied through native image-input tool: ${last.imageInputs?.length ?? 0}. This counts image bytes supplied, not the builder’s interpretation or alternate native file/image tools.`);
136
+ for (const diagnostic of [...(last.consoleErrors ?? []), ...(last.failedRequests ?? [])].slice(0, 10)) lines.push(reviewDiagnostic(JSON.stringify(diagnostic)));
137
+ lines.push('Visual inspection is not inferred from capture or tool availability. The builder must report actual image-tool observations, fixes, reruns, remaining findings and skipped checks. Previous attempt files retain the fix/recheck history. No approval or merge was performed.');
138
+ return scrub(lines.join('\n'));
139
+ }
140
+
141
+ /** The CLI helper calls this capability while the chosen builder is alive.
142
+ * All owned processes are daemon-side and taken down on abort, timeout or close. */
143
+ export async function createUiReview({ wt, signal, capture = captureLocalReview, timeoutMs = UI_REVIEW_TIMEOUT_MS, onChild = () => () => {} }) {
144
+ let closed = false, busy = false, count = 0, pending = null;
145
+ const reports = [];
146
+ const captures = new Map();
147
+ const controller = new AbortController();
148
+ const abort = () => controller.abort();
149
+ if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
150
+ const run = async input => {
151
+ if (closed || controller.signal.aborted) throw new Error('This builder turn has ended.');
152
+ if (busy) throw new Error('A local UI review is already running.');
153
+ if (count >= UI_REVIEW_MAX_RUNS) throw new Error('The three-run self-review bound was reached; report remaining findings.');
154
+ validateReviewPlan(input);
155
+ busy = true; count++;
156
+ const runController = new AbortController();
157
+ const stop = () => runController.abort();
158
+ controller.signal.addEventListener('abort', stop, { once: true });
159
+ const timer = setTimeout(stop, timeoutMs);
160
+ const record = { status: 'unavailable', before: reviewSourceState(wt), basis: reviewDiagnostic(input.basis), fixture: reviewDiagnostic(input.fixture.description),
161
+ requestedScenarios: input.scenarios.map(s => ({ name: reviewDiagnostic(s.name), path: reviewDiagnostic(s.path), actions: s.actions.map(a => ({ type: a.type, ...(a.selector ? { selector: reviewDiagnostic(a.selector) } : {}) })) })),
162
+ scenarios: [], consoleErrors: [], failedRequests: [] };
163
+ let child, proxy, scratch, artifactDir, untrack;
164
+ try {
165
+ const cwd = realpathSync(resolve(wt, input.cwd ?? '.'));
166
+ const rel = relative(realpathSync(wt), cwd);
167
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('../')) throw new Error('Preview cwd must be inside this agent worktree.');
168
+ if (!listenersSupported()) throw new Error('This platform cannot measure preview socket ownership.');
169
+ if (!ensureArtifactDir(wt)) throw new Error('A safe retained artifact directory is unavailable.');
170
+ const name = `ui-review-${randomUUID().slice(0, 12)}`;
171
+ artifactDir = join(wt, ARTIFACT_DIR, name); mkdirSync(artifactDir, { mode: 0o700 });
172
+ record.folder = `${ARTIFACT_DIR}/${name}`;
173
+ record.artifact = `${record.folder}/report.json`;
174
+ scratch = mkdtempSync(join(tmpdir(), 'flowviant-ui-preview-'));
175
+ const env = unattendedEnv(childEnv({ cwd }), { browserHome: scratch });
176
+ // No inherited personal home, SDK credentials or browser sessions.
177
+ env.HOME = scratch; env.USERPROFILE = scratch; env.XDG_CONFIG_HOME = scratch;
178
+ env.XDG_DATA_HOME = scratch; env.XDG_CACHE_HOME = scratch;
179
+ let output = '';
180
+ child = spawn(input.command[0], input.command.slice(1), { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
181
+ untrack = onChild(child);
182
+ let spawnError;
183
+ child.on('error', error => { spawnError = error; });
184
+ const keep = bytes => { output = reviewDiagnostic(output + bytes.toString()).slice(-2000); };
185
+ child.stdout.on('data', keep); child.stderr.on('data', keep);
186
+ const kill = () => killGroup(child);
187
+ runController.signal.addEventListener('abort', kill, { once: true });
188
+ let upstream;
189
+ const start = Date.now();
190
+ while (!runController.signal.aborted && Date.now() - start < Math.min(30_000, timeoutMs)) {
191
+ if (spawnError) throw spawnError;
192
+ upstream = measuredReviewOrigin(cwd, input.port, child);
193
+ if (upstream) break;
194
+ if (child.exitCode != null) throw new Error(`Preview exited (${child.exitCode}). ${output}`);
195
+ await sleep(150);
196
+ }
197
+ if (!upstream) throw new Error(`Preview readiness unavailable: no owned listener measured. ${output}`);
198
+ const stillOwned = () => measuredReviewOrigin(cwd, input.port, child) === upstream;
199
+ proxy = await createReviewProxy({ upstream, stillOwned, signal: runController.signal });
200
+ // A real successful HTTP response, no redirect-following, after socket
201
+ // attribution. Never probe an unrelated or merely guessed port.
202
+ let ready = false;
203
+ while (!runController.signal.aborted && Date.now() - start < Math.min(30_000, timeoutMs)) {
204
+ try {
205
+ const reply = await fetch(proxy.origin + reviewPath(input.readyPath ?? '/'), { redirect: 'manual', signal: AbortSignal.any([runController.signal, AbortSignal.timeout(1500)]) });
206
+ ready = reply.status >= 200 && reply.status < 400;
207
+ await reply.body?.cancel();
208
+ if (ready) break;
209
+ } catch { /* startup not ready yet */ }
210
+ await sleep(150);
211
+ }
212
+ if (!ready || !stillOwned()) throw new Error('The owned preview did not become HTTP-ready.');
213
+ record.preview = { upstream, proxy: proxy.origin, pid: child.pid, readyPath: input.readyPath ?? '/', command: input.command.map(reviewDiagnostic), cwd: input.cwd ?? '.' };
214
+ Object.assign(record, await capture({ origin: proxy.origin, scenarios: input.scenarios, artifactDir, signal: runController.signal, timeoutMs, onChild }));
215
+ } catch (error) { record.reason = reviewDiagnostic(error.message); }
216
+ finally {
217
+ clearTimeout(timer); controller.signal.removeEventListener('abort', stop);
218
+ runController.abort(); proxy?.close(); killGroup(child);
219
+ child?.stdout?.destroy(); child?.stderr?.destroy();
220
+ if (child && child.exitCode == null && child.signalCode == null) await Promise.race([new Promise(r => child.once('exit', r)), sleep(500)]);
221
+ untrack?.();
222
+ if (scratch) try { rmSync(scratch, { recursive: true, force: true }); } catch { /* never reused */ }
223
+ record.after = reviewSourceState(wt);
224
+ record.sourceStable = sameReviewSource(record.before, record.after);
225
+ reports.push(record); busy = false;
226
+ for (const name of record.screenshots ?? []) {
227
+ const path = `${record.folder}/${name}`;
228
+ const bytes = readFileSync(join(wt, path));
229
+ captures.set(path, { digest: createHash('sha256').update(bytes).digest('hex'), record });
230
+ }
231
+ if (artifactDir) writeFileSync(join(artifactDir, 'report.json'), scrub(JSON.stringify(record, null, 2)), { flag: 'wx' });
232
+ }
233
+ return { ...record, screenshots: (record.screenshots ?? []).map(name => `${record.folder}/${name}`), next: 'Open every screenshot with a real image-input tool, inspect findings, fix in scope and rerun (at most three attempts). Source edits invalidate earlier checks.' };
234
+ };
235
+ let held;
236
+ try {
237
+ held = await listenHeldTool({ envPrefix: 'FLOWVIANT_UI_REVIEW', verb: '__ui-review', requestTimeoutMs: timeoutMs + 5000, isClosed: () => closed || controller.signal.aborted,
238
+ respond: async (req, res) => {
239
+ try {
240
+ let body = '';
241
+ for await (const chunk of req) { body += chunk; if (body.length > 64 * 1024) throw new Error('UI review plan exceeds 64 KiB.'); }
242
+ const input = JSON.parse(body);
243
+ if (input.operation === 'inspect_screenshot') {
244
+ const capture = captures.get(input.path);
245
+ if (!capture) throw new Error('Select an exact screenshot path returned by this turn’s runner.');
246
+ const path = join(wt, input.path);
247
+ if (!lstatSync(path).isFile() || realpathSync(path) !== path) throw new Error('The captured image is no longer a regular local file.');
248
+ const bytes = readFileSync(path);
249
+ if (bytes.length > 2 * 1024 * 1024 || createHash('sha256').update(bytes).digest('hex') !== capture.digest) throw new Error('The screenshot changed after capture; rerun its review.');
250
+ capture.record.imageInputs ??= [];
251
+ if (!capture.record.imageInputs.includes(input.path)) capture.record.imageInputs.push(input.path);
252
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify({ path: input.path, mimeType: 'image/png', data: bytes.toString('base64'), note: 'Captured image supplied to this tool. Report what you actually see; this receipt alone is not a visual verdict.' }));
253
+ return;
254
+ }
255
+ if (busy) throw new Error('A local UI review is already running.');
256
+ pending = run(input);
257
+ const result = await pending;
258
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(scrub(JSON.stringify(result)));
259
+ } catch (error) { res.writeHead(409, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: reviewDiagnostic(error.message) })); }
260
+ } });
261
+ } catch (error) { signal?.removeEventListener('abort', abort); throw error; }
262
+ const secretKey = `ui-review:${held.capability.slice(0, 12)}`;
263
+ learnExtraSecrets(secretKey, [{ name: 'FLOWVIANT_UI_REVIEW_CAPABILITY', value: held.capability }]);
264
+ return { env: held.env, config: { command: process.execPath, args: heldToolArgv('__ui-review-mcp'), env: held.env }, instructions: uiReviewInstructions([process.execPath, ...heldToolArgv('__ui-review')]),
265
+ async close() {
266
+ closed = true; controller.abort(); held.server.close(); held.server.closeAllConnections();
267
+ await pending?.catch(() => {}); signal?.removeEventListener('abort', abort); forgetExtraSecrets(secretKey);
268
+ },
269
+ finalize() {
270
+ const state = reviewSourceState(wt);
271
+ const evidence = reviewEvidence(reports, state);
272
+ if (reports.at(-1)?.folder) writeFileSync(join(wt, reports.at(-1).folder, 'delivery-state.json'), scrub(JSON.stringify({ finalState: state, latestMatches: sameReviewSource(reports.at(-1).after, state) && reports.at(-1).sourceStable, imageInputs: reports.flatMap(r => r.imageInputs ?? []) }, null, 2)));
273
+ return evidence;
274
+ },
275
+ };
276
+ }
277
+
278
+ /** No MCP assumption: every supported build CLI can use its existing shell. */
279
+ export async function uiReviewMain(mcp = false) {
280
+ if (mcp) {
281
+ await serveMcpStdio({ serverInfo: { name: 'card-ui-review', version: '1.0.0' }, rawResult: true,
282
+ tools: [{ name: 'inspect_screenshot', description: 'Open an exact PNG from this builder turn’s local UI-review output as native image input. Inspect it and report actual visual findings; capture alone is not inspection.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false } }],
283
+ call: async (_name, { path }) => {
284
+ const result = await callUiReview({ operation: 'inspect_screenshot', path });
285
+ return { content: [{ type: 'text', text: `${result.path}: ${result.note}` }, { type: 'image', mimeType: result.mimeType, data: result.data }] };
286
+ } });
287
+ return;
288
+ }
289
+ try {
290
+ const path = process.argv[3];
291
+ if (!path || lstatSync(path).size > 64 * 1024 || !lstatSync(path).isFile()) throw new Error('Supply a regular JSON plan file under 64 KiB.');
292
+ const plan = validateReviewPlan(JSON.parse(readFileSync(path, 'utf8')));
293
+ const result = await callUiReview(plan);
294
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
295
+ } catch (error) { process.stderr.write(reviewDiagnostic(error.message) + '\n'); process.exitCode = 1; }
296
+ }
297
+
298
+ async function callUiReview(input) {
299
+ const url = new URL(process.env.FLOWVIANT_UI_REVIEW_URL);
300
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || url.pathname !== '/' || url.username || url.password) throw new Error('No held builder UI-review capability is available.');
301
+ const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FLOWVIANT_UI_REVIEW_CAPABILITY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal: AbortSignal.timeout(UI_REVIEW_TIMEOUT_MS + 10_000) });
302
+ const result = await response.json();
303
+ if (!response.ok) throw new Error(result.error ?? 'Local UI review failed.');
304
+ return result;
305
+ }
@@ -0,0 +1,212 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir, platform } from 'node:os';
5
+ import { createCdpClient } from './pinnedCapture.mjs';
6
+ import { resolveBrowser, chromeFlags, sandboxOff } from './shot.mjs';
7
+ import { unattendedEnv } from './noWindowEnv.mjs';
8
+ import { childEnv } from './childEnv.mjs';
9
+ import { killGroup } from './processGroup.mjs';
10
+ import { scrub } from './uplinkScrub.mjs';
11
+ import { UI_REVIEW_TIMEOUT_MS, UI_REVIEW_VIEWPORTS } from './uiReviewContract.mjs';
12
+
13
+ export function reviewRequestAllowed(value, origin) {
14
+ try { const u = new URL(value); return u.protocol === 'http:' && u.origin === origin && !u.username && !u.password; }
15
+ catch { return false; }
16
+ }
17
+
18
+ /** URLs lose query, fragment and userinfo before retention. Text uses the
19
+ * daemon's existing learned-value/credential-shape scrub, then is bounded. */
20
+ export function reviewDiagnostic(value) {
21
+ return scrub(String(value).replace(/https?:\/\/[^\s"'<>]+/g, raw => {
22
+ try { const u = new URL(raw); return u.origin + u.pathname; } catch { return '[URL]'; }
23
+ })).slice(0, 1000);
24
+ }
25
+
26
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
27
+
28
+ // Chrome does not implement the proposed CSP webrtc directive. Install this
29
+ // before any document script, including in child frames. Frames/workers/new
30
+ // targets are independently blocked; the aliases cannot be restored by page
31
+ // script. No existing artifact policy is changed.
32
+ export const LOCAL_REVIEW_BOOTSTRAP = `for (const name of ['RTCPeerConnection', 'webkitRTCPeerConnection']) {
33
+ Object.defineProperty(globalThis, name, { value: undefined, configurable: false, writable: false });
34
+ }`;
35
+
36
+ /** A fresh profile and browser context per scenario/viewport. The origin is
37
+ * the supervisor's preview-only proxy, never a free-form browser destination.
38
+ * This records captures, not an assertion that a model has inspected them. */
39
+ export async function captureLocalReview({ origin, scenarios, artifactDir, signal,
40
+ browser = resolveBrowser(), spawnImpl = spawn, timeoutMs = UI_REVIEW_TIMEOUT_MS, onChild = () => () => {} }) {
41
+ const result = { status: 'unavailable', scenarios: [], consoleErrors: [], failedRequests: [], screenshots: [] };
42
+ if (!browser) return { ...result, reason: 'No Chrome/Chromium/Edge found; no browser checks ran.' };
43
+ if (browser.viaWindows) return { ...result, reason: 'WSL Windows interop cannot carry the scoped DevTools pipe.' };
44
+ if (signal?.aborted) return { ...result, reason: 'Review was cancelled before launch.' };
45
+ const profile = mkdtempSync(join(tmpdir(), 'flowviant-ui-browser-'));
46
+ let child, cdp, timer, abort, untrack;
47
+ const deadline = new Promise((_, reject) => {
48
+ timer = setTimeout(() => reject(new Error('Browser review timed out.')), timeoutMs);
49
+ abort = () => reject(new Error('Browser review cancelled.'));
50
+ signal?.addEventListener('abort', abort, { once: true });
51
+ });
52
+ let stopped = false;
53
+ const run = async () => {
54
+ child = spawnImpl(browser.bin, [
55
+ ...chromeFlags({ headlessFlag: '--headless=new', noSandbox: sandboxOff(), userDataDir: profile, width: 1440, height: 900 }),
56
+ '--remote-debugging-pipe', '--disable-background-networking', '--disable-extensions',
57
+ '--disable-sync', '--no-first-run', '--disable-default-apps',
58
+ '--force-webrtc-ip-handling-policy=disable_non_proxied_udp', 'about:blank',
59
+ ], { stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'], detached: platform() !== 'win32', env: unattendedEnv({ ...childEnv(), HOME: profile, USERPROFILE: profile, XDG_CONFIG_HOME: profile, XDG_DATA_HOME: profile, XDG_CACHE_HOME: profile }, { browserHome: profile }) });
60
+ untrack = onChild(child);
61
+ if (!child.stdio?.[3] || !child.stdio?.[4]) throw new Error('No DevTools pipe is available.');
62
+ let stderr = '';
63
+ child.stderr?.on('data', data => { stderr = reviewDiagnostic(stderr + data.toString()).slice(-2000); });
64
+ cdp = createCdpClient(child.stdio[3], child.stdio[4]);
65
+ child.on('error', error => cdp.close(reviewDiagnostic(error.message)));
66
+ child.on('exit', code => cdp.close(`Chromium exited (${code}). ${stderr}`));
67
+ child.stdio[3].on('error', () => {});
68
+ child.stdio[4].on('error', () => {});
69
+ await cdp.send('Browser.getVersion');
70
+ await cdp.send('Browser.setDownloadBehavior', { behavior: 'deny' });
71
+ const attached = new Map();
72
+ let active = null, creating = false;
73
+ const keep = (list, item) => { if (list.length < 100) list.push(item); else result.diagnosticsTruncated = true; };
74
+ cdp.on(msg => {
75
+ const p = msg.params ?? {};
76
+ if (msg.method === 'Target.attachedToTarget' && !msg.sessionId) {
77
+ attached.set(p.targetInfo.targetId, p.sessionId);
78
+ if (!creating && p.targetInfo.targetId !== active?.targetId) {
79
+ cdp.send('Target.closeTarget', { targetId: p.targetInfo.targetId }).catch(() => {});
80
+ // Chrome's startup blank tab and late attach events from contexts
81
+ // we just disposed are not windows the app opened.
82
+ if (active && p.targetInfo.openerId === active.targetId) keep(result.failedRequests, { reason: 'A new window/target was blocked.' });
83
+ }
84
+ }
85
+ if (!active || msg.sessionId !== active.sessionId) return;
86
+ if (msg.method === 'Fetch.requestPaused') {
87
+ const allowed = reviewRequestAllowed(p.request?.url, origin);
88
+ if (!allowed) keep(result.failedRequests, { url: reviewDiagnostic(p.request?.url), reason: 'Outside measured preview; blocked.' });
89
+ cdp.send(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', { requestId: p.requestId, ...(!allowed ? { errorReason: 'BlockedByClient' } : {}) }, active.sessionId).catch(() => {});
90
+ }
91
+ if (msg.method === 'Runtime.exceptionThrown') keep(result.consoleErrors, { scenario: active.name, message: reviewDiagnostic(p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text ?? 'Page exception') });
92
+ if (msg.method === 'Runtime.consoleAPICalled' && p.type === 'error') keep(result.consoleErrors, { scenario: active.name, message: reviewDiagnostic((p.args ?? []).map(a => a.value == null ? a.description ?? a.type : String(a.value)).join(' ')) });
93
+ if (msg.method === 'Log.entryAdded' && p.entry?.level === 'error') keep(result.consoleErrors, { scenario: active.name, message: reviewDiagnostic(p.entry.text) });
94
+ if (msg.method === 'Network.requestWillBeSent') {
95
+ active.requests.set(p.requestId, reviewDiagnostic(p.request?.url)); active.pending.add(p.requestId); active.networkAt = Date.now();
96
+ }
97
+ if (['Network.loadingFinished', 'Network.loadingFailed'].includes(msg.method)) { active.pending.delete(p.requestId); active.networkAt = Date.now(); }
98
+ if (msg.method === 'Network.loadingFailed') keep(result.failedRequests, { scenario: active.name, url: active.requests.get(p.requestId), reason: reviewDiagnostic(p.errorText) });
99
+ if (msg.method === 'Network.responseReceived' && p.response?.status >= 400) keep(result.failedRequests, { scenario: active.name, url: reviewDiagnostic(p.response.url), status: p.response.status });
100
+ });
101
+ await cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
102
+ for (const viewport of UI_REVIEW_VIEWPORTS) {
103
+ for (const [index, scenario] of scenarios.entries()) {
104
+ if (stopped || signal?.aborted) throw new Error('Browser review cancelled.');
105
+ const row = { name: reviewDiagnostic(scenario.name), path: reviewDiagnostic(scenario.path), viewport,
106
+ actions: scenario.actions.map(action => ({ type: action.type, ...(action.selector ? { selector: reviewDiagnostic(action.selector) } : {}), status: 'skipped', outcome: 'A preceding state/action was not observed.' })), screenshots: [], status: 'failed' };
107
+ result.scenarios.push(row);
108
+ const { browserContextId } = await cdp.send('Target.createBrowserContext', { disposeOnDetach: true });
109
+ let captureFailure;
110
+ try {
111
+ creating = true;
112
+ const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank', browserContextId });
113
+ for (let n = 0; !attached.has(targetId) && n < 20; n++) await delay(25);
114
+ const sessionId = attached.get(targetId) ?? (await cdp.send('Target.attachToTarget', { targetId, flatten: true })).sessionId;
115
+ active = { targetId, sessionId, name: `${viewport.name}: ${scenario.name}`, requests: new Map(), pending: new Set(), networkAt: Date.now() };
116
+ creating = false;
117
+ for (const other of attached.keys()) if (other !== targetId) cdp.send('Target.closeTarget', { targetId: other }).catch(() => {});
118
+ const send = (method, params = {}) => cdp.send(method, params, sessionId);
119
+ const evaluate = async expression => {
120
+ const answer = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
121
+ if (answer.exceptionDetails) throw new Error('Page evaluation failed; inspect console diagnostics.');
122
+ return answer.result?.value;
123
+ };
124
+ const until = async expression => {
125
+ for (let n = 0; n < 50; n++) {
126
+ if (stopped || signal?.aborted) throw new Error('Review cancelled.');
127
+ try { if (await evaluate(expression)) return; } catch { /* a navigation replaced the context */ }
128
+ await delay(100);
129
+ }
130
+ throw new Error('Expected state was not observed within five seconds.');
131
+ };
132
+ const settle = async () => {
133
+ const start = Date.now();
134
+ while (Date.now() - start < 2000) {
135
+ if (stopped || signal?.aborted) throw new Error('Review cancelled.');
136
+ if (!active.pending.size && Date.now() - active.networkAt >= 200) return;
137
+ await delay(50);
138
+ }
139
+ row.networkSettle = 'Requests did not settle within two seconds; later diagnostics are unverified.';
140
+ };
141
+ await send('Page.enable');
142
+ await send('Runtime.enable');
143
+ await send('Network.enable');
144
+ await send('Log.enable');
145
+ await send('Page.addScriptToEvaluateOnNewDocument', { source: LOCAL_REVIEW_BOOTSTRAP });
146
+ await send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
147
+ await send('Emulation.setDeviceMetricsOverride', { width: viewport.width, height: viewport.height, mobile: viewport.mobile, deviceScaleFactor: 1 });
148
+ await send('Emulation.setTouchEmulationEnabled', { enabled: viewport.mobile });
149
+ await send('Runtime.runIfWaitingForDebugger');
150
+ const url = new URL(scenario.path, origin).href;
151
+ const nav = await send('Page.navigate', { url });
152
+ if (nav.errorText) throw new Error(reviewDiagnostic(nav.errorText));
153
+ await until(`location.origin === ${JSON.stringify(origin)} && location.href !== 'about:blank' && document.readyState === 'complete'`);
154
+ await settle();
155
+ const screenshot = async phase => {
156
+ const history = await send('Page.getNavigationHistory');
157
+ if (!reviewRequestAllowed(history.entries?.[history.currentIndex]?.url, origin)) throw new Error('The document left the measured preview.');
158
+ const shot = await send('Page.captureScreenshot', { format: 'png' });
159
+ const bytes = Buffer.from(shot.data ?? '', 'base64');
160
+ if (!bytes.length || bytes.length > 2 * 1024 * 1024) throw new Error('Screenshot was empty or exceeded the retained artifact size.');
161
+ const name = `${viewport.name}-${index + 1}-${phase}.png`;
162
+ writeFileSync(join(artifactDir, name), bytes, { flag: 'wx' });
163
+ row.screenshots.push(name); result.screenshots.push(name);
164
+ };
165
+ captureFailure = () => screenshot('failure');
166
+ await screenshot('initial');
167
+ for (const [actionIndex, action] of scenario.actions.entries()) {
168
+ const observed = row.actions[actionIndex];
169
+ observed.status = 'failed'; delete observed.outcome;
170
+ const select = `document.querySelector(${JSON.stringify(action.selector ?? '')})`;
171
+ if (action.type === 'assertUrl') await until(`location.origin === ${JSON.stringify(origin)} && location.pathname + location.search + location.hash === ${JSON.stringify(action.value)}`);
172
+ else {
173
+ await until(`!!${select}`);
174
+ if (action.type === 'assertText') await until(`(${select}?.textContent ?? '').includes(${JSON.stringify(action.value)})`);
175
+ if (['click', 'fill', 'submit'].includes(action.type)) {
176
+ const operation = action.type === 'click' ? 'el.click();'
177
+ : action.type === 'submit' ? 'if (!(el instanceof HTMLFormElement)) throw Error("Expected form"); el.requestSubmit();'
178
+ : `const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')?.set; if (!setter) throw Error('Expected input'); setter.call(el, ${JSON.stringify(action.value)}); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true }));`;
179
+ await evaluate(`(() => { const el = ${select}; if (!el || el.disabled || !el.getClientRects().length) throw Error('Control unavailable'); ${operation} return true; })()`);
180
+ }
181
+ }
182
+ observed.status = 'observed';
183
+ observed.outcome = ['assertText', 'assertUrl', 'waitFor'].includes(action.type) ? 'Expected state observed.' : 'DOM action dispatched; subsequent assertions establish its outcome.';
184
+ }
185
+ await settle();
186
+ await screenshot('final');
187
+ row.status = scenario.actions.some(a => a.type.startsWith('assert')) ? 'observed' : 'captured';
188
+ if (!scenario.actions.length) row.skipped = 'No interaction scenarios supplied; captures only.';
189
+ } catch (error) {
190
+ row.reason = reviewDiagnostic(error.message);
191
+ await captureFailure?.().catch(() => {});
192
+ }
193
+ finally { active = null; creating = false; await cdp.send('Target.disposeBrowserContext', { browserContextId }).catch(() => {}); }
194
+ }
195
+ }
196
+ result.status = result.scenarios.some(row => row.status === 'failed') || result.consoleErrors.length || result.failedRequests.length ? 'findings' : 'captured';
197
+ result.visualInspection = 'Not measured by Chromium. The builder must open screenshots with an actual image-input tool and report its observations.';
198
+ return result;
199
+ };
200
+ try { return await Promise.race([run(), deadline]); }
201
+ catch (error) { return { ...result, status: 'unavailable', reason: reviewDiagnostic(error.message) }; }
202
+ finally {
203
+ stopped = true;
204
+ clearTimeout(timer); signal?.removeEventListener('abort', abort);
205
+ killGroup(child); cdp?.close('Browser review ended.');
206
+ for (const fd of [2, 3, 4]) child?.stdio?.[fd]?.destroy();
207
+ // Give the killed group a short bounded opportunity to release its profile.
208
+ if (child && child.exitCode == null && child.signalCode == null) await Promise.race([new Promise(r => child.once('exit', r)), delay(500)]);
209
+ try { rmSync(profile, { recursive: true, force: true }); } catch { /* never reuse this profile */ }
210
+ untrack?.();
211
+ }
212
+ }
@@ -0,0 +1,51 @@
1
+ /** Local UI review is a builder tool, never an approval or a pre-review. */
2
+ export const UI_REVIEW_MAX_RUNS = 3;
3
+ export const UI_REVIEW_TIMEOUT_MS = 120_000;
4
+ export const UI_REVIEW_VIEWPORTS = Object.freeze([
5
+ { name: 'desktop', width: 1440, height: 900, mobile: false },
6
+ { name: 'mobile', width: 390, height: 844, mobile: true },
7
+ ]);
8
+
9
+ /** Only relative routes on the measured preview; no repaired URLs. */
10
+ export function reviewPath(value) {
11
+ if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || /[\\\r\n]/.test(value)) throw new Error('Review routes must be local absolute paths.');
12
+ const url = new URL(value, 'http://preview.invalid');
13
+ if (url.origin !== 'http://preview.invalid' || url.username || url.password) throw new Error('Review route left the preview.');
14
+ return url.pathname + url.search + url.hash;
15
+ }
16
+
17
+ /** Shared input for the command client, supervisor and Chromium runner.
18
+ * command is argv, cwd is worktree-relative, port is explicitly configured in
19
+ * that repository command. No auto-install, public tunnel or personal session.
20
+ * captureLocalReview({ origin, scenarios, artifactDir, signal }) returns
21
+ * { status, reason?, scenarios, consoleErrors, failedRequests, screenshots }.
22
+ * createUiReview({ wt, signal }) returns { env, instructions, close, finalize }.
23
+ * finalize() returns Markdown evidence after remeasuring the final worktree.
24
+ */
25
+ export function validateReviewPlan(plan) {
26
+ if (!plan || !Array.isArray(plan.command) || !plan.command.length || plan.command.length > 40 || plan.command.some(s => typeof s !== 'string' || !s || s.length > 4000)) throw new Error('Supply the repository-supported preview command as an argv array.');
27
+ if (!Number.isInteger(plan.port) || plan.port < 1024 || plan.port > 65535) throw new Error('Supply a dedicated unprivileged preview port.');
28
+ if (typeof plan.basis !== 'string' || !plan.basis.trim()) throw new Error('Name the repository file/script establishing this preview command.');
29
+ if (plan.fixture?.isolated !== true || typeof plan.fixture.description !== 'string' || !plan.fixture.description.trim()) throw new Error('Describe isolated fixtures or disposable test state before browsing.');
30
+ if (!Array.isArray(plan.scenarios) || !plan.scenarios.length || plan.scenarios.length > 8) throw new Error('Supply one to eight acceptance scenarios.');
31
+ const kinds = ['click', 'fill', 'submit', 'assertText', 'assertUrl', 'waitFor'];
32
+ for (const scenario of plan.scenarios) {
33
+ reviewPath(scenario.path);
34
+ if (typeof scenario.name !== 'string' || !scenario.name.trim() || scenario.name.length > 200) throw new Error('Name each acceptance scenario.');
35
+ if (!Array.isArray(scenario.actions) || scenario.actions.length > 20) throw new Error('Each scenario takes at most twenty ordered actions.');
36
+ for (const action of scenario.actions) {
37
+ if (!kinds.includes(action.type)) throw new Error('Unsupported review action.');
38
+ if (action.type === 'assertUrl') reviewPath(action.value);
39
+ else if (typeof action.selector !== 'string' || !action.selector || action.selector.length > 1000) throw new Error('Supply a bounded CSS selector.');
40
+ if (['fill', 'assertText'].includes(action.type) && (typeof action.value !== 'string' || action.value.length > 4000)) throw new Error('Supply a bounded fixture value or expected text.');
41
+ }
42
+ }
43
+ reviewPath(plan.readyPath ?? '/');
44
+ return plan;
45
+ }
46
+
47
+ export function uiReviewInstructions(argv) {
48
+ return `LOCAL UI SELF-REVIEW — before delivering UI-changing code, use this turn's owned headless runner: ${JSON.stringify([...argv, '<plan.json>'])}.
49
+ Read repository preview scripts and affected acceptance criteria first. Write a JSON plan with command (argv array), basis (repository file/script), cwd (relative directory, optional), port (dedicated port explicitly set in command), readyPath, fixture: { isolated: true, description }, and scenarios: [{ name, path, actions: [{ type, selector, value }] }]. Actions: click, fill, submit, assertText, assertUrl (value is a local path), waitFor. Use only disposable local fixtures; never production data, real email, payments, personal sessions or credentials. The runner starts and measures its own preview and permits only that preview's resources. External dependencies are blocked and reported; use local fixtures instead.
50
+ Run desktop and mobile scenarios; read the returned report and OPEN EVERY returned screenshot using card_ui_review.inspect_screenshot when that tool is actually mounted (it returns the exact PNG as native image input), or your runtime's actual image-input tool (for example Read or view_image). Record which files you visually inspected and what you observed in delivery verification. A PNG path, capture success, tool listing or a skill is not proof of inspection. If image input is unavailable, report visual inspection skipped with its measured reason. Check console/network findings, expected and observed interaction results. Fix in-scope problems in this same worktree and rerun affected checks. At most ${UI_REVIEW_MAX_RUNS} runs per turn, each at most ${UI_REVIEW_TIMEOUT_MS / 1000}s; at the bound deliver remaining findings honestly. Later source edits invalidate earlier evidence; commit before the final run when possible. Include changed routes, ordered actions/outcomes, viewport sizes, screenshots, changes prompted by review and every unrun/unavailable check with reasons in verification. Missing browser, runnable preview or safe fixtures is unavailable, never passed; do not install tools or weaken isolation. This review never approves or merges work. For non-UI changes, state why browser review is not applicable.`;
51
+ }
@@ -0,0 +1,15 @@
1
+ /** Applied at every builder spawn, including native resumes and delivery
2
+ * continuations. Some CLIs do not reread system text on resume, so the same
3
+ * turn-scoped capability instructions also travel with its prompt. */
4
+ export function withUiReview(args, instructions, env) {
5
+ if (!instructions) return args;
6
+ return { ...args, prompt: `${args.prompt ?? ''}\n\n${instructions}`,
7
+ system: `${args.system ?? ''}\n\n${instructions}`,
8
+ ...(env ? { mcpEnv: { ...args.mcpEnv, ...env } } : {}),
9
+ };
10
+ }
11
+
12
+ /** Browser observations share the existing delivery Verification surface. */
13
+ export function appendUiReviewEvidence(body, evidence) {
14
+ if (body.outcome === 'delivered' && evidence) body.verification = [body.verification, evidence].filter(Boolean).join('\n\n');
15
+ }