flowviant 0.125.28 → 0.125.30

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,68 @@
1
+ /** Windows Studio through WSL stdio; no Windows daemon or desktop capture. */
2
+ import { createInterface } from 'node:readline';
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdirSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { basename, join } from 'node:path';
7
+ import { openBridgePage } from './reviewBridge.mjs';
8
+ import { openRobloxPage, robloxConfiguration } from './robloxReview.mjs';
9
+ import { heldToolArgv } from './heldTool.mjs';
10
+ import { browserAction } from './managedBrowser.mjs';
11
+ import { reviewDiagnostic } from './uiReviewBrowser.mjs';
12
+ import { commandOnPath } from './reviewTargets.mjs';
13
+
14
+ export function openRobloxStudio({ command, cwd, worktree, configuration, ...options }) {
15
+ const config = robloxConfiguration(configuration);
16
+ const name = config.studioName.replaceAll('${WORKTREE}', basename(worktree));
17
+ const flock = commandOnPath('flock', options.env);
18
+ if (process.platform !== 'linux' || !flock) throw new Error('The first Studio adapter requires a Linux/WSL daemon with flock and a configured Windows Studio MCP launcher.');
19
+ const directory = join(homedir(), '.flowviant', 'review-studios'); mkdirSync(directory, { recursive: true, mode: 0o700 });
20
+ const lock = join(directory, createHash('sha256').update(name).digest('hex') + '.lock');
21
+ return openBridgePage({ ...options, command: [flock, '-n', '-E', '75', lock, process.execPath, ...heldToolArgv('__roblox-studio-bridge')], cwd, worktree,
22
+ configuration: { ...config, command, cwd, timeoutMs: options.timeoutMs }, requestTimeoutMs: 60_000, closeGraceMs: 30_000,
23
+ }).catch(error => {
24
+ if (/exited \(75\)/.test(error.message)) throw new Error('This Studio fixture is already being reviewed by another local turn. Return or stop that review first.');
25
+ throw error;
26
+ });
27
+ }
28
+
29
+ export async function robloxStudioBridgeMain() {
30
+ const controller = new AbortController();
31
+ let page, configuration, worktree, epoch = 0, closing = false, shuttingDown;
32
+ const send = value => { if (!closing) process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\n'); };
33
+ const shutdown = () => {
34
+ if (shuttingDown) return shuttingDown;
35
+ closing = true; controller.abort();
36
+ shuttingDown = Promise.resolve().then(() => page?.close()).catch(() => { process.exitCode = 1; });
37
+ return shuttingDown;
38
+ };
39
+ process.stdin.once('end', () => { void shutdown(); }); process.stdin.once('error', () => { void shutdown(); }); process.stdout.once('error', () => { void shutdown(); });
40
+ process.once('SIGTERM', () => { void shutdown(); process.stdin.destroy(); }); process.once('SIGINT', () => { void shutdown(); process.stdin.destroy(); });
41
+ try {
42
+ for await (const line of createInterface({ input: process.stdin })) {
43
+ if (closing) break;
44
+ let request;
45
+ try {
46
+ if (Buffer.byteLength(line) > 128 * 1024) throw new Error('Studio bridge request exceeds its byte bound.');
47
+ request = JSON.parse(line);
48
+ const { id, method, params } = request; let result = {};
49
+ if (method === 'initialize' && !configuration) {
50
+ configuration = params.configuration; worktree = params.worktree;
51
+ if (params.protocol !== 1 || !Array.isArray(configuration?.command) || typeof worktree !== 'string') throw new Error('Invalid Studio bridge initialization.');
52
+ robloxConfiguration(configuration);
53
+ result = { protocol: 1, isolation: 'consented-window', privateHandoff: true };
54
+ } else if (method === 'open' && configuration && !page) {
55
+ const opened = await openRobloxPage({ command: configuration.command, cwd: configuration.cwd, worktree, configuration, env: process.env, signal: controller.signal, timeoutMs: configuration.timeoutMs,
56
+ onFrame: frame => send({ method: 'frame', params: { ...frame, epoch } }), onChange: patch => send({ method: 'failure', params: { epoch, message: patch.error } }) });
57
+ page = opened;
58
+ // Parent EOF may have arrived while Studio was starting play.
59
+ if (closing) await opened.close();
60
+ } else if (method === 'snapshot' && page) result = { ...await page.snapshot(), width: 1440, height: 900, epoch };
61
+ else if (method === 'act' && page && params.epoch === epoch) await page.act(browserAction(params.input));
62
+ else if (method === 'privacy' && page) { await page.setPrivate(params.private); epoch = params.epoch; result = { epoch, drained: true }; }
63
+ else throw new Error('Unsupported or stale Studio bridge operation.');
64
+ send({ id, result });
65
+ } catch (error) { send({ id: request?.id, error: { message: reviewDiagnostic(error.message) } }); }
66
+ }
67
+ } finally { await shutdown(); }
68
+ }
@@ -1,4 +1,4 @@
1
- /** Claude Code's own auth verdict for the local desktop contract.
1
+ /** Coding tools' own auth verdicts for the local desktop contract.
2
2
  *
3
3
  * The credential file is only a context clue (claudeAuth.mjs): on some boxes
4
4
  * Claude stores a working login in a keychain. Only `claude auth status --json`
@@ -15,6 +15,28 @@ const UNKNOWN = Object.freeze({ signedIn: null, billing: null, subscriptionType:
15
15
  const TTL_MS = 90_000;
16
16
  const SUBSCRIPTIONS = new Set(['pro', 'max', 'team', 'enterprise']);
17
17
 
18
+ function cachedAuth(cachePath, bin, now, refresh) {
19
+ if (refresh) return null;
20
+ try {
21
+ const cached = JSON.parse(readFileSync(cachePath, 'utf8'));
22
+ if (cached.bin === bin && Number.isFinite(cached.at) && now >= cached.at && now - cached.at < TTL_MS &&
23
+ cached.result?.checkedAt === new Date(cached.at).toISOString() &&
24
+ [true, false, null].includes(cached.result?.signedIn) &&
25
+ [null, 'api', 'subscription'].includes(cached.result?.billing)) return cached.result;
26
+ } catch { /* No usable cache: ask the CLI, including after an upgrade. */ }
27
+ return null;
28
+ }
29
+
30
+ function retainAuth(cachePath, bin, now, result) {
31
+ const measured = { ...result, checkedAt: new Date(now).toISOString(),
32
+ expiresAt: result.expiresAt ?? null, expiryKind: result.expiryKind ?? null };
33
+ try {
34
+ mkdirSync(dirname(cachePath), { recursive: true, mode: 0o700 });
35
+ writeFileSync(cachePath, JSON.stringify({ bin, at: now, result: measured }), { mode: 0o600 });
36
+ } catch { /* Caching is optional; never break status. */ }
37
+ return measured;
38
+ }
39
+
18
40
  export function parseClaudeAuthStatus(raw) {
19
41
  let value;
20
42
  try { value = JSON.parse(raw); } catch { return UNKNOWN; }
@@ -31,14 +53,8 @@ export function parseClaudeAuthStatus(raw) {
31
53
 
32
54
  export function readClaudeAuthStatus({ bin = 'claude', cachePath = join(homedir(), '.flowviant', 'claude-auth-status.json'),
33
55
  refresh = false, timeoutMs = 3_000, now = Date.now(), spawn = spawnSync, contextFor = claudeAuthContext } = {}) {
34
- if (!refresh) {
35
- try {
36
- const cached = JSON.parse(readFileSync(cachePath, 'utf8'));
37
- if (cached.bin === bin && Number.isFinite(cached.at) && now >= cached.at && now - cached.at < TTL_MS &&
38
- [true, false, null].includes(cached.result?.signedIn) &&
39
- [null, 'api', 'subscription'].includes(cached.result?.billing)) return cached.result;
40
- } catch { /* no cache, or unreadable cache: ask the CLI */ }
41
- }
56
+ const cached = cachedAuth(cachePath, bin, now, refresh);
57
+ if (cached) return cached;
42
58
  let result = UNKNOWN;
43
59
  try {
44
60
  const child = spawn(bin, ['auth', 'status', '--json'], {
@@ -46,16 +62,40 @@ export function readClaudeAuthStatus({ bin = 'claude', cachePath = join(homedir(
46
62
  stdio: ['ignore', 'pipe', 'ignore'],
47
63
  });
48
64
  if (!child.error && (child.status === 0 || child.status === 1)) result = parseClaudeAuthStatus(child.stdout);
49
- if (result.signedIn === true && result.billing === 'subscription' && result.subscriptionType === null) {
65
+ if (result.signedIn === true && result.billing === 'subscription') {
50
66
  const context = contextFor();
51
- if (context.source === 'file' && SUBSCRIPTIONS.has(context.subscriptionType)) {
52
- result = { ...result, subscriptionType: context.subscriptionType };
67
+ if (context.source === 'file') {
68
+ if (result.subscriptionType === null && SUBSCRIPTIONS.has(context.subscriptionType)) {
69
+ result = { ...result, subscriptionType: context.subscriptionType };
70
+ }
71
+ // Access-token expiry is renewable. Only the store's explicit refresh
72
+ // expiry can describe the end of this login; absent stays unknown.
73
+ const expiry = Date.parse(context.refreshExpiresAt ?? '');
74
+ if (Number.isFinite(expiry)) result = { ...result, expiresAt: new Date(expiry).toISOString(), expiryKind: 'refresh' };
53
75
  }
54
76
  }
55
77
  } catch { /* A failed probe means unknown, not signed out. */ }
78
+ return retainAuth(cachePath, bin, now, result);
79
+ }
80
+
81
+ /** `codex login status` writes its verdict to stderr. Keep only the verdict,
82
+ * never its output: API-key mode can include credential material. */
83
+ export function readCodexAuthStatus({ bin = 'codex', cachePath = join(homedir(), '.flowviant', 'codex-auth-status.json'),
84
+ refresh = false, timeoutMs = 3_000, now = Date.now(), spawn = spawnSync } = {}) {
85
+ const cached = cachedAuth(cachePath, bin, now, refresh);
86
+ if (cached) return cached;
87
+ let result = UNKNOWN;
56
88
  try {
57
- mkdirSync(dirname(cachePath), { recursive: true, mode: 0o700 });
58
- writeFileSync(cachePath, JSON.stringify({ bin, at: now, result }), { mode: 0o600 });
59
- } catch { /* Caching is optional; never break status. */ }
60
- return result;
89
+ const child = spawn(bin, ['login', 'status'], {
90
+ encoding: 'utf8', timeout: timeoutMs, maxBuffer: 64 * 1024,
91
+ stdio: ['ignore', 'pipe', 'pipe'],
92
+ });
93
+ const words = `${child.stdout ?? ''}\n${child.stderr ?? ''}`.trim();
94
+ if (!child.error && child.status === 0 && /^logged in\b/im.test(words)) {
95
+ result = { signedIn: true, billing: /api.?key/i.test(words) ? 'api' : /chatgpt/i.test(words) ? 'subscription' : null, subscriptionType: null };
96
+ } else if (!child.error && child.status === 1 && /^not logged in\b/im.test(words)) {
97
+ result = { ...UNKNOWN, signedIn: false };
98
+ }
99
+ } catch { /* Unknown, never a fabricated sign-out. */ }
100
+ return retainAuth(cachePath, bin, now, result);
61
101
  }
@@ -0,0 +1,185 @@
1
+ /** One model-independent review tool over reusable execution/surface adapters. */
2
+ import { createServer } from 'node:net';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { writeFileSync } from 'node:fs';
5
+ import { basename, join } from 'node:path';
6
+ import { listenHeldTool, heldToolArgv } from './heldTool.mjs';
7
+ import { serveMcpStdio } from './mcpStdio.mjs';
8
+ import { readReviewTargets, reviewCwd, commandOnPath } from './reviewTargets.mjs';
9
+ import { startReviewProcess } from './reviewProcess.mjs';
10
+ import { desktopSupport } from './reviewDesktop.mjs';
11
+ import { openLinuxDesktop } from './linuxDesktopBridge.mjs';
12
+ import { openBridgePage } from './reviewBridge.mjs';
13
+ import { openRobloxStudio } from './robloxStudioBridge.mjs';
14
+ import { reviewedProviderCommand } from './reviewEnvironmentConfig.mjs';
15
+ import { environmentOverlay, readWorktreeEnvironment } from './reviewEnvironment.mjs';
16
+ import { childEnv } from './childEnv.mjs';
17
+ import { measureListeners } from './listeners.mjs';
18
+ import { resolveBrowser } from './shot.mjs';
19
+ import { reviewSourceState, sameReviewSource } from './uiReview.mjs';
20
+ import { reviewDiagnostic } from './uiReviewBrowser.mjs';
21
+ import { ensureArtifactDir, ARTIFACT_DIR } from './artifacts.mjs';
22
+ import { learnExtraSecrets, forgetExtraSecrets, scrub } from './uplinkScrub.mjs';
23
+
24
+ export const RUNTIME_REVIEW_SERVER = 'card_runtime';
25
+ const TOOLS = [
26
+ { name: 'discover', description: 'List reviewed command, browser, desktop, Roblox Studio and engine review targets and measured prerequisites. Discovery starts no application.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
27
+ { name: 'run', description: 'Run a named reviewed target. Command checks return logs and source-bound evidence; interactive targets open the SAME Live app frame and human control handoff. Never treats a build or sync server as gameplay.', inputSchema: { type: 'object', properties: { target: { type: 'string' }, repository: { type: 'string' } }, required: ['target'], additionalProperties: false } },
28
+ { name: 'observe', description: 'Capture and retain the current shared runtime surface. Screenshot capture is an observation, not a passing test.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
29
+ { name: 'act', description: 'Interact with the current surface using bounded click, type, key, scroll or browser navigation verbs. Human control pauses model interactions.', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: ['click', 'type', 'key', 'scroll', 'navigate', 'snapshot'] }, x: { type: 'number' }, y: { type: 'number' }, text: { type: 'string' }, key: { type: 'string' }, path: { type: 'string' }, selector: { type: 'string' }, deltaY: { type: 'number' } }, required: ['type'], additionalProperties: false } },
30
+ { name: 'handoff', description: 'Hold this turn for human private sign-in or native app testing. Waits without model polling and resumes the same conversation.', inputSchema: { type: 'object', properties: { kind: { type: 'string', enum: ['signin', 'control'] }, reason: { type: 'string', maxLength: 300 } }, required: ['kind', 'reason'], additionalProperties: false } },
31
+ { name: 'stop', description: 'Stop the runtime processes owned by this tool. A person’s Stop prevents reopening for the turn.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
32
+ ];
33
+ const text = value => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
34
+ const availablePort = () => new Promise((resolve, reject) => {
35
+ const server = createServer(); server.once('error', reject);
36
+ server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); });
37
+ });
38
+
39
+ export async function createRuntimeReview({ wt, repoRoot, baseRef, sources = [], browser, signal, onChild,
40
+ openDesktop = openLinuxDesktop, openBridge = openBridgePage, openRoblox = openRobloxStudio, desktopProbe = desktopSupport, browserProbe = resolveBrowser }) {
41
+ const catalog = (sources.length ? sources : [{ wt, repoRoot, baseRef }]).map(source => ({ ...readReviewTargets(source.repoRoot, source.baseRef),
42
+ wt: source.wt, name: source.name ?? basename(source.repoRoot), id: source.id }));
43
+ if (catalog.every(source => Object.keys(source.targets).length === 0)) return null;
44
+ const controller = new AbortController(), reports = [], commands = new Set();
45
+ let closed = false, busy = false, current = null, app = null;
46
+ const abort = () => { void close(); };
47
+ const environmentFor = source => readWorktreeEnvironment(source.wt)?.env ?? process.env;
48
+ function support(target, env) {
49
+ if (target.platforms && !target.platforms.includes(process.platform)) return { available: false, reason: `This target requires ${target.platforms.join(' or ')}; this machine is ${process.platform}.` };
50
+ if (target.adapter === 'browser') {
51
+ const found = browserProbe();
52
+ return browser && found && !found.viaWindows ? { available: true } : { available: false, reason: 'No native managed Chromium browser is available on this machine.' };
53
+ }
54
+ if (target.adapter === 'desktop') return browser ? desktopProbe(env) : { available: false, reason: 'The interactive surface transport is unavailable on this turn.' };
55
+ if (target.adapter === 'roblox') {
56
+ const found = browserProbe();
57
+ return browser && process.platform === 'linux' && commandOnPath('flock', env) && found && !found.viaWindows ? { available: true } : { available: false, reason: 'Studio viewport review needs a Linux/WSL daemon, flock and native headless Chromium. Studio/MCP runs on the Windows host.' };
58
+ }
59
+ if (target.adapter === 'bridge' && !browser) return { available: false, reason: 'The interactive surface transport is unavailable on this turn.' };
60
+ return { available: true };
61
+ }
62
+ const stopApp = async () => { const previous = app; app = null; await previous?.close(); };
63
+ async function close() {
64
+ if (closed) return;
65
+ closed = true; signal?.removeEventListener('abort', abort); controller.abort();
66
+ if (current) await browser?.close();
67
+ await Promise.all([stopApp(), ...[...commands].map(command => command.close())]);
68
+ held.server.close(); held.server.closeAllConnections(); forgetExtraSecrets(secretKey);
69
+ }
70
+ const record = (source, target, name, before, result) => {
71
+ const after = reviewSourceState(source.wt);
72
+ const report = { target: name, repository: source.name, adapter: target.adapter, ...result,
73
+ before, after, sourceStable: sameReviewSource(before, after) };
74
+ reports.push({ source, report });
75
+ if (ensureArtifactDir(wt)) {
76
+ const path = `runtime-review-${randomUUID()}.md`;
77
+ const body = `# Runtime review: ${source.name}/${name}\n\n${scrub(JSON.stringify(report, null, 2))}\n`;
78
+ writeFileSync(join(wt, ARTIFACT_DIR, path), body, { flag: 'wx', mode: 0o600 });
79
+ report.artifact = `${ARTIFACT_DIR}/${path}`;
80
+ }
81
+ return report;
82
+ };
83
+ const call = async (name, input = {}) => {
84
+ if (closed || controller.signal.aborted) throw new Error('This review turn has ended.');
85
+ if (name === 'discover') return text({ repositories: catalog.map(source => ({ repository: source.name, targets: Object.entries(source.targets).map(([target, config]) => ({ target, adapter: config.adapter,
86
+ ...support(config, environmentFor(source)), command: config.command })) })),
87
+ instruction: 'Use the repository’s existing workflows when no recipe is configured. A missing runtime leaves those checks unverified; code editing can continue. A Roblox/Rojo sync server is not gameplay.' });
88
+ if (busy) throw new Error('Another runtime review operation is finishing.');
89
+ busy = true;
90
+ try {
91
+ if (name === 'observe') { if (!browser) throw new Error('No interactive runtime is available.'); return await browser.act({ type: 'snapshot' }); }
92
+ if (name === 'act') { if (!browser) throw new Error('No interactive runtime is available.'); return await browser.act(input); }
93
+ if (name === 'handoff') { if (!browser) throw new Error('No interactive runtime is available.'); return await browser.handoff(input); }
94
+ if (name === 'stop') { browser?.stopSurface(); await stopApp(); current = null; return text({ stopped: true }); }
95
+ if (name !== 'run') throw new Error('Unknown runtime tool.');
96
+ const source = input.repository == null ? catalog.find(source => source.wt === wt) : catalog.find(source => source.name === input.repository || source.id === input.repository);
97
+ const target = source && Object.hasOwn(source.targets, input.target) ? source.targets[input.target] : null;
98
+ if (!target) throw new Error('That reviewed target does not exist. Call discover and use a listed repository/target.');
99
+ const cwd = reviewCwd(source.wt, target.cwd), env = environmentFor(source), measured = support(target, env);
100
+ if (!measured.available) throw new Error(measured.reason);
101
+ if (!commandOnPath(target.command[0], env) && !target.command[0].startsWith('./')) throw new Error(`Review command ${target.command[0]} is not installed on this machine.`);
102
+ const scoped = readWorktreeEnvironment(source.wt);
103
+ const interactiveEnv = { ...childEnv({ cwd }), ...Object.fromEntries((target.env ?? []).filter(key => env[key] != null).map(key => [key, env[key]])), ...(scoped ? environmentOverlay(scoped) : {}) };
104
+ const before = reviewSourceState(source.wt), options = { cwd, env: target.adapter === 'command' ? env : interactiveEnv, onChild, signal: controller.signal, timeoutMs: target.timeoutSeconds * 1000 };
105
+ if (target.adapter === 'command') {
106
+ const running = startReviewProcess(target.command, options);
107
+ commands.add(running);
108
+ let result;
109
+ try { result = await running.done; } finally { commands.delete(running); }
110
+ const report = record(source, target, input.target, before, { status: !result.error && result.code === 0 ? 'passed' : 'failed', exitCode: result.code, output: result.output, ...(result.error ? { error: result.error } : {}) });
111
+ return text({ ...report, output: result.output.slice(-4000), ...(result.output.length > 4000 ? { outputTruncated: true } : {}) });
112
+ }
113
+ browser.assertControl();
114
+ const key = `${source.wt}:${input.target}`;
115
+ if (target.adapter === 'browser') {
116
+ try {
117
+ if (current?.key !== key || !app?.running()) {
118
+ await stopApp();
119
+ const port = target.port || await availablePort();
120
+ if (measureListeners(source.wt).rows.some(row => row.port === port)) throw new Error('That worktree port is already running. Use card_browser for the existing server, or choose a different review port.');
121
+ app = startReviewProcess(target.command.map(arg => arg.replaceAll('${PORT}', String(port))), options);
122
+ current = { key, port };
123
+ for (let n = 0; n < 100; n++) {
124
+ if (controller.signal.aborted) throw new Error('The review turn ended during startup.');
125
+ if (!app.running()) throw new Error(app.result()?.error ?? `The review app exited. ${app.output()}`);
126
+ if (measureListeners(source.wt).rows.some(row => row.port === port)) break;
127
+ if (n === 99) throw new Error('No listener was measured for this review target.');
128
+ await new Promise(resolve => setTimeout(resolve, 100));
129
+ }
130
+ }
131
+ return await browser.open({ port: current.port, hostname: target.hostname, scheme: target.scheme, path: target.path ?? '/', fixture: target.fixture }, source.wt);
132
+ }
133
+ catch (error) { await stopApp(); current = null; throw error; }
134
+ }
135
+ const result = await browser.openSurface({ key, target: input.target, surface: target.adapter === 'desktop' ? 'desktop' : 'engine',
136
+ open: async surface => {
137
+ await stopApp(); current = { key };
138
+ if (target.adapter === 'desktop') return openDesktop({ ...options, ...surface, command: target.command, support: measured });
139
+ const command = reviewedProviderCommand({ root: source.root, commit: source.commit, recipe: { database: { command: target.command } } });
140
+ if (target.adapter === 'roblox') return openRoblox({ ...options, ...surface, command, cwd: source.root, worktree: source.wt, configuration: target.configuration });
141
+ return openBridge({ ...options, ...surface, command, cwd: source.root, worktree: source.wt, configuration: target.configuration });
142
+ } });
143
+ record(source, target, input.target, before, { status: 'observed', note: 'Interactive runtime opened. Captures and input do not establish passing gameplay tests.' });
144
+ return result;
145
+ } catch (error) { throw new Error(reviewDiagnostic(error.message)); }
146
+ finally { busy = false; }
147
+ };
148
+ const held = await listenHeldTool({ envPrefix: 'FLOWVIANT_RUNTIME_REVIEW', verb: '__runtime-review-mcp', requestTimeoutMs: 615_000, isClosed: () => closed,
149
+ respond: async (req, res) => {
150
+ try {
151
+ const chunks = []; let size = 0;
152
+ for await (const bytes of req) { size += bytes.length; if (size > 16_000) throw new Error('Runtime request exceeds its byte bound.'); chunks.push(bytes); }
153
+ const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
154
+ if (!TOOLS.some(tool => tool.name === input.name)) throw new Error('Unknown runtime tool.');
155
+ const result = await call(input.name, input.input);
156
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(result));
157
+ } catch (error) { res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ isError: true, content: [{ type: 'text', text: reviewDiagnostic(error.message) }] })); }
158
+ } });
159
+ const secretKey = `runtime-review:${held.capability.slice(0, 12)}`;
160
+ learnExtraSecrets(secretKey, [{ name: 'FLOWVIANT_RUNTIME_REVIEW_CAPABILITY', value: held.capability }]);
161
+ signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) await close();
162
+ return { env: held.env, config: { command: process.execPath, args: held.args, env: held.env, toolTimeoutSec: 650 }, close,
163
+ instructions: `Use card_runtime.discover to find reviewed command, browser, desktop or engine targets. Run a named target with card_runtime.run; never treat code compilation or a sync server as a visual/gameplay test. Desktop targets use a private virtual display, never the personal desktop. Native/engine screenshots use the SAME Live app frame and handoff controls as browser review. Human control pauses model observations/input; call handoff before asking for sign-in or manual testing and finish this invocation without polling. Missing capabilities are skipped checks, not passing tests or reasons to stop code work. Keep external services on scoped test bindings. Observe and retain actual captures before delivery. For a runner without MCP, this turn also provides the private shell helper argv ${JSON.stringify([process.execPath, ...heldToolArgv('__runtime-review')])}; pass a JSON request file {name,input}.`,
164
+ finalize() {
165
+ return reports.map(({ source, report }) => `Runtime review ${report.repository}/${report.target}: ${report.status}${report.status === 'observed' ? ' (not a passing gameplay test)' : ''}. ${report.sourceStable && sameReviewSource(report.after, reviewSourceState(source.wt)) ? 'Source matches final tracked/nonignored state.' : 'STALE: source changed or could not be measured; this does not verify final source.'}${report.artifact ? ` Evidence: ${report.artifact}.` : ''}`).join('\n');
166
+ },
167
+ };
168
+ }
169
+
170
+ async function requestRuntime(input) {
171
+ const response = await fetch(process.env.FLOWVIANT_RUNTIME_REVIEW_URL, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FLOWVIANT_RUNTIME_REVIEW_CAPABILITY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal: AbortSignal.timeout(615_000) });
172
+ if (!response.ok) throw new Error('The held runtime turn has ended.');
173
+ return response.json();
174
+ }
175
+ export async function runtimeReviewMain(mcp = false) {
176
+ if (mcp) return serveMcpStdio({ serverInfo: { name: RUNTIME_REVIEW_SERVER, version: '1.0.0' }, tools: TOOLS, rawResult: true, call: (name, input) => requestRuntime({ name, input }) });
177
+ try {
178
+ const { readFileSync } = await import('node:fs');
179
+ const result = await requestRuntime(JSON.parse(readFileSync(process.argv[3], 'utf8')));
180
+ // Shell tools are text transports. Never flood a model context with image
181
+ // base64; observe retains a local artifact that its image tool can inspect.
182
+ const content = result.content?.map(block => block.type === 'image' ? { type: 'text', text: 'Image shown in Live app. Use observe to retain it, then open the reported artifact with an available image tool. Capture alone does not establish visual inspection.' } : block);
183
+ process.stdout.write(JSON.stringify({ ...result, ...(content ? { content } : {}) }) + '\n'); process.exitCode = result.isError ? 1 : 0;
184
+ } catch (error) { process.stderr.write(error.message + '\n'); process.exitCode = 1; }
185
+ }
@@ -12,6 +12,7 @@ import { procStat } from './procStat.mjs';
12
12
  import { killGroup } from './processGroup.mjs';
