flowviant 0.125.29 → 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/environmentProcess.mjs +44 -0
- package/bin/lib/fleet.mjs +2 -0
- package/bin/lib/linuxDesktopBridge.mjs +62 -0
- package/bin/lib/managedBrowser.mjs +50 -10
- 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/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 +46 -2
- package/package.json +1 -1
|
@@ -70,7 +70,7 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
70
70
|
let lastRxAt = 0;
|
|
71
71
|
let captures = 0;
|
|
72
72
|
let observationEpoch = 0, pageEpoch = 0;
|
|
73
|
-
let pageOrigin = null, pageMeasured = null, statusPending = false, statusRetry = null;
|
|
73
|
+
let pageOrigin = null, pageMeasured = null, pageScope = null, statusPending = false, statusRetry = null;
|
|
74
74
|
let handoff = null, handoffResult = null;
|
|
75
75
|
let transportStarted = false;
|
|
76
76
|
const controller = new AbortController();
|
|
@@ -88,7 +88,7 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
88
88
|
};
|
|
89
89
|
const change = patch => { status = { ...status, ...patch }; statusPending = true; flushStatus(); };
|
|
90
90
|
const frame = message => { if (closed || stopped) return; lastFrame = message; flushStatus(); if (!statusPending) send(message); };
|
|
91
|
-
const release = () => { pageEpoch++; observationEpoch++; page?.close(); page = null; pageOrigin = null; pageMeasured = null; lastFrame = null; };
|
|
91
|
+
const release = () => { pageEpoch++; observationEpoch++; page?.close(); page = null; pageOrigin = null; pageMeasured = null; pageScope = null; lastFrame = null; };
|
|
92
92
|
const endHandoff = reason => {
|
|
93
93
|
if (!handoff) return;
|
|
94
94
|
const active = handoff; handoff = null; clearTimeout(active.timer);
|
|
@@ -122,12 +122,15 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
122
122
|
}
|
|
123
123
|
return { content: [text({ page: shot.observation, diagnostics: shot.diagnostics, path: status.path, pendingOrigins: status.pendingOrigins, networkError: status.networkError, ...(artifact ? { artifact } : {}) }), { type: 'image', data: shot.data, mimeType: 'image/jpeg' }] };
|
|
124
124
|
};
|
|
125
|
-
const
|
|
125
|
+
const requireModelControl = () => {
|
|
126
126
|
if (closed || signal?.aborted) throw new Error('This browser turn has ended.');
|
|
127
127
|
if (stopped) throw new Error('A person stopped the browser for this turn.');
|
|
128
128
|
if (status.handoff && name !== 'handoff') throw new Error('The browser is waiting for a person. Continue other work or finish this invocation; the daemon will resume after the handoff.');
|
|
129
129
|
if (status.mode === 'user') throw new Error('A person has control of the shared browser. Continue other work and retry after they return control.');
|
|
130
130
|
if (busy) throw new Error('A browser action is already running.');
|
|
131
|
+
};
|
|
132
|
+
const call = async (name, input, scope = wt) => {
|
|
133
|
+
requireModelControl();
|
|
131
134
|
busy = true;
|
|
132
135
|
let openingNew = false;
|
|
133
136
|
try {
|
|
@@ -141,22 +144,22 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
141
144
|
const path = browserPath(input.path ?? '/');
|
|
142
145
|
if (typeof input.fixture !== 'string' || input.fixture.trim().length < 12 || input.fixture.length > 2000) throw new Error('Describe the disposable test fixtures before opening the browser.');
|
|
143
146
|
if (input.hostname != null && input.hostname !== 'localhost') throw new Error('The optional preview hostname must be localhost.');
|
|
144
|
-
const measured = measuredOrigin(
|
|
147
|
+
const measured = measuredOrigin(scope, input.port, undefined, input.scheme);
|
|
145
148
|
const upstreamUrl = new URL(measured);
|
|
146
149
|
const connectHost = upstreamUrl.hostname === '[::1]' ? '::1' : upstreamUrl.hostname;
|
|
147
150
|
if (input.hostname === 'localhost') upstreamUrl.hostname = 'localhost';
|
|
148
151
|
const upstream = upstreamUrl.origin;
|
|
149
152
|
if (!transportStarted) { transportStarted = true; openSocket(); }
|
|
150
|
-
if (!page || status.state !== 'live' || pageOrigin !== upstream || pageMeasured !== measured) {
|
|
153
|
+
if (!page || status.state !== 'live' || pageOrigin !== upstream || pageMeasured !== measured || pageScope !== scope) {
|
|
151
154
|
openingNew = true;
|
|
152
|
-
release(); change({ state: 'starting', error: undefined, networkError: undefined, pendingOrigins: [], path });
|
|
155
|
+
release(); change({ state: 'starting', surface: 'browser', target: undefined, error: undefined, networkError: undefined, pendingOrigins: [], path });
|
|
153
156
|
const opening = pageEpoch;
|
|
154
157
|
page = await openPage({ origin: upstream, connectHost: input.hostname === 'localhost' ? connectHost : undefined, signal: controller.signal, stillOwned: () => {
|
|
155
|
-
try { return measuredOrigin(
|
|
158
|
+
try { return measuredOrigin(scope, input.port, undefined, input.scheme) === measured; } catch { return false; }
|
|
156
159
|
}, onFrame: message => { if (opening === pageEpoch) frame(message); },
|
|
157
160
|
onChange: patch => { if (opening === pageEpoch && !closed && !stopped) change(patch.error ? { ...patch, state: 'error' } : patch); }, onChild });
|
|
158
161
|
if (opening !== pageEpoch || closed || stopped) { page.close(); page = null; throw new Error('The browser turn has ended.'); }
|
|
159
|
-
pageOrigin = upstream; pageMeasured = measured;
|
|
162
|
+
pageOrigin = upstream; pageMeasured = measured; pageScope = scope;
|
|
160
163
|
}
|
|
161
164
|
await page.act({ type: 'navigate', path });
|
|
162
165
|
change({ state: 'live' });
|
|
@@ -172,6 +175,39 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
172
175
|
throw error;
|
|
173
176
|
} finally { busy = false; }
|
|
174
177
|
};
|
|
178
|
+
/** Runtime adapters share this transport, ownership and private handoff.
|
|
179
|
+
* The factory is daemon-owned; no raw desktop/debugging API crosses the relay. */
|
|
180
|
+
const openSurface = async ({ key, target, surface, open }) => {
|
|
181
|
+
requireModelControl();
|
|
182
|
+
if (!['desktop', 'engine'].includes(surface) || typeof key !== 'string' || key.length > 300 || !/^[A-Za-z0-9_-]{1,60}$/.test(target)) throw new Error('Invalid runtime surface.');
|
|
183
|
+
busy = true;
|
|
184
|
+
try {
|
|
185
|
+
if (!transportStarted) { transportStarted = true; openSocket(); }
|
|
186
|
+
if (!page || status.state !== 'live' || pageOrigin !== key) {
|
|
187
|
+
release(); change({ state: 'starting', surface, target, path: '/', error: undefined, networkError: undefined, pendingOrigins: [] });
|
|
188
|
+
const opening = pageEpoch;
|
|
189
|
+
const opened = await open({ signal: controller.signal, onChild,
|
|
190
|
+
onFrame: message => { if (opening === pageEpoch) frame(message); },
|
|
191
|
+
onChange: patch => {
|
|
192
|
+
if (opening !== pageEpoch || closed || stopped) return;
|
|
193
|
+
if (patch.ended) {
|
|
194
|
+
release(); change({ state: 'ended', mode: 'agent', private: false, handoff: undefined, error: undefined }); endHandoff('returned');
|
|
195
|
+
} else if (patch.error) change({ state: 'error', error: reviewDiagnostic(patch.error) });
|
|
196
|
+
} });
|
|
197
|
+
if (opening !== pageEpoch || closed || stopped) { opened.close(); throw new Error('The interactive turn has ended.'); }
|
|
198
|
+
page = opened; pageOrigin = key;
|
|
199
|
+
if (status.mode === 'user') beginHandoff();
|
|
200
|
+
if (status.private) await page.setPrivate(true);
|
|
201
|
+
}
|
|
202
|
+
change({ state: 'live' });
|
|
203
|
+
return await observe();
|
|
204
|
+
} catch (error) {
|
|
205
|
+
// Taking control while startup is finishing fences this model result;
|
|
206
|
+
// the newly opened app still belongs to the person holding the frame.
|
|
207
|
+
if (status.mode !== 'user') { release(); if (!closed && !stopped) change({ state: 'error', error: reviewDiagnostic(error.message) }); }
|
|
208
|
+
throw error;
|
|
209
|
+
} finally { busy = false; }
|
|
210
|
+
};
|
|
175
211
|
const control = async msg => {
|
|
176
212
|
if (msg.type !== 'control' || closed) return;
|
|
177
213
|
if (msg.action === 'refresh') { if (msg.protocol === 2) status.protocol = 2; change({}); if (lastFrame) send(lastFrame); return; }
|
|
@@ -254,19 +290,23 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
254
290
|
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(result));
|
|
255
291
|
} catch (error) { res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ isError: true, content: [text(reviewDiagnostic(error.message))] })); }
|
|
256
292
|
} });
|
|
257
|
-
|
|
293
|
+
const secretKey = `browser:${held.capability.slice(0, 12)}`;
|
|
294
|
+
learnExtraSecrets(secretKey, [{ name: 'FLOWVIANT_BROWSER_CAPABILITY', value: held.capability }]);
|
|
258
295
|
const close = async () => {
|
|
259
296
|
if (closed) return;
|
|
260
297
|
closed = true; signal?.removeEventListener('abort', abort);
|
|
261
298
|
clearTimeout(reconnect); clearInterval(ping); clearTimeout(statusRetry); statusRetry = null; endHandoff('closed');
|
|
262
299
|
controller.abort(); release(); change({ state: 'ended', mode: 'agent' });
|
|
263
300
|
ws?.close(1000); held.server.close(); held.server.closeAllConnections();
|
|
264
|
-
forgetExtraSecrets(
|
|
301
|
+
forgetExtraSecrets(secretKey);
|
|
265
302
|
};
|
|
266
303
|
const abort = () => { void close(); };
|
|
267
304
|
signal?.addEventListener('abort', abort, { once: true });
|
|
268
305
|
if (signal?.aborted) await close();
|
|
269
306
|
return { env: held.env, config: { command: process.execPath, args: held.args, env: held.env }, close,
|
|
307
|
+
open: (input, scope = wt) => call('open', input, scope), act: input => call('act', input), handoff: input => call('handoff', input), openSurface,
|
|
308
|
+
assertControl: requireModelControl,
|
|
309
|
+
stopSurface() { requireModelControl(); release(); change({ state: 'ended', mode: 'agent', private: false, handoff: undefined }); },
|
|
270
310
|
async waitForHandoff() {
|
|
271
311
|
for (;;) {
|
|
272
312
|
const result = handoff ? await handoff.promise : handoffResult;
|
package/bin/lib/mergeBatch.mjs
CHANGED
|
@@ -65,7 +65,7 @@ export async function integrateMergeBatch({ spec, progress = {}, previousError,
|
|
|
65
65
|
await report(states);
|
|
66
66
|
for (const r of rows) {
|
|
67
67
|
const stamp = resolve(r.wt, git(['rev-parse', '--git-path', 'flowviant-batch-setup'], r.wt));
|
|
68
|
-
if (!existsSync(stamp)) { await setup(r.repoRoot, r.wt); writeFileSync(stamp, 'ready'); }
|
|
68
|
+
if (!existsSync(stamp)) { await setup(r.repoRoot, r.wt, { baseRef: r.baseRef }); writeFileSync(stamp, 'ready'); }
|
|
69
69
|
}
|
|
70
70
|
const worktrees = rows.map((r) => ({ wt: r.wt, branch: r.branch, repoRoot: r.repoRoot }));
|
|
71
71
|
if (spec.prMode && pr.ghReady()) throw new Error('This project merges through pull requests. GitHub CLI is not installed or signed in on this machine.');
|
package/bin/lib/noWindowEnv.mjs
CHANGED
|
@@ -67,8 +67,10 @@ export function withoutWindows(env, browserHome = null) {
|
|
|
67
67
|
/**
|
|
68
68
|
* EVERY CLI CHILD THE DAEMON SPAWNS UNATTENDED, ONE RULE (2026-10-10, the
|
|
69
69
|
* audit): no machine credential (machineEnv.mjs), and no way to a window
|
|
70
|
-
* unless a person at the keyboard asked for one (
|
|
71
|
-
*
|
|
70
|
+
* unless a person at the keyboard asked for one (a Terminal tab), or the
|
|
71
|
+
* runtime-review daemon leaf supplied its own private X display/authority.
|
|
72
|
+
* `display: true` is never used to inherit a personal display on a code-agent
|
|
73
|
+
* CLI. It was composed by hand twice — a turn's `cliEnv`
|
|
72
74
|
* (runTurn.mjs) and the project check's `checkEnv` (workAgentCheck.mjs) — and
|
|
73
75
|
* skipped outright by the two spawns the daemon makes for its own purposes:
|
|
74
76
|
* the startup skills probe (runtimeCapabilities.mjs), whose `claude` started
|
|
@@ -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.
|
|
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).
|
package/bin/lib/projectTools.mjs
CHANGED
|
@@ -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`
|
|
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;
|
|
@@ -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
|
|
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
|
+
}
|