flowviant 0.125.29 → 0.125.31

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.
Files changed (72) hide show
  1. package/README.md +364 -0
  2. package/bin/cli.mjs +23 -0
  3. package/bin/lib/agentPlan.mjs +8 -0
  4. package/bin/lib/agentQueueContext.mjs +6 -0
  5. package/bin/lib/agentSplit.mjs +90 -0
  6. package/bin/lib/architectureContract.mjs +57 -0
  7. package/bin/lib/architectureReport.mjs +38 -0
  8. package/bin/lib/artifactContracts.mjs +21 -2
  9. package/bin/lib/claudeInput.mjs +1 -1
  10. package/bin/lib/claudeSubagents.mjs +46 -6
  11. package/bin/lib/claudeWorkflows.mjs +94 -0
  12. package/bin/lib/cliLimit.mjs +5 -0
  13. package/bin/lib/codexInput.mjs +191 -0
  14. package/bin/lib/codexSubagentEvents.mjs +3 -1
  15. package/bin/lib/codexSubagents.mjs +34 -9
  16. package/bin/lib/conversationOutput.mjs +14 -0
  17. package/bin/lib/crossCliChild.mjs +327 -0
  18. package/bin/lib/crossCliLaunch.mjs +92 -0
  19. package/bin/lib/crossCliPosture.mjs +55 -0
  20. package/bin/lib/crossCliResearch.mjs +19 -0
  21. package/bin/lib/environmentProcess.mjs +44 -0
  22. package/bin/lib/fixtures/workflow-ultracode.jsonl +22 -0
  23. package/bin/lib/fleet.mjs +2 -0
  24. package/bin/lib/linuxDesktopBridge.mjs +62 -0
  25. package/bin/lib/managedBrowser.mjs +50 -10
  26. package/bin/lib/mergeBatch.mjs +1 -1
  27. package/bin/lib/nativeAgentTools.mjs +12 -5
  28. package/bin/lib/noWindowEnv.mjs +4 -2
  29. package/bin/lib/orchestrator.mjs +24 -7
  30. package/bin/lib/orchestratorClient.mjs +8 -2
  31. package/bin/lib/orchestratorPrompt.mjs +13 -5
  32. package/bin/lib/orchestratorProtocol.mjs +18 -0
  33. package/bin/lib/outsideCli.mjs +124 -0
  34. package/bin/lib/projectToolRuntimes.mjs +4 -2
  35. package/bin/lib/projectTools.mjs +2 -2
  36. package/bin/lib/prompts.mjs +7 -5
  37. package/bin/lib/repositoryPlaces.mjs +1 -1
  38. package/bin/lib/repositorySetup.mjs +15 -24
  39. package/bin/lib/reviewBridge.mjs +110 -0
  40. package/bin/lib/reviewDatabase.mjs +112 -0
  41. package/bin/lib/reviewDesktop.mjs +153 -0
  42. package/bin/lib/reviewEnvironment.mjs +286 -0
  43. package/bin/lib/reviewEnvironmentConfig.mjs +104 -0
  44. package/bin/lib/reviewEnvironmentMain.mjs +38 -0
  45. package/bin/lib/reviewImage.mjs +58 -0
  46. package/bin/lib/reviewMcpClient.mjs +69 -0
  47. package/bin/lib/reviewProcess.mjs +51 -0
  48. package/bin/lib/reviewTargets.mjs +50 -0
  49. package/bin/lib/robloxReview.mjs +234 -0
  50. package/bin/lib/robloxStudioBridge.mjs +68 -0
  51. package/bin/lib/runTurn.mjs +30 -8
  52. package/bin/lib/runtimeClaude.mjs +7 -3
  53. package/bin/lib/runtimeCodex.mjs +12 -6
  54. package/bin/lib/runtimeLimits.mjs +59 -1
  55. package/bin/lib/runtimeReview.mjs +185 -0
  56. package/bin/lib/splitGate.mjs +115 -0
  57. package/bin/lib/subagentBounds.mjs +26 -0
  58. package/bin/lib/subagentLedger.mjs +110 -0
  59. package/bin/lib/trace.mjs +134 -6
  60. package/bin/lib/turnDeclaration.mjs +4 -4
  61. package/bin/lib/turnGaps.mjs +6 -2
  62. package/bin/lib/turnSubagents.mjs +23 -9
  63. package/bin/lib/uiReview.mjs +5 -2
  64. package/bin/lib/wikiDraft.mjs +9 -0
  65. package/bin/lib/work.mjs +22 -1
  66. package/bin/lib/workAgentCheck.mjs +24 -1
  67. package/bin/lib/workAgentResume.mjs +11 -2
  68. package/bin/lib/workAgentTools.mjs +6 -4
  69. package/bin/lib/workAgentTurnExecution.mjs +77 -9
  70. package/bin/lib/workAgentTurnOutcome.mjs +11 -0
  71. package/bin/lib/workAgentTurnReports.mjs +5 -2
  72. package/package.json +1 -1