13
13
  import { unattendedEnv } from './noWindowEnv.mjs';
14
14
  import { childEnv } from './childEnv.mjs';
15
+ import { environmentOverlay, readWorktreeEnvironment } from './reviewEnvironment.mjs';
15
16
  import { gitRaw } from './git.mjs';
16
17
  import { serveMcpStdio } from './mcpStdio.mjs';
17
18
  import { scrub, learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs';
@@ -172,8 +173,10 @@ export async function createUiReview({ wt, signal, capture = captureLocalReview,
172
173
  record.folder = `${ARTIFACT_DIR}/${name}`;
173
174
  record.artifact = `${record.folder}/report.json`;
174
175
  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.
176
+ const environment = readWorktreeEnvironment(wt);
177
+ const env = unattendedEnv({ ...childEnv({ cwd }), ...(environment ? environmentOverlay(environment) : {}) }, { browserHome: scratch });
178
+ // Only explicit scoped bindings join the minimal preview environment;
179
+ // no inherited personal home, SDK credentials or browser sessions.
177
180
  env.HOME = scratch; env.USERPROFILE = scratch; env.XDG_CONFIG_HOME = scratch;
178
181
  env.XDG_DATA_HOME = scratch; env.XDG_CACHE_HOME = scratch;
179
182
  let output = '';
package/bin/lib/work.mjs CHANGED
@@ -67,6 +67,8 @@ import { placeTurnLockedByLivePid } from './turnLock.mjs';
67
67
  import { createWorkLocalCleanup } from './workLocalCleanup.mjs';
68
68
  import { createManagedLocalCleanup } from './managedLocalCleanup.mjs';
69
69
  import { trackedRepositorySetup } from './repositorySetup.mjs';
70
+ import { sweepWorktreeEnvironments } from './reviewEnvironment.mjs';
71
+ import { workLanesStopped } from './standDownGate.mjs';
70
72
  import { createRepositoryPlaces } from './repositoryPlaces.mjs';
71
73
  import { createAgentRepositories } from './agentRepositories.mjs';
72
74
  import { createWorkProcesses } from './workProcesses.mjs';
@@ -395,6 +397,7 @@ export function createWorkManager({
395
397
  // The check and the pre-review are built with only what each one uses; the
396
398
  // review entry beat is handed the two and owns only their order.
397
399
  const { runCheck } = createWorkAgentCheck({
400
+ baseRef,
398
401
  repoRoot,
399
402
  postBestEffort,
400
403
  workChildren,
@@ -461,6 +464,23 @@ export function createWorkManager({
461
464
  sweepMergedSessionBranch,
462
465
  });
463
466
 
467
+ let environmentSweep = null, lastEnvironmentSweep = 0;
468
+ const reconcileEnvironments = () => {
469
+ if (environmentSweep || workLanesStopped() || Date.now() - lastEnvironmentSweep < 60_000) return;
470
+ lastEnvironmentSweep = Date.now();
471
+ environmentSweep = (async () => {
472
+ const roots = [...new Set(repositories?.list().map(r => r.repoRoot).filter(Boolean) ?? [repoRoot])];
473
+ for (const root of roots) {
474
+ if (workLanesStopped()) break;
475
+ const result = await sweepWorktreeEnvironments(root, {
476
+ onSpawn: ch => { workChildren.set(ch, null); groupKillChildren.add(ch); },
477
+ onClose: ch => { workChildren.delete(ch); groupKillChildren.delete(ch); },
478
+ });
479
+ for (const failure of result.errors) warn(envScrub(`Environment ${failure.id}: ${failure.error}`));
480
+ }
481
+ })().catch(error => warn(envScrub(error.message))).finally(() => { environmentSweep = null; });
482
+ };
483
+
464
484
  const { processKillJobs } = createWorkProcesses({
465
485
  placeDir,
466
486
  reportPlaceWorktrees,
@@ -498,7 +518,7 @@ export function createWorkManager({
498
518
  workChildren.size > 0 ||
499
519
  workAnswering.size > 0 ||
500
520
  pendingWorkReports.size > 0 ||
501
- pendingShipReports.size > 0 || cleanupBusy();
521
+ pendingShipReports.size > 0 || cleanupBusy() || environmentSweep !== null;
502
522
 
503
523
  return {
504
524
  flushWorkReports,
@@ -514,6 +534,7 @@ export function createWorkManager({
514
534
  retirePreviews,
515
535
  shutdownPreviews,
516
536
  retireWorkSessions,
537
+ reconcileEnvironments,
517
538
  reportWorktrees,
518
539
  reconcileIdleArtifacts,
519
540
  reconcileLocalCleanup,
@@ -21,6 +21,9 @@ import { scrub as envScrub } from './uplinkScrub.mjs';
21
21
  import { agentBrowserHome, openBrowserHome, unattendedEnv } from './noWindowEnv.mjs';
22
22
  import { fleetEndpoint } from './fleetWire.mjs';
23
23
  import { workLanesStopped } from './standDownGate.mjs';
24
+ import { setupRepository } from './repositorySetup.mjs';
25
+ import { readEnvironmentRecipe } from './reviewEnvironmentConfig.mjs';
26
+ import { holdWorktreeEnvironment } from './reviewEnvironment.mjs';
24
27
 
25
28
  /**
26
29
  * CLEAR THE ARTIFACTS DIRECTORY OF ANYTHING THAT IS NOT AN ARTIFACT, BEFORE
@@ -135,6 +138,7 @@ export function readConfiguredCheck(root) {
135
138
 
136
139
  export function createWorkAgentCheck({
137
140
  repoRoot,
141
+ baseRef = () => 'HEAD',
138
142
  postBestEffort,
139
143
  workChildren,
140
144
  groupKillChildren,
@@ -184,6 +188,24 @@ export function createWorkAgentCheck({
184
188
  await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
185
189
  return { status: 'none' };
186
190
  }
191
+ let environment = null, releaseEnvironment = () => {};
192
+ try {
193
+ const root = repository?.repoRoot ?? repoRoot;
194
+ const snapshot = readEnvironmentRecipe(root, repository?.baseRef ?? baseRef());
195
+ if (snapshot) {
196
+ // A combined merge has a different worktree/database from its input
197
+ // agents. Migrate its CURRENT integrated source before its checks.
198
+ environment = await setupRepository(root, wt, { snapshot,
199
+ onSpawn: ch => { workChildren.set(ch, agentId); groupKillChildren.add(ch); },
200
+ onClose: ch => { workChildren.delete(ch); groupKillChildren.delete(ch); } });
201
+ releaseEnvironment = holdWorktreeEnvironment(environment);
202
+ }
203
+ } catch (error) {
204
+ const text = envScrub(error.message);
205
+ await postCheck({ agentId, status: 'failed', output: text, ...(headSha ? { headSha } : {}) });
206
+ return { status: 'failed', text };
207
+ }
208
+ try {
187
209
  const cleared = clearNonArtifacts(wt);
188
210
  if (cleared.length)
189
211
  warn(
@@ -241,7 +263,7 @@ export function createWorkAgentCheck({
241
263
  shell: true,
242
264
  detached: true,
243
265
  stdio: ['ignore', 'pipe', 'pipe'],
244
- env: checkEnv(process.env, browser.dir),
266
+ env: checkEnv(environment?.env ?? process.env, browser.dir),
245
267
  });
246
268
  /**
247
269
  * …AND IT IS TRACKED, so a stop or a takeover takes it with them.
@@ -330,6 +352,7 @@ export function createWorkAgentCheck({
330
352
  ...(headSha ? { headSha } : {}),
331
353
  });
332
354
  return out;
355
+ } finally { releaseEnvironment(); }
333
356
  };
334
357
 
335
358
  return { runCheck };
@@ -28,6 +28,7 @@ import { machineMcpServers } from './claudeMachineMcp.mjs';
28
28
  import { ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
29
29
  import { VIDEO_SERVER } from './videoRender.mjs';
30
30
  import { BROWSER_SERVER } from './managedBrowser.mjs';
31
+ import { RUNTIME_REVIEW_SERVER } from './runtimeReview.mjs';
31
32
 
32
33
  /** The servers the daemon itself mounts on a card turn, by the names the
33
34
  * agent calls them: a multi-repository card's add_repository, an
@@ -35,7 +36,7 @@ import { BROWSER_SERVER } from './managedBrowser.mjs';
35
36
  * render_video (2026-10-26), and a code turn's managed browser (2026-10-30).
36
37
  * Never a project server's, whatever the base
37
38
  * `.mcp.json` names. */
38
- export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review', video: VIDEO_SERVER, browser: BROWSER_SERVER });
39
+ export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositories', orchestrator: ORCHESTRATOR_SERVER, uiReview: 'card_ui_review', video: VIDEO_SERVER, browser: BROWSER_SERVER, runtime: RUNTIME_REVIEW_SERVER });
39
40
 
40
41
  /**
41
42
  * The card turn's tools, prepared, and what it is told of them.
@@ -48,13 +49,13 @@ export const CARD_TURN_SERVERS = Object.freeze({ repositories: 'card_repositorie
48
49
  * from here), the note, the mount's part of the CLI's `mcpEnv` and whether it
49
50
  * needs one, and the keys the runner takes the mount as.
50
51
  */
51
- export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, videoConfig = null, browserConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
52
+ export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources = [], repositoryTool = null, orchestratorConfig = null, uiReviewConfig = null, videoConfig = null, browserConfig = null, runtimeConfig = null, orchestrated = false, codexHome = null, env = process.env, machineServers = machineMcpServers }) {
52
53
  // A turn reads the base ref afresh. Changes the agent has made to its
53
54
  // own worktree cannot silently change the next turn's tools.
54
55
  const source = sources.find((r) => r.wt === wt);
55
- const snapshot = readBaseTools(source?.repoRoot ?? repoRoot, source?.baseRef ?? baseRef());
56
+ const snapshot = readBaseTools(source?.repoRoot ?? repoRoot, source?.baseRef ?? baseRef(), env);
56
57
  for (const other of sources.filter((r) => r !== source)) {
57
- const base = readBaseTools(other.repoRoot, other.baseRef);
58
+ const base = readBaseTools(other.repoRoot, other.baseRef, env);
58
59
  snapshot.instructions.push(...[...base.instructions, ...base.skillFiles].map((file) => ({ ...file, path: `repositories/${other.folder}/${file.path}` })));
59
60
  }
60
61
  const projectTools = prepareAgentTools(snapshot, rt, env, wt, { codexHome, fenced: posture !== 'build', turnServers: {
@@ -62,6 +63,7 @@ export function prepareCardTools({ rt, posture, wt, repoRoot, baseRef, sources =
62
63
  ...(orchestratorConfig ? { [CARD_TURN_SERVERS.orchestrator]: orchestratorConfig } : {}),
63
64
  ...(posture === 'build' && uiReviewConfig ? { [CARD_TURN_SERVERS.uiReview]: uiReviewConfig } : {}),
64
65
  ...(posture === 'build' && browserConfig ? { [CARD_TURN_SERVERS.browser]: browserConfig } : {}),
66
+ ...(posture === 'build' && runtimeConfig ? { [CARD_TURN_SERVERS.runtime]: runtimeConfig } : {}),
65
67
  // A video card's render tool rides its fenced posture (videoRender.mjs).
66
68
  ...(videoConfig ? { [CARD_TURN_SERVERS.video]: videoConfig } : {}),
67
69
  } });