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.
- package/README.md +364 -0
- package/bin/cli.mjs +23 -0
- package/bin/lib/browserHandoff.mjs +25 -0
- package/bin/lib/desktopContract.mjs +8 -6
- package/bin/lib/environmentProcess.mjs +44 -0
- package/bin/lib/fleet.mjs +2 -0
- package/bin/lib/linuxDesktopBridge.mjs +62 -0
- package/bin/lib/managedBrowser.mjs +132 -30
- package/bin/lib/managedBrowserPage.mjs +12 -7
- package/bin/lib/mergeBatch.mjs +1 -1
- package/bin/lib/noWindowEnv.mjs +4 -2
- package/bin/lib/projectToolRuntimes.mjs +4 -2
- package/bin/lib/projectTools.mjs +2 -2
- package/bin/lib/repositoryPlaces.mjs +1 -1
- package/bin/lib/repositorySetup.mjs +15 -24
- package/bin/lib/reviewBridge.mjs +110 -0
- package/bin/lib/reviewDatabase.mjs +112 -0
- package/bin/lib/reviewDesktop.mjs +153 -0
- package/bin/lib/reviewEnvironment.mjs +286 -0
- package/bin/lib/reviewEnvironmentConfig.mjs +104 -0
- package/bin/lib/reviewEnvironmentMain.mjs +38 -0
- package/bin/lib/reviewImage.mjs +58 -0
- package/bin/lib/reviewMcpClient.mjs +69 -0
- package/bin/lib/reviewProcess.mjs +51 -0
- package/bin/lib/reviewTargets.mjs +50 -0
- package/bin/lib/robloxReview.mjs +234 -0
- package/bin/lib/robloxStudioBridge.mjs +68 -0
- package/bin/lib/runtimeAuth.mjs +56 -16
- package/bin/lib/runtimeReview.mjs +185 -0
- package/bin/lib/uiReview.mjs +5 -2
- package/bin/lib/work.mjs +22 -1
- package/bin/lib/workAgentCheck.mjs +24 -1
- package/bin/lib/workAgentTools.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +75 -11
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/bin/lib/runtimeAuth.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
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
|
-
|
|
35
|
-
|
|
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'
|
|
65
|
+
if (result.signedIn === true && result.billing === 'subscription') {
|
|
50
66
|
const context = contextFor();
|
|
51
|
-
if (context.source === 'file'
|
|
52
|
-
result
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
}
|
package/bin/lib/uiReview.mjs
CHANGED
|
@@ -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
|
|
176
|
-
|
|
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
|
} });
|