@@ -80,6 +80,7 @@ function codexServerTables(servers) {
80
80
  let out = '';
81
81
  for (const [name, server] of Object.entries(servers ?? {})) {
82
82
  out += `\n[mcp_servers.${name}]\n`;
83
+ if (Number.isInteger(server.toolTimeoutSec) && server.toolTimeoutSec > 0) out += `tool_timeout_sec = ${server.toolTimeoutSec}\n`;
83
84
  const approve = 'default_tools_approval_mode = "approve"\n';
84
85
  if (typeof server.command === 'string') {
85
86
  out += `command = ${toml(server.command)}\nargs = [${(server.args ?? []).map(toml).join(', ')}]\n${approve}`;
@@ -282,7 +283,8 @@ function reviewedClaudePlugin(dir, snapshot) {
282
283
  * turn)` returns the runner-specific part of prepareAgentTools' result — under
283
284
  * `dir`, which dies with the turn, except what `turn.codexHome` names a
284
285
  * lasting place for. `turn.mcpServers` is the base branch's servers this
285
- * runtime takes, expanded; `turn.fenced` a posture that reads no config file
286
+ * runtime takes, expanded; `turn.turnServers` retains daemon-owned metadata
287
+ * omitted from Claude's file; `turn.fenced` a posture that reads no config file
286
288
  * of the runner's own.
287
289
  */
288
290
  const TOOL_RUNTIMES = {
@@ -301,7 +303,7 @@ const TOOL_RUNTIMES = {
301
303
  // dies with the turn, as every home did before. A fenced turn ignores the
302
304
  // home's config.toml, so no server is written into it (2026-10-17).
303
305
  prepare: (dir, _snapshot, env, worktree, turn) => {
304
- const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.fenced ? {} : turn?.mcpServers);
306
+ const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.fenced ? {} : { ...turn?.mcpServers, ...turn?.turnServers });
305
307
  return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions, codexApprovals: turn?.fenced ? [] : isolated.approvals, codexMachineServers: turn?.fenced ? null : isolated.machineServers };
306
308
  },
307
309
  // A fenced posture ignores that home's config.toml (2026-10-10).
@@ -220,7 +220,7 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
220
220
  }
221
221
  }
222
222
  // Daemon-owned names cannot be replaced by project config. A turn server's
223
- // `toolTimeoutSec` is the Codex mount's alone (codexFencedMount.mjs); it is
223
+ // `toolTimeoutSec` belongs to Codex's config.toml or fenced mount; it is
224
224
  // no key of Claude's mcp.json.
225
225
  for (const [name, { toolTimeoutSec: _codexOnly, ...config }] of Object.entries(turnServers)) mcpServers[name] = config;
226
226
  const mcpPath = join(dir, 'mcp.json');
@@ -238,7 +238,7 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
238
238
  ].filter(Boolean).join('\n\n');
239
239
  let prepared;
240
240
  try {
241
- prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome, mcpServers, fenced });
241
+ prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome, mcpServers, turnServers, fenced });
242
242
  } catch (error) {
243
243
  rmSync(dir, { recursive: true, force: true });
244
244
  throw error;
@@ -1,3 +1,4 @@
1
+ import { CONVERSATION_OUTPUT } from './conversationOutput.mjs';
1
2
  import { TESTS_SCOPE, TESTS_REPORT } from './testFindingsContract.mjs';
2
3
  import { randomBytes } from 'node:crypto';
3
4
  import { REGRESSION_INVESTIGATION } from './discoveryContract.mjs';
@@ -28,13 +29,14 @@ import { ARTIFACT_CONTRACT_TAIL, HELP_THEM_DECIDE, LOOK_HEADLESS, SYSTEM_AGENT_D
28
29
  import { isLibraryReference } from './knowledgeLibrary.mjs';
29
30
  import { readCardThread } from './cardThread.mjs';
30
31
  import { AGENT_FILE_LINE_MAX, AGENT_FILES_PER_MESSAGE_MAX, CARD_FILES_PER_TURN_MAX } from './agentFiles.mjs';
32
+ import { SYSTEM_ARCHITECTURE_CONTRACT } from './architectureContract.mjs';
31
33
 
32
34
  // Wiki-gen turn: the local Claude READS the repo (cwd) and writes/maintains the
33
35
  // knowledge VAULT — a plain directory of markdown files with [[wikilinks]]
34
36
  // (Obsidian-style). No MCP tools involved: the vault is just files, and the
35
37
  // daemon hash-diff syncs them to Flowviant after the turn. The repo itself is
36
38
  // strictly read-only.
37
- const WIKI_MAINTENANCE = `
39
+ const WIKI_MAINTENANCE = `${SYSTEM_ARCHITECTURE_CONTRACT}
38
40
  OWNERSHIP AND PROVENANCE — this directory is a disposable draft of GENERATED
39
41
  output, separate from project Knowledge, uploaded/reference vault snapshots and
40
42
  human standing Instructions. Never write to any original source. In this draft,
@@ -2037,12 +2039,12 @@ XHR, sockets, forms and popups are all closed. Never commit these files.`;
2037
2039
  /**
2038
2040
  * The system prompt a turn actually runs under: the contract it picked, plus
2039
2041
  * whatever project context this box holds. `knowledgeDir` null or absent and
2040
- * `artifacts` false leave the contract byte-for-byte what it was, which is what
2041
- * every turn did before either existed — and what an older server still gets.
2042
- * Knowledge first, artifacts after: what to READ, then where to WRITE.
2042
+ * `artifacts` false omit their paragraphs. The shared conversation output guide
2043
+ * always follows the base contract. Knowledge then says what to READ, and the
2044
+ * artifact paragraph says where to WRITE when that surface accepts files.
2043
2045
  */
2044
2046
  export const withProjectContext = (system, { knowledgeDir, artifacts = false } = {}) => {
2045
- let out = system;
2047
+ let out = `${system}\n\n${CONVERSATION_OUTPUT}`;
2046
2048
  if (knowledgeDir) out = `${out}\n\n${KNOWLEDGE_PARAGRAPH(knowledgeDir)}`;
2047
2049
  if (artifacts) out = `${out}\n\n${ARTIFACTS_PARAGRAPH}`;
2048
2050
  return out;
@@ -137,7 +137,7 @@ export function createRepositoryPlaces({ repoRoot, baseDir, repositories, setup
137
137
  const repo = catalog.find((r) => r.id === item.id);
138
138
  if (!repo?.repoRoot) throw new Error(`Repository ${item.name} is not checked out on this machine.`);
139
139
  setupOptions.signal?.throwIfAborted();
140
- await setup(repo.repoRoot, join(home, item.folder), setupOptions);
140
+ await setup(repo.repoRoot, join(home, item.folder), { ...setupOptions, baseRef: repo.baseRef });
141
141
  item.setup = true;
142
142
  save(home, state);
143
143
  }
@@ -2,15 +2,25 @@
2
2
  * A checked-in .flowviant/setup.sh replaces the default dependency install. */
3
3
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync } from 'node:fs';
4
4
  import { join, dirname } from 'node:path';
5
- import { spawn } from 'node:child_process';
6
- import { killGroup } from './processGroup.mjs';
7
5
  import { git } from './git.mjs';
8
- import { unattendedEnv } from './noWindowEnv.mjs';
9
6
  import { workLanesStopped } from './standDownGate.mjs';
7
+ import { environmentCommand } from './environmentProcess.mjs';
8
+ import { readEnvironmentRecipe } from './reviewEnvironmentConfig.mjs';
9
+ import { prepareWorktreeEnvironment } from './reviewEnvironment.mjs';
10
10
 
11
- export async function setupRepository(root, wt, { signal, onSpawn = () => {}, onClose = () => {} } = {}) {
11
+ export async function setupRepository(root, wt, { signal, onSpawn = () => {}, onClose = () => {}, baseRef = 'HEAD', snapshot = readEnvironmentRecipe(root, baseRef) } = {}) {
12
12
  signal?.throwIfAborted();
13
13
  if (workLanesStopped()) throw new Error('The daemon is stopping. Repository setup has not started.');
14
+ const command = existsSync(join(wt, '.flowviant/setup.sh')) ? ['sh', '.flowviant/setup.sh'] :
15
+ ['bun.lock', 'bun.lockb'].some((f) => existsSync(join(wt, f))) ? ['bun', 'install', '--frozen-lockfile'] :
16
+ existsSync(join(wt, 'pnpm-lock.yaml')) ? ['pnpm', 'install', '--frozen-lockfile'] :
17
+ existsSync(join(wt, 'package-lock.json')) ? ['npm', 'ci'] :
18
+ existsSync(join(wt, 'yarn.lock')) ? ['yarn', 'install', '--frozen-lockfile'] : null;
19
+ const options = { signal, onSpawn, onClose };
20
+ // An explicit recipe replaces blind environment copying. Allocate first so
21
+ // even an install/setup hook receives the worktree's scoped database.
22
+ if (snapshot) return prepareWorktreeEnvironment(root, wt, { ...options, snapshot,
23
+ install: command ? env => environmentCommand(command, { ...options, cwd: wt, env, label: 'Repository setup', captureOutput: false, includeDiagnostics: true }) : null });
14
24
  const copyEnv = (relative = '') => {
15
25
  for (const name of readdirSync(join(root, relative))) {
16
26
  if (['.git', 'node_modules', '.flowviant', 'dist', 'target', '.next', '.cache', '.venv'].includes(name)) continue;
@@ -28,27 +38,8 @@ export async function setupRepository(root, wt, { signal, onSpawn = () => {}, on
28
38
  }
29
39
  };
30
40
  copyEnv();
31
- const command = existsSync(join(wt, '.flowviant/setup.sh')) ? ['sh', '.flowviant/setup.sh'] :
32
- ['bun.lock', 'bun.lockb'].some((f) => existsSync(join(wt, f))) ? ['bun', 'install', '--frozen-lockfile'] :
33
- existsSync(join(wt, 'pnpm-lock.yaml')) ? ['pnpm', 'install', '--frozen-lockfile'] :
34
- existsSync(join(wt, 'package-lock.json')) ? ['npm', 'ci'] :
35
- existsSync(join(wt, 'yarn.lock')) ? ['yarn', 'install', '--frozen-lockfile'] : null;
36
41
  if (!command) return;
37
- await new Promise((resolve, reject) => {
38
- const child = spawn(command[0], command.slice(1), { cwd: wt, env: unattendedEnv({ ...process.env, GIT_TERMINAL_PROMPT: '0' }), detached: true, stdio: ['ignore', 'ignore', 'pipe'] });
39
- const abort = () => killGroup(child);
40
- signal?.addEventListener('abort', abort, { once: true });
41
- if (signal?.aborted) abort();
42
- onSpawn(child);
43
- let tail = '';
44
- child.stderr.on('data', (s) => { tail = (tail + s).slice(-2000); });
45
- const timer = setTimeout(() => { killGroup(child); }, 10 * 60_000);
46
- child.once('error', (e) => { clearTimeout(timer); reject(e); });
47
- child.once('close', (code) => {
48
- clearTimeout(timer); signal?.removeEventListener('abort', abort); onClose(child);
49
- code === 0 ? resolve() : reject(new Error(`${command.join(' ')} failed in ${wt}: ${tail}`));
50
- });
51
- });
42
+ await environmentCommand(command, { ...options, cwd: wt, env: process.env, label: 'Repository setup', captureOutput: false, includeDiagnostics: true });
52
43
  }
53
44
 
54
45
  /** Installs are daemon children too: shutdown must drain their process groups. */
@@ -0,0 +1,110 @@
1
+ /** Native/engine helpers speak bounded JSON-RPC over a private stdio pipe. */
2
+ import { startReviewProcess } from './reviewProcess.mjs';
3
+ import { reviewDiagnostic } from './uiReviewBrowser.mjs';
4
+ import { StringDecoder } from 'node:string_decoder';
5
+
6
+ function image(value) {
7
+ if (!value || value.width !== 1440 || value.height !== 900 || typeof value.data !== 'string' || value.data.length > 1_000_000 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value.data)) throw new Error('The runtime bridge returned an invalid bounded frame.');
8
+ const bytes = Buffer.from(value.data, 'base64');
9
+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8) throw new Error('Runtime bridge frames must be JPEG images.');
10
+ return { type: 'frame', data: value.data, width: 1440, height: 900 };
11
+ }
12
+
13
+ export async function openBridgePage({ command, cwd, worktree, configuration = {}, env, signal, timeoutMs = 600_000, onChild, onFrame = () => {}, onChange = () => {}, requestTimeoutMs = 20_000, closeGraceMs = 500 }) {
14
+ signal?.throwIfAborted();
15
+ // Engine helpers may need to end their own play session over MCP before
16
+ // their child can be killed. Cancellation therefore closes the parent pipe.
17
+ const child = startReviewProcess(command, { cwd, env, onChild, timeoutMs: timeoutMs + closeGraceMs, stdio: ['pipe', 'pipe', 'pipe'], captureStdout: false });
18
+ const pending = new Map();
19
+ let serial = 0, epoch = 0, closed = false, ready = false, buffer = '', closeTimer, lifetime;
20
+ const decoder = new StringDecoder('utf8');
21
+ const close = (reason = new Error('The runtime bridge closed.')) => {
22
+ if (closed) return child.done;
23
+ closed = true;
24
+ clearTimeout(lifetime); signal?.removeEventListener('abort', abort);
25
+ for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(reason); }
26
+ pending.clear(); child.child.stdin.end();
27
+ // Let a well-behaved host helper release its session on parent-pipe EOF.
28
+ // A stalled helper still loses its owned group within a bounded grace.
29
+ closeTimer = setTimeout(() => { void child.close(); }, closeGraceMs); closeTimer.unref?.();
30
+ if (!child.running()) clearTimeout(closeTimer);
31
+ return child.done;
32
+ };
33
+ const exited = result => new Error(result.error ?? `The runtime bridge exited (${result.code}).`);
34
+ const abort = () => { void close(); };
35
+ const fail = error => { if (!closed) { onChange({ error: reviewDiagnostic(error.message) }); close(error); } };
36
+ lifetime = setTimeout(() => fail(new Error('The runtime bridge reached its time limit.')), timeoutMs); lifetime.unref?.();
37
+ signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort();
38
+ child.done.then(result => { clearTimeout(closeTimer); if (!closed) fail(exited(result)); });
39
+ child.child.stdout.on('data', bytes => {
40
+ if (closed) return;
41
+ buffer += decoder.write(bytes);
42
+ if (Buffer.byteLength(buffer) > 1_400_000) { fail(new Error('The runtime bridge exceeded its message bound.')); return; }
43
+ let newline;
44
+ while (!closed && (newline = buffer.indexOf('\n')) >= 0) {
45
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
46
+ try {
47
+ const message = JSON.parse(line);
48
+ if (message.jsonrpc !== '2.0') throw new Error('The runtime bridge protocol is invalid.');
49
+ if (message.id != null) {
50
+ const entry = pending.get(message.id); if (!entry) continue;
51
+ pending.delete(message.id); clearTimeout(entry.timer);
52
+ if (message.error) entry.reject(new Error(reviewDiagnostic(message.error.message ?? 'Runtime bridge operation failed.')));
53
+ else entry.resolve(message.result);
54
+ } else if (ready && message.params?.epoch === epoch) {
55
+ if (message.method === 'frame') onFrame(image(message.params));
56
+ else if (message.method === 'ended') onChange({ ended: true });
57
+ else if (message.method === 'failure') fail(new Error(reviewDiagnostic(message.params.message)));
58
+ }
59
+ } catch (error) { fail(error); }
60
+ }
61
+ });
62
+ const request = (method, params) => {
63
+ if (closed) return Promise.reject(new Error('The runtime bridge is closed.'));
64
+ const id = ++serial;
65
+ return new Promise((resolve, reject) => {
66
+ const timer = setTimeout(() => { pending.delete(id); const error = new Error('The runtime bridge did not answer in time.'); reject(error); fail(error); }, requestTimeoutMs);
67
+ pending.set(id, { resolve, reject, timer });
68
+ child.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n', error => {
69
+ if (!error || closed) return;
70
+ // A fast launcher refusal (e.g. a held flock) can close stdin before
71
+ // Node observes its exit. Preserve that measured refusal over EPIPE.
72
+ void (async () => {
73
+ let timer;
74
+ try {
75
+ const result = await Promise.race([child.done, new Promise(resolve => { timer = setTimeout(() => resolve(null), 250); })]);
76
+ fail(result ? exited(result) : error);
77
+ } finally { clearTimeout(timer); }
78
+ })();
79
+ });
80
+ });
81
+ };
82
+ try {
83
+ const hello = await request('initialize', { protocol: 1, worktree, width: 1440, height: 900, configuration });
84
+ if (hello?.protocol !== 1 || !['owned-session', 'consented-window'].includes(hello.isolation) || hello.privateHandoff !== true) throw new Error('The bridge must bind an owned session or consented window and support private handoff fences.');
85
+ await request('open', { epoch }); ready = true;
86
+ return { kind: 'engine', pid: child.child.pid, close,
87
+ async setPrivate(privateInput) {
88
+ ready = false; const next = ++epoch;
89
+ const ack = await request('privacy', { private: privateInput, epoch: next });
90
+ if (ack?.epoch !== next || ack.drained !== true) { close(); throw new Error('The bridge did not acknowledge its private capture fence.'); }
91
+ ready = true;
92
+ },
93
+ async act(input) {
94
+ if (input.type === 'snapshot') return;
95
+ if (input.selector || ['navigate', 'retry'].includes(input.type)) throw new Error('Use this runtime’s coordinate input or engine tools; browser navigation is unavailable.');
96
+ await request('act', { input, epoch });
97
+ },
98
+ async snapshot() {
99
+ const current = epoch, shot = await request('snapshot', { epoch: current });
100
+ // A deliberate handoff invalidates this model observation, not the
101
+ // native session the person just took control of.
102
+ if (current !== epoch || !ready) throw new Error('Runtime control changed during capture.');
103
+ try {
104
+ if (shot?.epoch !== current) throw new Error('The runtime bridge returned a stale snapshot epoch.');
105
+ return { data: image(shot).data, observation: reviewDiagnostic(shot.observation ?? 'Native runtime observation.'), diagnostics: [] };
106
+ } catch (error) { fail(error); throw error; }
107
+ },
108
+ };
109
+ } catch (error) { close(); throw error; }
110
+ }
@@ -0,0 +1,112 @@
1
+ /** Database allocation is deterministic from a durable ownership receipt. */
2
+ import { randomBytes } from 'node:crypto';
3
+ import { environmentCommand } from './environmentProcess.mjs';
4
+ import { reviewedProviderCommand } from './reviewEnvironmentConfig.mjs';
5
+
6
+ const quote = value => `'${String(value).replaceAll("'", "''")}'`;
7
+ const credentialNames = recipe => recipe.database.provider === 'postgres' ? [recipe.database.adminUrlEnv] : recipe.database.credentials ?? [];
8
+ export { credentialNames as environmentCredentialNames };
9
+
10
+ export function newDatabaseReceipt(id, provider) {
11
+ return provider === 'postgres' ? { provider, name: `fv_${id}`, role: `fv_${id}`, password: randomBytes(32).toString('hex') } : { provider, owner: id };
12
+ }
13
+
14
+ function postgresEnv(value) {
15
+ let url;
16
+ try { url = new URL(value); } catch { throw new Error('The review PostgreSQL admin URL is invalid.'); }
17
+ if (!['postgres:', 'postgresql:'].includes(url.protocol) || !url.hostname || !url.username || !url.pathname.slice(1) || url.hash) throw new Error('The review PostgreSQL admin URL must name a host, user and database.');
18
+ const env = { PGHOST: url.hostname.replace(/^\[|\]$/g, ''), PGPORT: url.port || '5432', PGUSER: decodeURIComponent(url.username), PGPASSWORD: decodeURIComponent(url.password), PGDATABASE: decodeURIComponent(url.pathname.slice(1)), PGCONNECT_TIMEOUT: '15' };
19
+ const options = { sslmode: 'PGSSLMODE', sslrootcert: 'PGSSLROOTCERT', sslcert: 'PGSSLCERT', sslkey: 'PGSSLKEY', channel_binding: 'PGCHANNELBINDING', connect_timeout: 'PGCONNECT_TIMEOUT', application_name: 'PGAPPNAME' };
20
+ for (const [key, v] of url.searchParams) {
21
+ if (!Object.hasOwn(options, key)) throw new Error(`Unsupported review PostgreSQL connection option: ${key}.`);
22
+ env[options[key]] = v;
23
+ }
24
+ return env;
25
+ }
26
+
27
+ const providerEnv = local => Object.fromEntries(['PATH', 'TMPDIR', 'LANG', 'LC_ALL', 'LD_LIBRARY_PATH', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'PGSYSCONFDIR'].filter(key => local[key] != null).map(key => [key, local[key]]));
28
+ const pgCommand = async (sql, local, snapshot, options) => {
29
+ const admin = local[snapshot.recipe.database.adminUrlEnv];
30
+ if (!admin) throw new Error(`Set ${snapshot.recipe.database.adminUrlEnv} on this machine to provision review databases.`);
31
+ return environmentCommand(['psql', '-X', '-q', '-A', '-t', '-w', '-v', 'ON_ERROR_STOP=1'], { ...options, cwd: snapshot.root, timeoutMs: 30_000, label: 'Review PostgreSQL operation', env: { ...providerEnv(local), ...postgresEnv(admin) }, input: sql });
32
+ };
33
+
34
+ async function postgresState(record, local, snapshot, options) {
35
+ const db = record.database;
36
+ const answer = await pgCommand(`SELECT json_build_object(
37
+ 'role', (SELECT json_build_object('marker', shobj_description(oid, 'pg_authid'), 'superuser', rolsuper, 'createdb', rolcreatedb, 'createrole', rolcreaterole, 'replication', rolreplication, 'bypassrls', rolbypassrls, 'login', rolcanlogin, 'memberships', EXISTS(SELECT 1 FROM pg_auth_members WHERE member = pg_roles.oid)) FROM pg_roles WHERE rolname = ${quote(db.role)}),
38
+ 'database', (SELECT json_build_object('marker', shobj_description(oid, 'pg_database'), 'owner', pg_get_userbyid(datdba)) FROM pg_database WHERE datname = ${quote(db.name)}));`, local, snapshot, options);
39
+ try { return JSON.parse(answer); } catch { throw new Error('PostgreSQL did not return the review database ownership measurement.'); }
40
+ }
41
+
42
+ function ownedPostgres(state, record) {
43
+ const marker = `flowviant:${record.id}`;
44
+ const role = state.role;
45
+ if (role && (role.marker !== marker || role.superuser || role.createdb || role.createrole || role.replication || role.bypassrls || role.memberships || !role.login)) throw new Error('The review database role has unexpected ownership or privileges. It was left alone.');
46
+ if (state.database && (!role || state.database.owner !== record.database.role || (state.database.marker != null && state.database.marker !== marker))) throw new Error('The review database has unexpected ownership. It was left alone.');
47
+ }
48
+
49
+ async function allocatePostgres(record, local, snapshot, options) {
50
+ const db = record.database, marker = `flowviant:${record.id}`;
51
+ let state = await postgresState(record, local, snapshot, options);
52
+ ownedPostgres(state, record);
53
+ if (!state.role) {
54
+ await pgCommand(`BEGIN;
55
+ CREATE ROLE ${db.role} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS PASSWORD ${quote(db.password)};
56
+ COMMENT ON ROLE ${db.role} IS ${quote(marker)};
57
+ COMMIT;`, local, snapshot, options);
58
+ }
59
+ if (!state.database) await pgCommand(`CREATE DATABASE ${db.name} WITH OWNER ${db.role} TEMPLATE template0;`, local, snapshot, options);
60
+ // Re-read after creation, also recovering a crash between CREATE DATABASE
61
+ // and COMMENT. Only a database owned by our marked, restricted role qualifies.
62
+ state = await postgresState(record, local, snapshot, options);
63
+ ownedPostgres(state, record);
64
+ if (!state.role || !state.database) throw new Error('The review database could not be measured after creation.');
65
+ await pgCommand(`COMMENT ON DATABASE ${db.name} IS ${quote(marker)};
66
+ REVOKE CONNECT ON DATABASE ${db.name} FROM PUBLIC;
67
+ GRANT CONNECT ON DATABASE ${db.name} TO ${db.role};`, local, snapshot, options);
68
+ const url = new URL(local[snapshot.recipe.database.adminUrlEnv]);
69
+ url.username = db.role; url.password = db.password; url.pathname = `/${db.name}`;
70
+ return { ...db, connectionString: url.toString() };
71
+ }
72
+
73
+ async function commandOperation(operation, record, local, snapshot, options) {
74
+ const credentials = {};
75
+ for (const name of credentialNames(snapshot.recipe)) {
76
+ if (!local[name]) throw new Error(`Set ${name} on this machine for the review database adapter.`);
77
+ credentials[name] = local[name];
78
+ }
79
+ const output = await environmentCommand([...reviewedProviderCommand(snapshot), operation], {
80
+ ...options, cwd: snapshot.root, timeoutMs: 120_000, label: `Review database ${operation}`,
81
+ env: { ...providerEnv(local), ...credentials,
82
+ FLOWVIANT_ENVIRONMENT_ID: record.id, FLOWVIANT_ENVIRONMENT_SOURCE: snapshot.recipe.database.source,
83
+ FLOWVIANT_ENVIRONMENT_RESOURCE_ID: record.database.resourceId ?? '',
84
+ FLOWVIANT_ENVIRONMENT_EXPIRES_AT: new Date(record.expiresAt).toISOString() },
85
+ input: JSON.stringify({ id: record.id, resourceId: record.database.resourceId ?? null, source: snapshot.recipe.database.source }),
86
+ });
87
+ let answer;
88
+ try { answer = JSON.parse(output); } catch { throw new Error(`The review database adapter must return JSON for ${operation}.`); }
89
+ if (answer?.owner !== record.id) throw new Error('The database adapter returned a different environment owner.');
90
+ return answer;
91
+ }
92
+
93
+ export async function allocateReviewDatabase(record, local, snapshot, options) {
94
+ if (record.database.provider === 'postgres') return allocatePostgres(record, local, snapshot, options);
95
+ const answer = await commandOperation('allocate', record, local, snapshot, options);
96
+ if (typeof answer.resourceId !== 'string' || !answer.resourceId || answer.resourceId.length > 200 || typeof answer.connectionString !== 'string' || !answer.connectionString || answer.connectionString.length > 16_384 || /[\0\r\n]/.test(answer.connectionString)) throw new Error('The database adapter must return a resourceId and connectionString.');
97
+ if (answer.bindings != null && (typeof answer.bindings !== 'object' || Array.isArray(answer.bindings) || Object.entries(answer.bindings).some(([key, value]) => !snapshot.recipe.database.bindings.includes(key) || typeof value !== 'string' || !value || value.length > 16_384 || /[\0\r\n]/.test(value)))) throw new Error('Database adapter bindings must name declared database variables with bounded connection strings.');
98
+ return { provider: 'command', owner: record.id, resourceId: answer.resourceId, connectionString: answer.connectionString, ...(answer.bindings ? { bindings: answer.bindings } : {}) };
99
+ }
100
+
101
+ export async function releaseReviewDatabase(record, local, snapshot, options) {
102
+ if (record.database.provider === 'command') {
103
+ // An interrupted allocation has no resource id. The adapter must reconcile
104
+ // by the original id, including create-success/response-lost crashes.
105
+ await commandOperation('release', record, local, snapshot, options);
106
+ return;
107
+ }
108
+ const state = await postgresState(record, local, snapshot, options);
109
+ ownedPostgres(state, record);
110
+ if (state.database) await pgCommand(`DROP DATABASE ${record.database.name} WITH (FORCE);`, local, snapshot, options);
111
+ if (state.role) await pgCommand(`DROP ROLE ${record.database.role};`, local, snapshot, options);
112
+ }
@@ -0,0 +1,153 @@
1
+ /** Linux native apps on an owned X server. Never attach to DISPLAY/WSLg. */
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir, platform } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { commandOnPath } from './reviewTargets.mjs';
7
+ import { startReviewProcess } from './reviewProcess.mjs';
8
+ import { learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs';
9
+
10
+ export function desktopSupport(env = process.env) {
11
+ if (platform() !== 'linux') return { available: false, reason: 'The built-in isolated desktop needs Linux. Configure a native host bridge on this OS.' };
12
+ const tools = Object.fromEntries(['Xvfb', 'xauth', 'openbox', 'xdotool', 'import'].map(name => [name, commandOnPath(name, env)]));
13
+ const missing = Object.keys(tools).filter(name => !tools[name]);
14
+ return missing.length ? { available: false, reason: `Isolated desktop tools missing: ${missing.join(', ')}.` } : { available: true, tools };
15
+ }
16
+
17
+ export async function openDesktopPage({ command, cwd, env, timeoutMs = 600_000, signal, onChild, onFrame = () => {}, onChange = () => {}, support = desktopSupport(env), frameIntervalMs = 750, detachedChildren = true }) {
18
+ if (!support.available) throw new Error(support.reason);
19
+ signal?.throwIfAborted();
20
+ const scratch = mkdtempSync(join(tmpdir(), 'flowviant-desktop-'));
21
+ const authority = join(scratch, 'Xauthority');
22
+ const cookie = randomBytes(16).toString('hex'), secretKey = `desktop:${cookie}`;
23
+ learnExtraSecrets(secretKey, [{ name: 'XAUTHORITY_COOKIE', value: cookie }]);
24
+ writeFileSync(authority, '', { mode: 0o600 });
25
+ const runtime = join(scratch, 'run'); mkdirSync(runtime, { mode: 0o700 });
26
+ const tools = support.tools;
27
+ const privateEnv = { ...env, HOME: scratch, USERPROFILE: scratch, XDG_CONFIG_HOME: scratch, XDG_DATA_HOME: scratch,
28
+ XDG_CACHE_HOME: scratch, XDG_RUNTIME_DIR: runtime, XAUTHORITY: authority,
29
+ WAYLAND_DISPLAY: '', WAYLAND_SOCKET: undefined, DBUS_SESSION_BUS_ADDRESS: 'disabled:', BROWSER: 'none',
30
+ XDG_SESSION_TYPE: 'x11', GDK_BACKEND: 'x11', QT_QPA_PLATFORM: 'xcb', SDL_VIDEODRIVER: 'x11' };
31
+ let xserver, manager, app, display, timer, failure, closed = false, seenImage = false, privateUsed = false, epoch = 0, queue = Promise.resolve();
32
+ const controller = new AbortController();
33
+ const abort = () => close();
34
+ const processes = new Set();
35
+ const options = { cwd, env: privateEnv, signal: controller.signal, onChild, detached: detachedChildren };
36
+ const exec = async (argv, extra = {}) => {
37
+ const process = startReviewProcess(argv, { ...options, timeoutMs: 5000, ...extra });
38
+ processes.add(process);
39
+ try {
40
+ const result = await process.done;
41
+ if (result.error || result.code !== 0) throw failure ?? new Error(result.error ?? `The isolated desktop operation failed (${result.code}). ${privateUsed ? '' : result.output.slice(-800)}`);
42
+ return result;
43
+ } finally { processes.delete(process); }
44
+ };
45
+ function close() {
46
+ if (closed) return;
47
+ closed = true; epoch++; clearTimeout(timer); signal?.removeEventListener('abort', abort); controller.abort();
48
+ const children = [app, manager, xserver, ...processes].filter(Boolean);
49
+ void Promise.all(children.map(child => child.close())).finally(() => {
50
+ // A process may still have Xauthority or its runtime files open until exit.
51
+ try { rmSync(scratch, { recursive: true, force: true }); } catch { /* a failed local unlink must not crash the daemon */ }
52
+ forgetExtraSecrets(secretKey);
53
+ });
54
+ }
55
+ const fail = error => { if (!closed) { failure = error; onChange({ error: error.message }); close(); } };
56
+ const captured = () => {
57
+ const run = queue.catch(() => {}).then(async () => {
58
+ if (closed) throw new Error('The isolated desktop has ended.');
59
+ const { data } = await exec([tools.import, '-display', display, '-window', 'root', '-quality', '65', 'jpeg:-'], { display: true, binary: true, maxBytes: 750_000 });
60
+ if (data[0] !== 0xff || data[1] !== 0xd8) throw new Error('The desktop capture did not return a JPEG.');
61
+ return data.toString('base64');
62
+ });
63
+ queue = run; return run;
64
+ };
65
+ const stream = async () => {
66
+ const current = epoch;
67
+ try { const data = await captured(); if (!closed && current === epoch) onFrame({ type: 'frame', data, width: 1440, height: 900 }); }
68
+ catch (error) { if (!closed && current === epoch) fail(error); }
69
+ finally { if (!closed && current === epoch) timer = setTimeout(stream, frameIntervalMs); }
70
+ };
71
+ signal?.addEventListener('abort', abort, { once: true });
72
+ try {
73
+ // The X server reads this cookie independent of the display number. Once
74
+ // -displayfd allocates a free display atomically, add the client's entry.
75
+ await exec([tools.xauth, '-f', authority, 'source', '-'], { input: `add :0 MIT-MAGIC-COOKIE-1 ${cookie}\n` });
76
+ xserver = startReviewProcess([tools.Xvfb, '-displayfd', '3', '-screen', '0', '1440x900x24', '-nolisten', 'tcp', '-auth', authority], {
77
+ ...options, timeoutMs, stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
78
+ });
79
+ display = await new Promise((resolve, reject) => {
80
+ let text = '';
81
+ const deadline = setTimeout(() => reject(new Error('The private X server did not start.')), 5000);
82
+ const finish = (error, value) => { clearTimeout(deadline); error ? reject(error) : resolve(value); };
83
+ xserver.child.stdio[3].on('data', bytes => {
84
+ text += bytes.toString();
85
+ if (text.length > 20) finish(new Error('The X server returned an invalid display.'));
86
+ else if (/^\d+\n$/.test(text)) finish(null, `:${Number(text.trim())}`);
87
+ });
88
+ xserver.done.then(result => finish(new Error(result.error ?? `The private X server exited. ${result.output.slice(-1500)}`)));
89
+ });
90
+ privateEnv.DISPLAY = display;
91
+ await exec([tools.xauth, '-f', authority, 'source', '-'], { input: `add ${display} MIT-MAGIC-COOKIE-1 ${cookie}\n` });
92
+ if (closed || signal?.aborted) throw new Error('The desktop turn has ended.');
93
+ // Start only the window manager, never a desktop session's autostart apps.
94
+ // It provides normal click-to-focus and window behavior on the private X server.
95
+ manager = startReviewProcess([tools.openbox, '--sm-disable'], { ...options, env: privateEnv, display: true, timeoutMs });
96
+ manager.done.then(result => { if (!closed) fail(new Error(result.error ?? `The private window manager exited (${result.code}). ${!seenImage && !privateUsed ? result.output.slice(-1000) : ''}`)); });
97
+ app = startReviewProcess(command, { ...options, env: privateEnv, display: true, timeoutMs });
98
+ app.done.then(result => {
99
+ if (closed) return;
100
+ if (result.code === 0 && !result.error && seenImage) { onChange({ ended: true }); close(); }
101
+ else fail(new Error(result.error ?? `The desktop app exited (${result.code}). ${!seenImage && !privateUsed ? result.output.slice(-1000) : ''}`));
102
+ });
103
+ xserver.done.then(result => { if (!closed) fail(new Error(result.error ?? 'The private X server exited.')); });
104
+ // A live X server alone is not an application preview. Wait for a mapped
105
+ // client window in this owned display and give it ordinary keyboard focus.
106
+ let window, startupError;
107
+ const startupUntil = Date.now() + 10_000;
108
+ while (Date.now() < startupUntil && !closed) {
109
+ try {
110
+ const result = await exec([tools.xdotool, 'search', '--onlyvisible', '--class', '.'], { display: true });
111
+ window = result.output.split(/\s+/).find(value => /^\d+$/.test(value));
112
+ if (window) {
113
+ await exec([tools.xdotool, 'windowactivate', '--sync', window], { display: true, timeoutMs: 1000 });
114
+ // _NET_ACTIVE_WINDOW can arrive before the window manager sets X
115
+ // input focus. Do not accept the first key into that startup gap.
116
+ await exec([tools.xdotool, 'windowfocus', '--sync', window], { display: true, timeoutMs: 1000 });
117
+ break;
118
+ }
119
+ } catch (error) { startupError = error; window = null; if (closed) break; }
120
+ await new Promise(resolve => setTimeout(resolve, 100));
121
+ }
122
+ if (!window || closed) throw failure ?? new Error(`No visible application window opened in the private desktop.${startupError ? ` ${startupError.message}` : ''}`);
123
+ timer = setTimeout(stream, frameIntervalMs);
124
+ return { kind: 'desktop', display, pid: app.child.pid, displayPid: xserver.child.pid, managerPid: manager.child.pid, close,
125
+ async setPrivate(privateInput) {
126
+ if (privateInput) privateUsed = true;
127
+ epoch++; clearTimeout(timer);
128
+ // Discard captures already queued before the handoff. A fresh capture
129
+ // begins after the drain and cannot carry a prior private frame.
130
+ await queue.catch(() => {});
131
+ if (!closed) timer = setTimeout(stream, frameIntervalMs);
132
+ },
133
+ async snapshot() {
134
+ const current = epoch;
135
+ const data = await captured();
136
+ if (current !== epoch || closed) throw new Error('Desktop control changed during capture.');
137
+ seenImage = true;
138
+ return { data, observation: 'Isolated native application. Use screen coordinates; DOM selectors are unavailable. A screenshot is an observation, not a passing gameplay or integration test.', diagnostics: [] };
139
+ },
140
+ async act(input) {
141
+ if (closed) throw new Error('The isolated desktop has ended.');
142
+ if (input.type === 'snapshot') return;
143
+ if (input.selector || ['navigate', 'retry'].includes(input.type)) throw new Error('This native desktop supports coordinate input; browser navigation and DOM selectors do not apply.');
144
+ if (input.type === 'click') await exec([tools.xdotool, 'mousemove', String(input.x), String(input.y), 'click', '1'], { display: true });
145
+ else if (input.type === 'type') await exec([tools.xdotool, 'type', '--clearmodifiers', '--delay', '0', '--file', '-'], { display: true, input: input.text });
146
+ else if (input.type === 'key') await exec([tools.xdotool, 'key', '--clearmodifiers', ({ Enter: 'Return', Backspace: 'BackSpace', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right' })[input.key] ?? input.key], { display: true });
147
+ else if (input.type === 'scroll' && input.deltaY !== 0) await exec([tools.xdotool, 'click', '--repeat', String(Math.max(1, Math.min(10, Math.ceil(Math.abs(input.deltaY) / 100)))), '--delay', '20', input.deltaY < 0 ? '4' : '5'], { display: true });
148
+ else if (input.type === 'scroll') return;
149
+ else throw new Error('Unsupported native input.');
150
+ },
151
+ };
152
+ } catch (error) { close(); throw error; }
153
+ }