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
package/README.md
CHANGED
|
@@ -200,6 +200,370 @@ On Claude Code and Codex build turns, `card_ui_review.inspect_screenshot` can su
|
|
|
200
200
|
|
|
201
201
|
The agent can fix in-scope findings and rerun up to three attempts per turn, each capped at two minutes including startup. Every attempt stops its owned browser and preview, including on cancellation; teardown also tracks their process groups. Reports and screenshots use `.flowviant/artifacts/`, and the existing delivery Verification retains routes, viewports, actions/outcomes, scrubbed diagnostics and source identity. Tracked/nonignored file bytes and HEAD are remeasured at delivery; later changes mark evidence stale. Ignored build/runtime files are outside that source measurement. The project check, fresh read-only pre-review, human approval and merge remain separate.
|
|
202
202
|
|
|
203
|
+
## Isolated review environments
|
|
204
|
+
|
|
205
|
+
Code agents can provision disposable databases natively. This is opt-in: commit
|
|
206
|
+
`.flowviant/environment.json` on the reviewed base branch. Projects without a
|
|
207
|
+
recipe keep their existing setup. This layer works independently of the model
|
|
208
|
+
and whether the project has a browser preview.
|
|
209
|
+
|
|
210
|
+
For PostgreSQL, use a **dedicated development/review server** with `psql` on the
|
|
211
|
+
daemon's PATH. The administrative role needs permission to create/drop databases,
|
|
212
|
+
create restricted login roles and assign database ownership. Flowviant creates
|
|
213
|
+
an empty database from `template0` and a distinct role for each worktree. It
|
|
214
|
+
does not clone the contents of a shared development or production database.
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"version": 1,
|
|
219
|
+
"database": {
|
|
220
|
+
"provider": "postgres",
|
|
221
|
+
"adminUrlEnv": "REVIEW_DATABASE_ADMIN_URL",
|
|
222
|
+
"bindings": ["DATABASE_URL", "DIRECT_DATABASE_URL"]
|
|
223
|
+
},
|
|
224
|
+
"migrate": ["npm", "run", "db:migrate"],
|
|
225
|
+
"seed": ["npm", "run", "db:seed"],
|
|
226
|
+
"envFiles": [".env.local"],
|
|
227
|
+
"env": {
|
|
228
|
+
"NODE_ENV": "development",
|
|
229
|
+
"CACHE_PREFIX": "review_${ENVIRONMENT_ID}"
|
|
230
|
+
},
|
|
231
|
+
"connectors": ["database"],
|
|
232
|
+
"ttlHours": 24
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Use this project's actual migration/fixture scripts and connector names. Commands
|
|
237
|
+
are argv arrays, run without a shell. The migration runner and fixture seeder
|
|
238
|
+
must tolerate retries after partial failure. Each listed project MCP connector
|
|
239
|
+
must reference one of the database bindings, for example `${DATABASE_URL}` in
|
|
240
|
+
its environment configuration. Flowviant expands it from the same scoped values
|
|
241
|
+
the app receives. Unlisted/personal connectors are not rewritten or isolated.
|
|
242
|
+
|
|
243
|
+
Machine-local values belong in the gitignored
|
|
244
|
+
`.flowviant/environment.local.json`, with mode `0600`, or the admin variable can
|
|
245
|
+
be supplied by the daemon's environment. For example:
|
|
246
|
+
|
|
247
|
+
```json
|
|
248
|
+
{
|
|
249
|
+
"env": {
|
|
250
|
+
"REVIEW_DATABASE_ADMIN_URL": "postgresql://review_admin:LOCAL_PASSWORD@127.0.0.1:5432/postgres",
|
|
251
|
+
"PAYMENTS_API_KEY": "LOCAL_SANDBOX_KEY"
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
This local file stays in the checkout. Its selected runtime values, excluding
|
|
257
|
+
provider administrator credentials, join the isolated database bindings. Generated
|
|
258
|
+
`.env`/`.dev.vars` files and local receipts are owner-only and excluded from Git.
|
|
259
|
+
Existing/tracked/user-edited env files and symlink destinations are preserved;
|
|
260
|
+
choose another path if one conflicts. Use `envFiles: []` for projects that do not
|
|
261
|
+
load dotenv files; the provided command wrapper injects values directly, including
|
|
262
|
+
complex multiline credentials. A recipe disables automatic copying of the
|
|
263
|
+
checkout's ignored `.env*`, including production profiles.
|
|
264
|
+
|
|
265
|
+
The first configured build allocates the database, runs the existing setup hook
|
|
266
|
+
or lockfile install once, migrates and seeds. Later cards reuse that worktree's
|
|
267
|
+
database, rerun migrations and retain successfully seeded data. The agent gets
|
|
268
|
+
an exact private helper command for `run -- <argv>` and `refresh`. The wrapper
|
|
269
|
+
selects the repository's own bindings even when a multi-repository place has
|
|
270
|
+
several environments. `refresh --seed` explicitly reruns fixtures. The local UI
|
|
271
|
+
self-review and final/combined-merge checks use scoped values too; combined
|
|
272
|
+
worktrees have their own databases and migrate their integrated source before
|
|
273
|
+
validation. These setup receipts do not count as passing application tests.
|
|
274
|
+
|
|
275
|
+
The daemon keeps local allocation intent before contacting a provider, so retries
|
|
276
|
+
reuse one environment id. Active turns/checks hold a heartbeat lease. Its sweep
|
|
277
|
+
releases inactive environments after `ttlHours` (1–168, default 24) or worktree
|
|
278
|
+
retirement; at most two receipts per repository per sweep. Shutdown retains them
|
|
279
|
+
for restart. Cleanup checks database/role ownership before deleting anything;
|
|
280
|
+
failures retain the receipt for retry. A reviewed recipe change rebuilds disposable
|
|
281
|
+
data after the previous environment is released. Moving a worktree retains its id.
|
|
282
|
+
The daemon must be running for this sweep; a remote provider adapter should also
|
|
283
|
+
set/renew its own expiration as protection while the machine is offline.
|
|
284
|
+
|
|
285
|
+
To use a database provider's branches, replace the `database` object with:
|
|
286
|
+
|
|
287
|
+
```json
|
|
288
|
+
{
|
|
289
|
+
"provider": "command",
|
|
290
|
+
"command": ["node", ".flowviant/database.mjs"],
|
|
291
|
+
"credentials": ["DATABASE_PROVIDER_API_KEY"],
|
|
292
|
+
"source": "SANITIZED_DEVELOPMENT_BRANCH_ID",
|
|
293
|
+
"bindings": ["DATABASE_URL", "DIRECT_DATABASE_URL"]
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
This is the native provider-adapter lifecycle, not an AI-generated provisioner
|
|
298
|
+
on every card. The reviewed adapter receives `allocate` or `release` as its last
|
|
299
|
+
argument, with the environment id, original test-data source, known resource id
|
|
300
|
+
and expiration in `FLOWVIANT_ENVIRONMENT_ID`, `FLOWVIANT_ENVIRONMENT_SOURCE`,
|
|
301
|
+
`FLOWVIANT_ENVIRONMENT_RESOURCE_ID` and `FLOWVIANT_ENVIRONMENT_EXPIRES_AT`.
|
|
302
|
+
Stdin also carries JSON `{id, resourceId, source}`. Only the named provider
|
|
303
|
+
credentials and minimal toolchain environment are passed. Allocate must look up
|
|
304
|
+
the original id before creating, renew its expiration and verify ownership;
|
|
305
|
+
a create-success/lost-response retry must never create another branch. It returns
|
|
306
|
+
JSON `{owner, resourceId, connectionString}`. Release must verify ownership and
|
|
307
|
+
reconcile by id even if allocation never returned a resource id, then return
|
|
308
|
+
`{owner}`. Both operations must be idempotent. Output is private and never included
|
|
309
|
+
in error messages. Repository adapter files named with `./` or `.flowviant/`
|
|
310
|
+
must still match the saved reviewed blobs, including during cleanup; keep that
|
|
311
|
+
adapter available until its resources are released. No commercial branch SDK
|
|
312
|
+
or provider credentials are installed automatically.
|
|
313
|
+
|
|
314
|
+
An allocate response may also return `bindings`, keyed only by declared database
|
|
315
|
+
variable names, when the provider needs distinct pooled and direct URLs for the
|
|
316
|
+
same branch. Other bindings use `connectionString`. The adapter must verify that
|
|
317
|
+
all of these connections target its owned environment.
|
|
318
|
+
|
|
319
|
+
Database isolation does not configure auth tenants, Redis, queues, object storage,
|
|
320
|
+
email or payment sandboxes. Select their development credentials explicitly and
|
|
321
|
+
namespace them with the environment id where supported. Apps and connector
|
|
322
|
+
processes still run as the user's UID: this is data separation, not a security
|
|
323
|
+
sandbox against repository code reading other local files. An inherited role's
|
|
324
|
+
access to unrelated databases is the PostgreSQL server's policy; use a dedicated
|
|
325
|
+
review server. See PostgreSQL's [database creation documentation](https://www.postgresql.org/docs/current/sql-createdatabase.html).
|
|
326
|
+
|
|
327
|
+
## Runtime review adapters
|
|
328
|
+
|
|
329
|
+
Commit `.flowviant/review.json` on the project's reviewed base branch to expose
|
|
330
|
+
repeatable review workflows to code agents. One `card_runtime` interface supports
|
|
331
|
+
command checks, browser apps, Linux desktops, Roblox Studio and native/engine bridges. Projects
|
|
332
|
+
without targets mount no additional tools or processes. The agent's existing
|
|
333
|
+
shell and browser workflows remain available.
|
|
334
|
+
|
|
335
|
+
```json
|
|
336
|
+
{
|
|
337
|
+
"version": 1,
|
|
338
|
+
"targets": {
|
|
339
|
+
"checks": {
|
|
340
|
+
"adapter": "command",
|
|
341
|
+
"command": ["npm", "test"],
|
|
342
|
+
"timeoutSeconds": 120
|
|
343
|
+
},
|
|
344
|
+
"web": {
|
|
345
|
+
"adapter": "browser",
|
|
346
|
+
"command": ["npm", "run", "dev", "--", "--port", "${PORT}"],
|
|
347
|
+
"port": 0,
|
|
348
|
+
"fixture": "Disposable seeded review database and test account"
|
|
349
|
+
},
|
|
350
|
+
"desktop": {
|
|
351
|
+
"adapter": "desktop",
|
|
352
|
+
"platforms": ["linux"],
|
|
353
|
+
"command": ["npm", "run", "desktop:dev"],
|
|
354
|
+
"fixture": "Disposable local workspace and sandbox service bindings"
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Use the repository's real commands. A Roblox repository can declare command
|
|
361
|
+
targets for its configured Luau analyzer, standalone logic tests or Rojo build;
|
|
362
|
+
these results do not claim a Studio playtest. The adapters do not install tools
|
|
363
|
+
or infer gameplay from a sync server's listening port. `cwd` defaults to the
|
|
364
|
+
repository root and must resolve inside that worktree. Commands are argv arrays,
|
|
365
|
+
without shell expansion. Timeouts are 1–600 seconds. Browser `port: 0` allocates
|
|
366
|
+
a loopback port and substitutes only `${PORT}`; a fixed port is also supported.
|
|
367
|
+
The listener is attributed to the target worktree before Chrome opens it.
|
|
368
|
+
The runtime MCP server gives Codex a longer tool-call timeout in its private
|
|
369
|
+
build config so the CLI's default does not truncate a configured command.
|
|
370
|
+
|
|
371
|
+
`discover` reports targets and actual prerequisites without launching apps.
|
|
372
|
+
`run` executes a named target; command results retain exit status, bounded logs
|
|
373
|
+
and source identity. A large log stays in the evidence artifact while only its
|
|
374
|
+
tail enters the model context. Browser/desktop/engine targets use the existing
|
|
375
|
+
Live app frame, private sign-in and take/return/stop control. `observe` retains
|
|
376
|
+
the actual screenshot, `act` supplies bounded input, and `handoff` holds the turn
|
|
377
|
+
without model polling. Screenshots and interactions are observations, not passing
|
|
378
|
+
tests. Source changes invalidate earlier command evidence. The shell helper
|
|
379
|
+
supplied to runners without MCP omits image base64 from text output; the model
|
|
380
|
+
opens the retained screenshot with its own image tool if available.
|
|
381
|
+
|
|
382
|
+
Only an interactive target launch creates a browser/display or stream. One
|
|
383
|
+
interactive surface occupies the frame per held turn. Native screenshots run at
|
|
384
|
+
a bounded low cadence, with fresh captures after input; the stream invokes no
|
|
385
|
+
model per frame. Adapter-owned app/process groups stop on explicit runtime Stop,
|
|
386
|
+
target replacement, timeout, abort and turn teardown. The browser frame's Stop
|
|
387
|
+
closes its browser; its supervised server also closes when the runtime tool or
|
|
388
|
+
turn closes. A person's Stop prevents reopening an interactive surface in that
|
|
389
|
+
turn. Commands can run alongside an existing surface without replacing its
|
|
390
|
+
owned server. Artifacts use the existing delivery/storage path.
|
|
391
|
+
|
|
392
|
+
Command checks inherit the normal unattended build environment without the
|
|
393
|
+
machine credential. Interactive apps/helpers start from the minimal toolchain
|
|
394
|
+
environment plus scoped review-environment bindings. A target's optional `env`
|
|
395
|
+
array selects additional variables by name, e.g. `["SANDBOX_API_KEY"]`; values
|
|
396
|
+
stay local and scoped database bindings take precedence. Provider administrator
|
|
397
|
+
credentials remain excluded. Personal/unlisted connectors and other external
|
|
398
|
+
services keep their explicit configuration.
|
|
399
|
+
|
|
400
|
+
### Linux virtual desktops
|
|
401
|
+
|
|
402
|
+
The first native backend requires Xvfb, xauth, Openbox, xdotool and ImageMagick's
|
|
403
|
+
`import` executable. On Debian/Ubuntu these are supplied by the `xvfb`, `xauth`,
|
|
404
|
+
`openbox`, `xdotool` and `imagemagick` packages. Install them in the daemon's VM
|
|
405
|
+
image; missing tools are reported on the target. The runner never installs them
|
|
406
|
+
automatically or falls back to the user's display. Chrome headless itself does
|
|
407
|
+
not need these desktop packages or a visible desktop session.
|
|
408
|
+
|
|
409
|
+
The backend atomically allocates a private X display, disables its TCP listener
|
|
410
|
+
and creates an owner-only Xauthority cookie. It starts a window manager without
|
|
411
|
+
session autostart, then the declared app, waits for a visible client window and
|
|
412
|
+
focuses it. The app has a fresh home/config/runtime directory and private X11
|
|
413
|
+
display; inherited Wayland/WSLg and personal D-Bus sessions are not used. If an
|
|
414
|
+
app needs D-Bus, use a repository-supported `dbus-run-session -- ...` command.
|
|
415
|
+
Capture/input helpers receive only that display's authority. Handoff drains
|
|
416
|
+
capture work before resuming public frames. Natural app exit ends the surface;
|
|
417
|
+
crashes/timeouts report their actual failure. Teardown kills owned groups and
|
|
418
|
+
removes the temporary home and authority files.
|
|
419
|
+
|
|
420
|
+
The Linux backend runs behind the same stdio bridge used by other host helpers.
|
|
421
|
+
It owns a separate process group and watches its parent pipe: a daemon killed
|
|
422
|
+
without graceful shutdown closes that pipe and the helper reaps its native
|
|
423
|
+
descendants. Normal bridge close first allows cleanup on EOF, with a bounded
|
|
424
|
+
force-stop grace for a stalled helper.
|
|
425
|
+
|
|
426
|
+
V1 supports left clicks, text, the shared bounded navigation keys and vertical
|
|
427
|
+
scrolling at 1440×900. It does not yet provide drag, key holds, sound or native
|
|
428
|
+
accessibility inspection. Apps needing Wayland-only APIs or GPU acceleration
|
|
429
|
+
need an appropriate host/backend; an Xvfb image is not proof those capabilities
|
|
430
|
+
exist. The session isolates display/cookies and avoids accidental personal
|
|
431
|
+
desktop access, but still runs as the operator's UID. Use a dedicated VM/container
|
|
432
|
+
for execution containment and scoped service credentials for data isolation.
|
|
433
|
+
|
|
434
|
+
### Native and engine bridges
|
|
435
|
+
|
|
436
|
+
A target with `adapter: "bridge"` uses the same UI and control ownership. Its
|
|
437
|
+
`command` starts a reviewed host helper over private newline-delimited JSON-RPC
|
|
438
|
+
2.0 stdio; `platforms` can restrict where it runs. `configuration` is a bounded
|
|
439
|
+
object passed to the helper alongside the actual `worktree`. Repository helper
|
|
440
|
+
paths spelled `./...` or `.flowviant/...` must match their reviewed base blobs;
|
|
441
|
+
the helper launches from the checkout, with the target worktree in its scope.
|
|
442
|
+
|
|
443
|
+
The protocol methods are `initialize`, `open`, `snapshot`, `act` and `privacy`.
|
|
444
|
+
Initialize receives `{protocol:1, worktree, width:1440, height:900, configuration}`
|
|
445
|
+
and returns `{protocol:1, isolation:"owned-session", privateHandoff:true}` or
|
|
446
|
+
`isolation:"consented-window"`. It must bind that owned session or an explicitly
|
|
447
|
+
consented window. Open receives `{epoch:0}`. Snapshot receives the current epoch
|
|
448
|
+
and returns `{epoch,width:1440,height:900,data:"JPEG_BASE64",observation:"..."}`.
|
|
449
|
+
Act receives `{epoch,input}` with the shared bounded input verbs. Frame
|
|
450
|
+
notifications use method `frame` and params `{epoch,width,height,data}`. Frames
|
|
451
|
+
are bounded to one million base64 characters; no debugging socket is exposed.
|
|
452
|
+
An `ended` notification with the current epoch reports normal application exit;
|
|
453
|
+
`failure` with `{epoch,message}` reports an actual backend error. Helpers must
|
|
454
|
+
release their owned children when stdin closes and when terminated.
|
|
455
|
+
Privacy receives `{private,epoch}` and must drain old captures before replying
|
|
456
|
+
`{epoch,drained:true}`. The broker discards every old-epoch frame. Wrong ownership,
|
|
457
|
+
missing private fences, malformed messages/images and timeouts close the helper.
|
|
458
|
+
|
|
459
|
+
This contract is the extension point for Windows/macOS desktop capture and specialized engines;
|
|
460
|
+
general Windows/macOS desktop capture helpers are not included in this release. Windows capture would
|
|
461
|
+
use an explicitly selected window through Windows Graphics Capture; macOS would
|
|
462
|
+
use ScreenCaptureKit and native input/accessibility permissions. Linux Wayland
|
|
463
|
+
hosts would use their consented portal/PipeWire path. Roblox Studio requires a
|
|
464
|
+
Windows or Mac host; Linux can still edit and check its filesystem sources.
|
|
465
|
+
Engine-specific play/start/assert tools can use project MCP or command targets;
|
|
466
|
+
declaring a bridge alone does not implement those engine semantics. The built-in
|
|
467
|
+
Roblox adapter below uses Studio's own MCP rather than OS desktop capture.
|
|
468
|
+
|
|
469
|
+
### Roblox Studio through Windows + WSL
|
|
470
|
+
|
|
471
|
+
Use `adapter: "roblox"` for a Windows Studio fixture reached through a reviewed
|
|
472
|
+
stdio launcher in WSL. Studio must already be installed, signed in, and have its
|
|
473
|
+
built-in MCP server enabled. The daemon needs native Linux headless Chromium and
|
|
474
|
+
`flock`; it creates no personal browser window or Windows daemon. No model service
|
|
475
|
+
is added. [Roblox's MCP guide](https://create.roblox.com/docs/studio/mcp) describes
|
|
476
|
+
the built-in server and its available tools.
|
|
477
|
+
|
|
478
|
+
```json
|
|
479
|
+
{
|
|
480
|
+
"version": 1,
|
|
481
|
+
"targets": {
|
|
482
|
+
"studio": {
|
|
483
|
+
"adapter": "roblox",
|
|
484
|
+
"command": ["./scripts/roblox-studio-mcp"],
|
|
485
|
+
"env": ["ROBLOX_STUDIO_MCP", "WSL_INTEROP", "WSL_DISTRO_NAME"],
|
|
486
|
+
"fixture": "Disposable local place with test services and worktree scripts",
|
|
487
|
+
"configuration": {
|
|
488
|
+
"studioName": "${WORKTREE}.rbxl",
|
|
489
|
+
"startPlay": true,
|
|
490
|
+
"scripts": [
|
|
491
|
+
{
|
|
492
|
+
"file": "src/server/Main.server.luau",
|
|
493
|
+
"path": ["ServerScriptService", "Main"]
|
|
494
|
+
}
|
|
495
|
+
]
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
Commit the executable launcher on the reviewed base. For an existing Windows
|
|
503
|
+
StudioMCP executable, a small launcher can use the daemon's selected
|
|
504
|
+
`ROBLOX_STUDIO_MCP` environment variable (a WSL `/mnt/c/.../StudioMCP.exe` path):
|
|
505
|
+
|
|
506
|
+
```bash
|
|
507
|
+
#!/usr/bin/env bash
|
|
508
|
+
set -euo pipefail
|
|
509
|
+
exec "${ROBLOX_STUDIO_MCP:?Set the installed StudioMCP.exe path on this machine}"
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Reuse a working launcher if already configured. Keep stdin/stdout attached to the
|
|
513
|
+
MCP process, without shell banners or `start`; do not launch Studio itself from
|
|
514
|
+
this wrapper. The adapter validates the reviewed launcher bytes before running
|
|
515
|
+
it from the checkout. Machine-specific paths stay in the selected environment.
|
|
516
|
+
|
|
517
|
+
Build/sync a disposable place from the task worktree using the project's actual
|
|
518
|
+
Rojo or Studio workflow, then open it in Windows Studio. `studioName` must equal
|
|
519
|
+
the **exact name returned by `list_roblox_studios`**; `${WORKTREE}` expands to the
|
|
520
|
+
task worktree's folder name. No first-instance or place-id fallback is used.
|
|
521
|
+
In that fixture set the `FlowviantReviewWorktree` attribute on ReplicatedStorage
|
|
522
|
+
to the same folder name, for example in Studio's Command Bar:
|
|
523
|
+
|
|
524
|
+
```lua
|
|
525
|
+
game:GetService("ReplicatedStorage"):SetAttribute("FlowviantReviewWorktree", "a-TASK-WORKTREE-ID")
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
The source mappings use worktree-relative Lua/Luau filenames and explicit Studio
|
|
529
|
+
instance path segments. At most forty scripts, each at most 128 KiB, are compared
|
|
530
|
+
before input or play starts; CRLF/LF differences are normalized. Missing,
|
|
531
|
+
ambiguous, escaping or stale mappings and a wrong worktree marker refuse that
|
|
532
|
+
review. Unmapped scripts, assets and other fixtures remain unverified. A marker
|
|
533
|
+
is an explicit fixture binding, not a sandbox or proof of automatic Rojo sync.
|
|
534
|
+
|
|
535
|
+
`run` requires an unambiguous Edit state before starting play and waits for a
|
|
536
|
+
Client player/camera. A pre-existing play session is left alone; `startPlay:false`
|
|
537
|
+
attaches to an already running fixture and never stops that session. A local OS
|
|
538
|
+
lock prevents simultaneous turns using the same fixture name in that daemon
|
|
539
|
+
environment. This does not coordinate other WSL distros or independent MCP clients.
|
|
540
|
+
Owned play receives a temporary `FlowviantReviewSession` Client attribute; the
|
|
541
|
+
adapter rechecks it before input and cleanup, leaving a replacement play session
|
|
542
|
+
alone. This marker is runtime state, not a saved/published place edit.
|
|
543
|
+
|
|
544
|
+
Viewport captures, click/text/navigation-key input, Take control and Return use
|
|
545
|
+
the existing Live app frame. PNG/JPEG images are normalized in a network-disabled
|
|
546
|
+
headless canvas, letterboxed to 1440×900, with click coordinates mapped to the
|
|
547
|
+
measured player viewport. Mismatched aspect ratios are refused. The two-second
|
|
548
|
+
capture cadence makes no model calls. Screenshots and input are observations;
|
|
549
|
+
they do not assert gameplay passed. The first version uses the shared bounded
|
|
550
|
+
input vocabulary, without WASD/key holds, mouse drag or full Studio editor control.
|
|
551
|
+
|
|
552
|
+
Use privately fences old captures; console history is omitted after any private
|
|
553
|
+
control. Studio's Windows login/MFA dialogs are outside the viewport and must be
|
|
554
|
+
completed on the Windows host. Stop review leaves the Studio editor open. The
|
|
555
|
+
helper watches parent EOF and on normal Stop, abort or daemon death attempts to
|
|
556
|
+
stop only play it started, with bounded cleanup time before its processes are
|
|
557
|
+
forced down. A disconnected/unresponsive MCP server can prevent that final stop;
|
|
558
|
+
the editor and any remaining play session stay on the Windows host.
|
|
559
|
+
|
|
560
|
+
Studio must already have disposable/namespaced DataStores and sandbox service
|
|
561
|
+
bindings. Flowviant's database environment does not rewrite Roblox DataStore,
|
|
562
|
+
Open Cloud, HTTP or game-universe configuration. This adapter does not install
|
|
563
|
+
Studio, publish a place, upload assets, invoke Roblox's AI subagents, import
|
|
564
|
+
personal auth or silently select a live game. Without Studio MCP, Lua changes
|
|
565
|
+
still produce the usual code diff and configured command-check evidence.
|
|
566
|
+
|
|
203
567
|
## Modes
|
|
204
568
|
|
|
205
569
|
| Env | What runs |
|
package/bin/cli.mjs
CHANGED
|
@@ -80,6 +80,12 @@
|
|
|
80
80
|
// answered here before anything else loads: no config, no update check, no
|
|
81
81
|
// output but the hook's. An unknown hook exits 2, which Claude Code reads as a
|
|
82
82
|
// refusal — the read guard's own rule for a hook that cannot judge.
|
|
83
|
+
if (process.argv[2] === '__environment') {
|
|
84
|
+
await (await import('./lib/reviewEnvironmentMain.mjs')).reviewEnvironmentMain();
|
|
85
|
+
await new Promise(resolve => process.stdout.write('', resolve));
|
|
86
|
+
process.exit(process.exitCode ?? 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
83
89
|
if (process.argv[2] === '__capture-mcp') {
|
|
84
90
|
await (await import('./lib/captureAuthority.mjs')).captureMcpMain();
|
|
85
91
|
process.exit(process.exitCode ?? 0);
|
|
@@ -98,6 +104,23 @@ if (process.argv[2] === '__managed-browser-mcp') {
|
|
|
98
104
|
process.exit(0);
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
if (['__runtime-review', '__runtime-review-mcp'].includes(process.argv[2])) {
|
|
108
|
+
await (await import('./lib/runtimeReview.mjs')).runtimeReviewMain(process.argv[2] === '__runtime-review-mcp');
|
|
109
|
+
await new Promise(resolve => process.stdout.write('', resolve));
|
|
110
|
+
process.exit(process.exitCode ?? 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (process.argv[2] === '__linux-desktop-bridge') {
|
|
114
|
+
await (await import('./lib/linuxDesktopBridge.mjs')).linuxDesktopBridgeMain();
|
|
115
|
+
process.exit(process.exitCode ?? 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (process.argv[2] === '__roblox-studio-bridge') {
|
|
119
|
+
await (await import('./lib/robloxStudioBridge.mjs')).robloxStudioBridgeMain();
|
|
120
|
+
await new Promise(resolve => process.stdout.write('', resolve));
|
|
121
|
+
process.exit(process.exitCode ?? 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
101
124
|
if (process.argv[2] === '__video-render-mcp') {
|
|
102
125
|
await (await import('./lib/videoRender.mjs')).videoRenderMain();
|
|
103
126
|
process.exit(process.exitCode ?? 0);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A human browser handoff holds the existing turn, worktree and browser.
|
|
2
|
+
* No model process polls while the person signs in. A returned handoff resumes
|
|
3
|
+
* the same CLI conversation before final delivery/verification can settle. */
|
|
4
|
+
export const BROWSER_HANDOFF_CONTINUATIONS = 2;
|
|
5
|
+
|
|
6
|
+
export function browserHandoffPrompt({ reason }) {
|
|
7
|
+
return reason === 'returned'
|
|
8
|
+
? 'A person returned control of the SAME managed browser session. Inspect its current page and verify whether sign-in or the requested interaction succeeded, then continue the existing card and its checks. Reuse the running dev server and browser; do not restart them. Report only actual observations and retained evidence.'
|
|
9
|
+
: `The managed browser handoff ${reason === 'expired' ? 'expired' : 'was stopped by a person'}. This browser is no longer interactive. Respect that stop; report the unfinished browser checks as unverified, complete any other available verification, and finish the existing card. Do not claim sign-in or interactive checks succeeded.`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function completeBrowserReview(initial, { browser, signal, finish, resume }) {
|
|
13
|
+
let out = initial, finished = false, continuations = 0;
|
|
14
|
+
for (;;) {
|
|
15
|
+
if (signal?.aborted) return out;
|
|
16
|
+
const handoff = await browser?.waitForHandoff?.();
|
|
17
|
+
if (signal?.aborted || handoff?.reason === 'closed') return out;
|
|
18
|
+
if (handoff) {
|
|
19
|
+
if (continuations >= BROWSER_HANDOFF_CONTINUATIONS) throw new Error('The browser handoff continuation limit was reached. Interactive verification is incomplete.');
|
|
20
|
+
out = await resume(handoff, ++continuations);
|
|
21
|
+
finished = false;
|
|
22
|
+
} else if (finished) return out;
|
|
23
|
+
else { out = await finish(out); finished = true; }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -12,7 +12,7 @@ import { installChannel } from './launchCommand.mjs';
|
|
|
12
12
|
import { boxesUrlFrom, classifyMachineResponse, fetchBoxesFor } from './machines.mjs';
|
|
13
13
|
import { daemonLockPid, daemonLogPath, readDaemonState } from './daemonState.mjs';
|
|
14
14
|
import { detectRuntimes } from './runtimeDetection.mjs';
|
|
15
|
-
import { readClaudeAuthStatus } from './runtimeAuth.mjs';
|
|
15
|
+
import { readClaudeAuthStatus, readCodexAuthStatus } from './runtimeAuth.mjs';
|
|
16
16
|
import { fleetEndpoint } from './fleetWire.mjs';
|
|
17
17
|
|
|
18
18
|
/**
|
|
@@ -42,11 +42,13 @@ export function busyOf(liveState, now = Date.now()) {
|
|
|
42
42
|
// One home for the channel (launchCommand.mjs); re-exported for views.mjs.
|
|
43
43
|
export { installChannel };
|
|
44
44
|
|
|
45
|
-
export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor = readDaemonState, pidFor = daemonLockPid, drainFor = drainingFor, nowMs = Date.now(), authFor = readClaudeAuthStatus, refreshAuth = false } = {}) {
|
|
45
|
+
export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor = readDaemonState, pidFor = daemonLockPid, drainFor = drainingFor, nowMs = Date.now(), authFor = readClaudeAuthStatus, codexAuthFor = readCodexAuthStatus, refreshAuth = false } = {}) {
|
|
46
46
|
const runtimeRows = runtimes.map((rt) => ({
|
|
47
47
|
id: rt.id, installed: rt.installed, version: rt.version ?? null, dispatchable: rt.dispatchable,
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
authSource: 'local',
|
|
49
|
+
signedIn: null, billing: null, subscriptionType: null, checkedAt: null, expiresAt: null, expiryKind: null,
|
|
50
|
+
...(rt.installed && rt.id === 'claude' ? authFor({ refresh: refreshAuth }) :
|
|
51
|
+
rt.installed && rt.id === 'codex' ? codexAuthFor({ refresh: refreshAuth }) : {}),
|
|
50
52
|
}));
|
|
51
53
|
return {
|
|
52
54
|
schema: 1,
|
|
@@ -148,10 +150,10 @@ const refusalWords = (r) =>
|
|
|
148
150
|
|
|
149
151
|
export async function desktopStatusRemote({ entries = listStoredProjects(), runtimes = detectRuntimes(),
|
|
150
152
|
runningFor = daemonRunningFor, stateFor = readDaemonState, pidFor = daemonLockPid,
|
|
151
|
-
authFor = readClaudeAuthStatus, refreshAuth = false,
|
|
153
|
+
authFor = readClaudeAuthStatus, codexAuthFor = readCodexAuthStatus, refreshAuth = false,
|
|
152
154
|
fetchImpl = fetch, envpub = readStoredPubB64(), fleetUrl = FLEET_URL, host = MACHINE_HOST,
|
|
153
155
|
now = () => new Date().toISOString(), timeoutMs = REMOTE_TIMEOUT_MS, log = console.error } = {}) {
|
|
154
|
-
const status = desktopStatus({ entries, runtimes, runningFor, stateFor, pidFor, authFor, refreshAuth });
|
|
156
|
+
const status = desktopStatus({ entries, runtimes, runningFor, stateFor, pidFor, authFor, codexAuthFor, refreshAuth });
|
|
155
157
|
const boxesUrl = boxesUrlFrom(fleetUrl);
|
|
156
158
|
const liveUrl = fleetEndpoint('live-agents', fleetUrl);
|
|
157
159
|
const remotes = await Promise.all(entries.map(async (entry) => {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Bounded, tracked setup children. Secrets travel in env/stdin, never argv. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { killGroup } from './processGroup.mjs';
|
|
4
|
+
import { unattendedEnv } from './noWindowEnv.mjs';
|
|
5
|
+
import { workLanesStopped } from './standDownGate.mjs';
|
|
6
|
+
import { scrub } from './uplinkScrub.mjs';
|
|
7
|
+
|
|
8
|
+
export function environmentCommand(argv, { cwd, env, input = '', signal, onSpawn = () => {}, onClose = () => {}, timeoutMs = 600_000, label = 'Environment command', captureOutput = true, includeDiagnostics = false } = {}) {
|
|
9
|
+
signal?.throwIfAborted();
|
|
10
|
+
if (workLanesStopped()) throw new Error('The daemon is stopping. Environment setup has not started.');
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
let child, timer, settled = false, output = '', diagnostics = '';
|
|
13
|
+
const diagnostic = chunk => { diagnostics = scrub(diagnostics + chunk.toString()).slice(-2000); };
|
|
14
|
+
const failed = message => new Error(message + (includeDiagnostics && diagnostics ? '\n' + diagnostics : ''));
|
|
15
|
+
const abort = () => { killGroup(child); finish(signal?.reason ?? new Error(`${label} was interrupted.`)); };
|
|
16
|
+
const finish = (error) => {
|
|
17
|
+
if (settled) return;
|
|
18
|
+
settled = true; clearTimeout(timer);
|
|
19
|
+
signal?.removeEventListener('abort', abort);
|
|
20
|
+
if (child) onClose(child);
|
|
21
|
+
error ? reject(error) : resolve(output);
|
|
22
|
+
};
|
|
23
|
+
try {
|
|
24
|
+
child = spawn(argv[0], argv.slice(1), { cwd, env: unattendedEnv({ ...env, GIT_TERMINAL_PROMPT: '0' }), detached: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
25
|
+
child.once('error', () => finish(new Error(`${label} could not start (${argv[0]}). Check that it is installed.`)));
|
|
26
|
+
child.once('close', code => finish(code === 0 ? null : failed(`${label} failed (exit ${code ?? 'signal'}).`)));
|
|
27
|
+
// Provider operations leave diagnostics off: output can contain newly
|
|
28
|
+
// minted credentials even on failure. Runtime hooks retain scrubbed tails.
|
|
29
|
+
if (includeDiagnostics) child.stderr.on('data', diagnostic);
|
|
30
|
+
else child.stderr.resume();
|
|
31
|
+
child.stdout.on('data', chunk => {
|
|
32
|
+
if (!captureOutput) { if (includeDiagnostics) diagnostic(chunk); return; }
|
|
33
|
+
output += chunk;
|
|
34
|
+
if (Buffer.byteLength(output) > 256 * 1024) { killGroup(child); finish(new Error(`${label} returned too much output.`)); }
|
|
35
|
+
});
|
|
36
|
+
child.stdin.on('error', () => {});
|
|
37
|
+
child.stdin.end(input);
|
|
38
|
+
onSpawn(child);
|
|
39
|
+
timer = setTimeout(() => { killGroup(child); finish(failed(`${label} timed out.`)); }, timeoutMs);
|
|
40
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
41
|
+
if (signal?.aborted) abort();
|
|
42
|
+
} catch (error) { if (child) killGroup(child); finish(error); }
|
|
43
|
+
});
|
|
44
|
+
}
|
package/bin/lib/fleet.mjs
CHANGED
|
@@ -276,6 +276,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
276
276
|
retirePreviews,
|
|
277
277
|
shutdownPreviews,
|
|
278
278
|
retireWorkSessions,
|
|
279
|
+
reconcileEnvironments,
|
|
279
280
|
reportWorktrees,
|
|
280
281
|
reconcileIdleArtifacts,
|
|
281
282
|
reconcileLocalCleanup,
|
|
@@ -597,6 +598,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
|
|
|
597
598
|
// winner is working in — absence would mean "somebody else won" instead of
|
|
598
599
|
// "the tab closed".
|
|
599
600
|
retireWorkSessions(roster.activeWorkSessions, roster.sessionsHeldElsewhere);
|
|
601
|
+
reconcileEnvironments();
|
|
600
602
|
// Diffs somebody has open and is waiting on. Project-scoped rather than
|
|
601
603
|
// per-session: `git show` runs from the repo ROOT, which can see a closed
|
|
602
604
|
// tab's branch and a shipped commit on main alike.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Linux host helper: parent EOF owns the lifetime of its whole process group. */
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { openBridgePage } from './reviewBridge.mjs';
|
|
4
|
+
import { openDesktopPage } from './reviewDesktop.mjs';
|
|
5
|
+
import { heldToolArgv } from './heldTool.mjs';
|
|
6
|
+
import { killGroup } from './processGroup.mjs';
|
|
7
|
+
import { procStat } from './procStat.mjs';
|
|
8
|
+
import { reviewDiagnostic } from './uiReviewBrowser.mjs';
|
|
9
|
+
|
|
10
|
+
export function openLinuxDesktop({ command, cwd, env, timeoutMs, signal, onChild, onFrame, onChange }) {
|
|
11
|
+
return openBridgePage({ command: [process.execPath, ...heldToolArgv('__linux-desktop-bridge')], cwd, env, worktree: cwd,
|
|
12
|
+
configuration: { command, cwd, timeoutMs }, timeoutMs, signal, onChild, onFrame, onChange });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function linuxDesktopBridgeMain() {
|
|
16
|
+
// Never group-kill a caller's terminal if somebody manually invokes this
|
|
17
|
+
// private verb. The parent starts the helper detached as its group leader.
|
|
18
|
+
if (process.platform !== 'linux' || procStat(process.pid)?.pgrp !== process.pid) throw new Error('The desktop helper needs its own Linux process group.');
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
let page, configuration, epoch = 0, closing = false;
|
|
21
|
+
const send = value => { if (!closing) process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\n'); };
|
|
22
|
+
const shutdown = () => {
|
|
23
|
+
if (closing) return;
|
|
24
|
+
closing = true; controller.abort(); page?.close();
|
|
25
|
+
// All native descendants inherit this group; this also reaps descendants
|
|
26
|
+
// of an app launcher after a killed daemon closes the private stdin pipe.
|
|
27
|
+
setTimeout(() => killGroup(process.pid), 50);
|
|
28
|
+
};
|
|
29
|
+
process.stdin.once('end', shutdown); process.stdin.once('error', shutdown);
|
|
30
|
+
process.stdout.once('error', shutdown);
|
|
31
|
+
process.once('SIGTERM', shutdown); process.once('SIGINT', shutdown);
|
|
32
|
+
let bytes = 0;
|
|
33
|
+
try {
|
|
34
|
+
for await (const line of createInterface({ input: process.stdin })) {
|
|
35
|
+
if (closing) break;
|
|
36
|
+
let request;
|
|
37
|
+
try {
|
|
38
|
+
bytes = Buffer.byteLength(line); if (bytes > 128 * 1024) throw new Error('Desktop helper request exceeds its byte bound.');
|
|
39
|
+
request = JSON.parse(line);
|
|
40
|
+
const { id, method, params } = request;
|
|
41
|
+
let result = {};
|
|
42
|
+
if (method === 'initialize') {
|
|
43
|
+
configuration = params.configuration;
|
|
44
|
+
if (params.protocol !== 1 || !configuration || !Array.isArray(configuration.command) || configuration.cwd !== params.worktree) throw new Error('Invalid scoped desktop initialization.');
|
|
45
|
+
result = { protocol: 1, isolation: 'owned-session', privateHandoff: true };
|
|
46
|
+
} else if (method === 'open' && configuration && !page) {
|
|
47
|
+
page = await openDesktopPage({ ...configuration, env: process.env, signal: controller.signal, detachedChildren: false,
|
|
48
|
+
onFrame: frame => send({ method: 'frame', params: { ...frame, epoch } }),
|
|
49
|
+
onChange: patch => send({ method: patch.ended ? 'ended' : 'failure', params: { epoch, message: patch.error } }) });
|
|
50
|
+
} else if (method === 'snapshot' && page) result = { ...await page.snapshot(), width: 1440, height: 900, epoch };
|
|
51
|
+
else if (method === 'act' && page) await page.act(params.input);
|
|
52
|
+
else if (method === 'privacy' && page) { await page.setPrivate(params.private); epoch = params.epoch; result = { epoch, drained: true }; }
|
|
53
|
+
else throw new Error('Unsupported desktop helper operation.');
|
|
54
|
+
send({ id, result });
|
|
55
|
+
} catch (error) { send({ id: request?.id, error: { message: reviewDiagnostic(error.message) } }); }
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
shutdown();
|
|
59
|
+
// Do not return to cli.mjs's process.exit before the group cleanup runs.
|
|
60
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
61
|
+
}
|
|
62
|
+
}
|