flowviant 0.125.20 → 0.125.21
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 +11 -1
- package/bin/cli.mjs +13 -0
- package/bin/lib/artifactContracts.mjs +87 -2
- package/bin/lib/artifactIteration.mjs +2 -1
- package/bin/lib/artifactReporter.mjs +2 -2
- package/bin/lib/artifacts.mjs +8 -1
- package/bin/lib/claudeStream.mjs +7 -0
- package/bin/lib/codexFencedMount.mjs +2 -2
- package/bin/lib/pinnedCapture.mjs +19 -8
- package/bin/lib/postureTools.mjs +2 -2
- package/bin/lib/publicReferenceBrowser.mjs +180 -0
- package/bin/lib/publicReferenceNetwork.mjs +131 -0
- package/bin/lib/publicReferenceProtocol.mjs +55 -0
- package/bin/lib/publicReferenceRuntime.mjs +65 -0
- package/bin/lib/publicReferenceTool.mjs +89 -0
- package/bin/lib/referenceCaptureProtocol.mjs +12 -0
- package/bin/lib/referenceCaptures.mjs +140 -0
- package/bin/lib/runTurn.mjs +8 -0
- package/bin/lib/runtimeEvents.mjs +24 -2
- package/bin/lib/uiReview.mjs +305 -0
- package/bin/lib/uiReviewBrowser.mjs +212 -0
- package/bin/lib/uiReviewContract.mjs +51 -0
- package/bin/lib/uiReviewTurn.mjs +15 -0
- package/bin/lib/websiteAccess.mjs +68 -0
- package/bin/lib/websiteTrace.mjs +36 -0
- package/bin/lib/work.mjs +1 -0
- package/bin/lib/workAgentTools.mjs +3 -2
- package/bin/lib/workAgentTurnExecution.mjs +43 -2
- package/bin/lib/workAgentTurns.mjs +2 -0
- package/bin/lib/workToolLog.mjs +24 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -188,7 +188,17 @@ From **0.86.0**, a project whose owner turns on **Publish agent branches** has e
|
|
|
188
188
|
|
|
189
189
|
You run your dev server yourself, in the session's own worktree, exactly as you would in any terminal. The daemon NOTICES the listening port; ask for a share in the app and it puts a [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) quick tunnel in front of it (auto-fetched if missing, pinned and checksummed), behind the daemon's own gate. Viewers reach the share through Flowviant's relay at `<name>.flowviant.dev`, never the tunnel's own address, and the gate admits only the share's password or a short-lived grant signed with the share's key. Flowviant stores that key and the password with the share, and its relay mints grants for signed-in project members — or for anyone with the link, if the share's owner opens it to everyone. Since 0.72.0 the gate also lets the Workbench embed the share in a frame: it rewrites the response's frame policy to permit exactly the app's origin, and a framed sign-in gets a partitioned cookie so it works where third-party cookies are blocked.
|
|
190
190
|
|
|
191
|
-
|
|
191
|
+
Sharing never starts a repository command. An earlier version read a `.flowviant/preview.json` from the branch and spawned the command it named — that sharing start path was removed in 0.53.0; see the header of `bin/lib/preview.mjs`.
|
|
192
|
+
|
|
193
|
+
## Local UI self-review in code agents
|
|
194
|
+
|
|
195
|
+
During a code build, the same agent can run a bounded local Chromium review before delivery. Its turn supplies the exact helper command and JSON-plan format. The agent chooses the repository-supported preview argv and dedicated port, names its script/configuration basis, and describes disposable fixtures. The runner starts that command inside the worktree with an isolated home and no inherited SDK credentials, measures its listener and process group, then checks HTTP readiness. It does not install a browser or dependencies, share a preview, or open a public tunnel.
|
|
196
|
+
|
|
197
|
+
Each plan contains local routes and ordered CSS-selector actions (`click`, `fill`, `submit`, `waitFor`, `assertText`, `assertUrl`). Chromium uses fresh contexts for 1440×900 desktop and 390×844 mobile captures. Click/submit mean DOM activation; assertions establish the resulting state. A preview-only proxy and request fence block other destinations, popups, downloads, frames, workers and sockets. Apps requiring those capabilities must report the affected checks skipped or use a disposable fixture that works within this boundary. Repository code still executes as the user's UID; the runner is not a sandbox for a malicious backend or a substitute for choosing safe fixtures.
|
|
198
|
+
|
|
199
|
+
On Claude Code and Codex build turns, `card_ui_review.inspect_screenshot` can supply an exact captured PNG as native image input. The agent must actually inspect the images and report what it saw, or report the runtime's image-input failure. A screenshot or image-input receipt alone never counts as a visual verdict. Other build CLIs can use their existing image tools if available and must name unavailable capabilities honestly.
|
|
200
|
+
|
|
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.
|
|
192
202
|
|
|
193
203
|
## Modes
|
|
194
204
|
|
package/bin/cli.mjs
CHANGED
|
@@ -85,6 +85,19 @@ if (process.argv[2] === '__capture-mcp') {
|
|
|
85
85
|
process.exit(process.exitCode ?? 0);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
if (process.argv[2] === '__reference-mcp') {
|
|
89
|
+
await (await import('./lib/publicReferenceTool.mjs')).referenceToolMain();
|
|
90
|
+
// A screenshot can exceed the pipe buffer. EOF on the client's input must
|
|
91
|
+
// not truncate a completed result while stdout is still draining.
|
|
92
|
+
await new Promise(resolve => process.stdout.write('', resolve));
|
|
93
|
+
process.exit(process.exitCode ?? 0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (['__ui-review', '__ui-review-mcp'].includes(process.argv[2])) {
|
|
97
|
+
await (await import('./lib/uiReview.mjs')).uiReviewMain(process.argv[2] === '__ui-review-mcp');
|
|
98
|
+
process.exit(process.exitCode ?? 0);
|
|
99
|
+
}
|
|
100
|
+
|
|
88
101
|
if (process.argv[2] === '__orchestrator-mcp') {
|
|
89
102
|
await (await import('./lib/orchestratorClient.mjs')).orchestratorMcpMain();
|
|
90
103
|
process.exit(process.exitCode ?? 0);
|
|
@@ -158,7 +158,54 @@ export const LOOK_HEADLESS = `${NO_WINDOW} To look at your page, screenshot it
|
|
|
158
158
|
* so it posts to `parent` with `'*'`; the app accepts only its own frame's
|
|
159
159
|
* messages. A page with no handshake (every 0.105.0 bundle) is still viewed;
|
|
160
160
|
* it is just never offered a Download .glb.
|
|
161
|
+
*
|
|
162
|
+
* THE MANIFEST AND THE POSTER (2026-10-25, the owner's `Output Viewer Frame`;
|
|
163
|
+
* decision record `2026-10-25-flowviant-draws-standard-outputs`). Flowviant
|
|
164
|
+
* draws the exported GLB in its own viewer, and every control over it, so the
|
|
165
|
+
* page becomes a full-bleed canvas and its folder gains `view.json`, the
|
|
166
|
+
* manifest the web reads when present (packages/shared/src/modelViewer.ts —
|
|
167
|
+
* its literal here is `MODEL_VIEW_MANIFEST`, held to that schema by
|
|
168
|
+
* agentTaskKinds.test.mjs). It uploads on the general rule — an allowlisted
|
|
169
|
+
* type the turn changed is prepared and sent (intermediateArtifacts.mjs) — so
|
|
170
|
+
* only these words had been keeping it out. It proves no delivery: the page
|
|
171
|
+
* and its measured export still do.
|
|
172
|
+
*
|
|
173
|
+
* THE POSTER IS THE DAEMON'S RENDER OF THE PAGE (0.125.21). These words first
|
|
174
|
+
* asked the agent to screenshot its own `poster.png`, the still a list or a
|
|
175
|
+
* nested kit shows without loading a GLB; but a Claude model card cannot run
|
|
176
|
+
* Chrome under its fenced shell, and the daemon already renders every HTML
|
|
177
|
+
* artifact it uploads (artifactPreview.mjs `renderDesignPreview`), which the
|
|
178
|
+
* server stores as that artifact's preview. So the page's default view IS the
|
|
179
|
+
* poster, and the contract asks for that view instead: the whole model,
|
|
180
|
+
* full-bleed, nothing over it, standing within the render's few seconds.
|
|
181
|
+
* "poster" stays optional in the shape: a poster.png an agent already has
|
|
182
|
+
* still ships on the same general rule.
|
|
161
183
|
*/
|
|
184
|
+
export const MODEL_VIEW_MANIFEST = `{
|
|
185
|
+
"kind": "map",
|
|
186
|
+
"title": "Street and metro map",
|
|
187
|
+
"entry": "street-metro-map/index.glb",
|
|
188
|
+
"poster": "street-metro-map/poster.png",
|
|
189
|
+
"view": {
|
|
190
|
+
"units": { "metre": 1, "unit": "m" },
|
|
191
|
+
"upAxis": "Y",
|
|
192
|
+
"front": "+Z",
|
|
193
|
+
"defaultView": "overview",
|
|
194
|
+
"views": [
|
|
195
|
+
{ "id": "overview", "label": "Overview", "group": "overview", "direction": "iso" },
|
|
196
|
+
{ "id": "plan", "label": "Plan", "group": "overview", "direction": "top" },
|
|
197
|
+
{ "id": "konbini", "label": "Konbini", "group": "spot", "area": "Shops", "direction": "front", "part": "konbini" },
|
|
198
|
+
{ "id": "north-platform", "label": "North platform", "group": "spot", "area": "Station",
|
|
199
|
+
"camera": { "position": [42, 6, -18], "target": [42, 1, -4] } }
|
|
200
|
+
],
|
|
201
|
+
"layers": [
|
|
202
|
+
{ "id": "konbini", "label": "Konbini", "part": "konbini" },
|
|
203
|
+
{ "id": "station-platforms", "label": "Station platforms", "part": "station-platforms" }
|
|
204
|
+
]
|
|
205
|
+
},
|
|
206
|
+
"parts": ["konbini", "station-platforms"]
|
|
207
|
+
}`;
|
|
208
|
+
|
|
162
209
|
export const SYSTEM_AGENT_MODEL = `You are the human's own Claude, working one 3D MODEL card in a git worktree of
|
|
163
210
|
their repository. Nobody is watching this run. You have the repo and the tools
|
|
164
211
|
TOOLS ON THIS CARD names below — no board, no chat.
|
|
@@ -177,8 +224,9 @@ WHAT TO DO:
|
|
|
177
224
|
|
|
178
225
|
2. Write ONE viewer, .flowviant/artifacts/<kebab-name>/index.html — a short
|
|
179
226
|
kebab-case name for what it is (for example
|
|
180
|
-
.flowviant/artifacts/reading-chair/index.html). Keep the viewer self-contained
|
|
181
|
-
|
|
227
|
+
.flowviant/artifacts/reading-chair/index.html). Keep the viewer self-contained:
|
|
228
|
+
beside it, its folder holds only the view.json below (and a poster.png, if
|
|
229
|
+
you already have one), never scripts or notes. Flowviant materializes index.glb
|
|
182
230
|
beside this exact page before completing the card, through the handler below.
|
|
183
231
|
Keep original generated texture PNGs elsewhere under .flowviant/artifacts/;
|
|
184
232
|
preserve their bytes and material mapping. The page BUILDS
|
|
@@ -186,6 +234,9 @@ WHAT TO DO:
|
|
|
186
234
|
- three.js as ES modules from cdn.jsdelivr.net/npm, through an import map
|
|
187
235
|
for "three" and "three/addons/"; orbit controls, lights and a neutral
|
|
188
236
|
ground. Nothing else loads from the network, and nothing can be sent.
|
|
237
|
+
- the page is the scene and nothing else: a full-bleed canvas, with no
|
|
238
|
+
title, panel, legend, button or text over the model. Flowviant draws
|
|
239
|
+
every control.
|
|
189
240
|
- the model is ONE THREE.Group named for what it is, built from three.js
|
|
190
241
|
geometry (lathe, extrude, tube, box and the rest, one mesh per part),
|
|
191
242
|
with MeshStandardMaterial only. Colour, roughness and metalness are
|
|
@@ -228,6 +279,40 @@ WHAT TO DO:
|
|
|
228
279
|
|
|
229
280
|
${LOOK_HEADLESS}
|
|
230
281
|
|
|
282
|
+
THE PAGE'S DEFAULT VIEW IS THE POSTER FLOWVIANT SHOWS. Flowviant renders
|
|
283
|
+
this page itself when it uploads it, and that still is the model's picture
|
|
284
|
+
wherever the model is listed. So the page opens on the default view below:
|
|
285
|
+
the whole model, fitted full-bleed to the frame, with nothing over it — no
|
|
286
|
+
loading text, label or overlay — standing within a few seconds of load.
|
|
287
|
+
Take no poster yourself; a poster.png you already have may sit in the
|
|
288
|
+
folder, named in "poster" below.
|
|
289
|
+
|
|
290
|
+
Then write <kebab-name>/view.json — how Flowviant's viewer frames the
|
|
291
|
+
model, exactly in this shape (a map's, with every field):
|
|
292
|
+
|
|
293
|
+
${MODEL_VIEW_MANIFEST.replace(/^/gm, ' ')}
|
|
294
|
+
|
|
295
|
+
"kind" is "model" for one object, "map" for a scene that places several
|
|
296
|
+
kits. "entry" is a path under .flowviant/artifacts/: this folder's
|
|
297
|
+
index.glb. "poster" is optional: name this folder's poster.png only when
|
|
298
|
+
the folder holds one; otherwise leave the field out. Declare "units"
|
|
299
|
+
once, as the size of the scene's unit in metres —
|
|
300
|
+
{ "stud": 0.28, "unit": "m" } for a scene built in studs — and never
|
|
301
|
+
write units or any other text into the scene; Flowviant draws the scale
|
|
302
|
+
bar. "upAxis" is "Y" (three.js is Y-up); "front" is the side the model
|
|
303
|
+
faces: "+X", "-X", "+Z" or "-Z". "views": up to three "overview" views,
|
|
304
|
+
"defaultView" naming one; a map adds named "spot" views, each grouped
|
|
305
|
+
under its "area". A view looks from a "direction" ("iso", "top",
|
|
306
|
+
"front", "side" or "back") fitted to the whole scene or to one "part", or
|
|
307
|
+
from a "camera" position and target in scene units. "layers", for a map:
|
|
308
|
+
one per kit, its "part" the kit's scene node name exactly. "parts": every
|
|
309
|
+
node you name in code (.name, which GLTFExporter keeps); each "part"
|
|
310
|
+
above is one of them, spelled the same. A single model has no spots and
|
|
311
|
+
no layers: Flowviant gives it Display and Turntable. A label or area is a
|
|
312
|
+
few words, 40 characters at most; at most 40 views, 60 layers and 200
|
|
313
|
+
parts — a manifest past any limit is not read. The page's opening view
|
|
314
|
+
is the one "defaultView" names, so the poster and the viewer agree.
|
|
315
|
+
|
|
231
316
|
${ARTIFACT_CONTRACT_TAIL({
|
|
232
317
|
ask: `If you cannot model it without a DECISION only a person can make — which of
|
|
233
318
|
two directions, what size or style, which asset it stands beside — STOP AND
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Explicit iteration relationships, plus measured local deletions. No model runs here. */
|
|
2
|
+
import { REFERENCE_CAPTURE_FILE } from './referenceCaptureProtocol.mjs';
|
|
2
3
|
import { constants, openSync, closeSync, fstatSync, readFileSync, lstatSync, unlinkSync, realpathSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
3
4
|
import { join, dirname, resolve, sep } from 'node:path';
|
|
4
5
|
import { createHash } from 'node:crypto';
|
|
@@ -117,7 +118,7 @@ export function seenArtifacts(record, stored, present, bornAt = null) {
|
|
|
117
118
|
let seen = record;
|
|
118
119
|
if (!seen) {
|
|
119
120
|
const since = Number.isFinite(bornAt)
|
|
120
|
-
? stored.filter((a) => storedAt(a) >= bornAt).map((a) => a.name) : [];
|
|
121
|
+
? stored.filter((a) => storedAt(a) >= bornAt && !REFERENCE_CAPTURE_FILE.test(a.name)).map((a) => a.name) : [];
|
|
121
122
|
seen = since.some((n) => present.has(n)) ? new Set(since) : new Set();
|
|
122
123
|
}
|
|
123
124
|
return new Set([...seen, ...present].filter((n) => names.has(n) || present.has(n)));
|
|
@@ -297,7 +297,7 @@ export function createArtifactReporter({
|
|
|
297
297
|
|
|
298
298
|
/** Upload what this turn changed in `placeDir`. Resolves to the number of
|
|
299
299
|
* bodies SENT this call (delivered or held), for the tests and the log. */
|
|
300
|
-
const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null, requiredOutputs = null }) => {
|
|
300
|
+
const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null, requiredOutputs = null, referenceCapture = false }) => {
|
|
301
301
|
if (!placeDir || (!sessionId && !agentId)) return 0;
|
|
302
302
|
const changes = iterationChanges(placeDir, before);
|
|
303
303
|
const protectedNames = imageRevisionProtection(before?.stored, before?.imageRevision);
|
|
@@ -347,7 +347,7 @@ export function createArtifactReporter({
|
|
|
347
347
|
failures.push(`Required intermediate output ${name} is missing, unsafe or excluded from reporting; its handoff is unfinished.`);
|
|
348
348
|
let n = 0;
|
|
349
349
|
for (const entry of changed) {
|
|
350
|
-
const body = buildUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
|
|
350
|
+
const body = buildUpload(entry, { sessionId, agentId, turnId, ...(referenceCapture ? { referenceCapture: true } : {}) }, scrub, secretIn);
|
|
351
351
|
if (required.has(entry.name) && (!body?.bytes || body.withheld || body.fields.sha256 !== required.get(entry.name))) {
|
|
352
352
|
failures.push(`Required intermediate output ${entry.name} is unreadable, withheld or changed since preparation. Restore its exact producer bytes and retry.`);
|
|
353
353
|
continue;
|
package/bin/lib/artifacts.mjs
CHANGED
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
* that cannot show an artifact must not have the CLI told it will.
|
|
75
75
|
*/
|
|
76
76
|
|
|
77
|
+
import { REFERENCE_CAPTURE_MANIFEST, REFERENCE_CAPTURE_FILE } from './referenceCaptureProtocol.mjs';
|
|
77
78
|
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readSync } from 'node:fs';
|
|
78
79
|
import { createHash } from 'node:crypto';
|
|
79
80
|
import { join } from 'node:path';
|
|
@@ -305,7 +306,7 @@ export const artifactStoredName = (name) => name.split('/').map(safeArtifactName
|
|
|
305
306
|
* re-sent exactly as it was first built (scrubbed once, never re-read from a
|
|
306
307
|
* disk the agent may have changed since).
|
|
307
308
|
*/
|
|
308
|
-
export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub = (s) => s, secretIn = () => null) {
|
|
309
|
+
export function buildArtifactUpload(entry, { sessionId, agentId, turnId, referenceCapture = false }, scrub = (s) => s, secretIn = () => null) {
|
|
309
310
|
const type = artifactTypeFor(entry.name);
|
|
310
311
|
const fields = {
|
|
311
312
|
...(sessionId ? { sessionId } : { agentId }),
|
|
@@ -313,6 +314,9 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
|
|
|
313
314
|
name: artifactStoredName(entry.name),
|
|
314
315
|
bytes: String(entry.size),
|
|
315
316
|
};
|
|
317
|
+
// Reserved evidence names are writable only by the measured browser
|
|
318
|
+
// consumer. Model-authored JSON cannot become a page-access measurement.
|
|
319
|
+
if (REFERENCE_CAPTURE_FILE.test(entry.name) && !referenceCapture) return { withheld: 'measured-reference-capture-only', fields };
|
|
316
320
|
if (!type) return { fields, bytes: null }; // reported by name only
|
|
317
321
|
const maxBytes = artifactMaxBytesFor(entry.name);
|
|
318
322
|
if (entry.size > maxBytes) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
|
|
@@ -328,7 +332,10 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
|
|
|
328
332
|
const secret = secretIn(bytes);
|
|
329
333
|
if (secret) return { withheld: secret, fields };
|
|
330
334
|
} else {
|
|
335
|
+
const original = bytes;
|
|
331
336
|
bytes = Buffer.from(String(scrub(bytes.toString('utf8'))), 'utf8');
|
|
337
|
+
if (REFERENCE_CAPTURE_MANIFEST.test(entry.name) && !bytes.equals(original))
|
|
338
|
+
return { withheld: 'reference-capture-redaction', fields };
|
|
332
339
|
// A redaction marker can be longer than the value it replaced.
|
|
333
340
|
if (bytes.byteLength > maxBytes) {
|
|
334
341
|
return { fields: { ...fields, bytes: String(bytes.byteLength), tooLarge: '1' }, bytes: null };
|
package/bin/lib/claudeStream.mjs
CHANGED
|
@@ -198,6 +198,7 @@ export function handleStreamLine(line, options) {
|
|
|
198
198
|
for (const b of ev.message.content) {
|
|
199
199
|
if (b.type === 'tool_result') onToolEvent?.(null, {}, {
|
|
200
200
|
id: b.tool_use_id, result: true, isError: b.is_error === true,
|
|
201
|
+
toolSuccess: b.is_error !== true,
|
|
201
202
|
...(agentId ? { agentId } : {}),
|
|
202
203
|
output: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.slice(0, 8).filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
|
|
203
204
|
detail: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
|
|
@@ -232,6 +233,12 @@ export function handleStreamLine(line, options) {
|
|
|
232
233
|
// alias, and this is the CLI naming what it chose. See turnModel.mjs.
|
|
233
234
|
const resolved = turnModelOf(ev.model);
|
|
234
235
|
if (resolved) onModel?.(resolved);
|
|
236
|
+
if (Array.isArray(ev.tools)) for (const tool of ['WebSearch', 'WebFetch']) {
|
|
237
|
+
onToolEvent?.('WebsiteAccess', { method: 'capability', tool }, {
|
|
238
|
+
id: `website-capability:${tool}`, result: true,
|
|
239
|
+
website: { access: 'unknown', availability: ev.tools.includes(tool) ? 'advertised' : 'unknown' },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
235
242
|
if (Array.isArray(ev.skills) || typeof ev.session_id === 'string' || Array.isArray(ev.mcp_servers)) {
|
|
236
243
|
onInit?.({
|
|
237
244
|
skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
|
|
@@ -109,7 +109,7 @@ const table = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
|
109
109
|
* config.toml could not be read), and
|
|
110
110
|
* `unmounted` `[{ name, reason, source: 'project' | 'machine', partial? }]`.
|
|
111
111
|
*/
|
|
112
|
-
export function codexFencedMount(usable, expand, machineEnv, turnServers = {}) {
|
|
112
|
+
export function codexFencedMount(usable, expand, machineEnv, turnServers = {}, { includeMachine = true } = {}) {
|
|
113
113
|
const args = [];
|
|
114
114
|
const childEnv = {};
|
|
115
115
|
const mounted = [];
|
|
@@ -192,7 +192,7 @@ export function codexFencedMount(usable, expand, machineEnv, turnServers = {}) {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
const projectNames = new Set([...usable.map((t) => t.name), ...ownNames]);
|
|
195
|
-
const personal = machineCodexServers(machineEnv);
|
|
195
|
+
const personal = includeMachine ? machineCodexServers(machineEnv) : [];
|
|
196
196
|
for (const [name, server] of personal ?? []) {
|
|
197
197
|
if (projectNames.has(name) || server.enabled === false) continue;
|
|
198
198
|
if (typeof server.command !== 'string' && typeof server.url !== 'string') continue;
|
|
@@ -59,17 +59,28 @@ const ATTACH_WAIT_MS = 2_000;
|
|
|
59
59
|
* protocol's error; `on(fn)` receives every event; `close(reason)` rejects
|
|
60
60
|
* whatever is still waiting (the browser is gone).
|
|
61
61
|
*/
|
|
62
|
-
export function createCdpClient(writable, readable) {
|
|
62
|
+
export function createCdpClient(writable, readable, { maxMessageBytes = Infinity } = {}) {
|
|
63
63
|
let nextId = 0;
|
|
64
64
|
let closed = null;
|
|
65
65
|
const waiting = new Map();
|
|
66
66
|
const listeners = [];
|
|
67
67
|
let chunks = [];
|
|
68
|
+
let chunkBytes = 0;
|
|
69
|
+
const close = reason => {
|
|
70
|
+
if (closed) return;
|
|
71
|
+
closed = reason;
|
|
72
|
+
chunks = []; chunkBytes = 0;
|
|
73
|
+
for (const w of waiting.values()) w.reject(new Error(reason));
|
|
74
|
+
waiting.clear();
|
|
75
|
+
};
|
|
68
76
|
readable.on('data', (d) => {
|
|
77
|
+
if (closed) return;
|
|
69
78
|
let start = 0;
|
|
70
79
|
for (let i = 0; i < d.length; i++) {
|
|
71
80
|
if (d[i] !== 0) continue;
|
|
72
81
|
chunks.push(d.subarray(start, i));
|
|
82
|
+
chunkBytes += i - start;
|
|
83
|
+
if (chunkBytes > maxMessageBytes) { close('DevTools message exceeded the reference bound.'); return; }
|
|
73
84
|
start = i + 1;
|
|
74
85
|
let msg;
|
|
75
86
|
try {
|
|
@@ -78,6 +89,7 @@ export function createCdpClient(writable, readable) {
|
|
|
78
89
|
msg = null;
|
|
79
90
|
}
|
|
80
91
|
chunks = [];
|
|
92
|
+
chunkBytes = 0;
|
|
81
93
|
if (!msg) continue;
|
|
82
94
|
if (msg.id != null && waiting.has(msg.id)) {
|
|
83
95
|
const w = waiting.get(msg.id);
|
|
@@ -88,7 +100,11 @@ export function createCdpClient(writable, readable) {
|
|
|
88
100
|
for (const fn of listeners) fn(msg);
|
|
89
101
|
}
|
|
90
102
|
}
|
|
91
|
-
if (start < d.length)
|
|
103
|
+
if (start < d.length) {
|
|
104
|
+
chunkBytes += d.length - start;
|
|
105
|
+
if (chunkBytes > maxMessageBytes) { close('DevTools message exceeded the reference bound.'); return; }
|
|
106
|
+
chunks.push(d.subarray(start));
|
|
107
|
+
}
|
|
92
108
|
});
|
|
93
109
|
return {
|
|
94
110
|
send(method, params = {}, sessionId) {
|
|
@@ -108,12 +124,7 @@ export function createCdpClient(writable, readable) {
|
|
|
108
124
|
on(fn) {
|
|
109
125
|
listeners.push(fn);
|
|
110
126
|
},
|
|
111
|
-
close
|
|
112
|
-
if (closed) return;
|
|
113
|
-
closed = reason;
|
|
114
|
-
for (const w of waiting.values()) w.reject(new Error(reason));
|
|
115
|
-
waiting.clear();
|
|
116
|
-
},
|
|
127
|
+
close,
|
|
117
128
|
};
|
|
118
129
|
}
|
|
119
130
|
|
package/bin/lib/postureTools.mjs
CHANGED
|
@@ -15,10 +15,10 @@ const POSTURES = {
|
|
|
15
15
|
research: {
|
|
16
16
|
claude: {
|
|
17
17
|
has: 'the repository’s files (never .env files), the project’s knowledge library, web search and web fetch, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
18
|
-
lacks: 'no measured native image-generation tool, no delegation, guarded read commands only (no helper scripts or
|
|
18
|
+
lacks: 'no measured native image-generation tool, no delegation, guarded read commands only (no helper scripts or direct browser launches), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
|
|
19
19
|
// An orchestrated research card (2026-10-19): its children read under
|
|
20
20
|
// CARD ORCHESTRATOR, so the note does not withhold delegation.
|
|
21
|
-
orchestratedLacks: 'no measured native image-generation tool, guarded read commands only (no helper scripts or
|
|
21
|
+
orchestratedLacks: 'no measured native image-generation tool, guarded read commands only (no helper scripts or direct browser launches), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
|
|
22
22
|
},
|
|
23
23
|
codex: {
|
|
24
24
|
has: 'a sandboxed shell over an isolated copy of the repository (no .env, credential or git files, and no network from the shell), web search, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/** Chromium has an empty private root and no network. Public response bytes
|
|
2
|
+
* enter only through the bounded DNS-pinned broker, over a private CDP pipe.
|
|
3
|
+
* Unsupported namespace or Chrome sandbox launch never retries unsandboxed. */
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync, openSync, readSync, closeSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { resolveBrowser, SANDBOX_FAILURE_RE } from './shot.mjs';
|
|
9
|
+
import { createCdpClient } from './pinnedCapture.mjs';
|
|
10
|
+
import { killGroup } from './processGroup.mjs';
|
|
11
|
+
import { createReferenceNetwork, publicReferenceUrl } from './publicReferenceNetwork.mjs';
|
|
12
|
+
import { REFERENCE_BOUNDS as B } from './publicReferenceProtocol.mjs';
|
|
13
|
+
|
|
14
|
+
export function referenceBrowserLaunch(browser = resolveBrowser()) {
|
|
15
|
+
if (process.platform !== 'linux') return { unavailable: 'unsupported_isolation' };
|
|
16
|
+
if (process.getuid?.() === 0) return { unavailable: 'root_browser_unsupported' };
|
|
17
|
+
if (!browser || browser.viaWindows) return { unavailable: 'no_native_browser' };
|
|
18
|
+
const bwrap = ['/usr/bin/bwrap', '/bin/bwrap'].find(existsSync);
|
|
19
|
+
if (!bwrap) return { unavailable: 'no_namespace_sandbox' };
|
|
20
|
+
let binary;
|
|
21
|
+
try {
|
|
22
|
+
const real = realpathSync(browser.bin);
|
|
23
|
+
binary = [real, join(dirname(real), 'chrome'), join(dirname(real), 'chromium')].find(file => {
|
|
24
|
+
let fd;
|
|
25
|
+
try { fd = openSync(file, 'r'); const magic = Buffer.alloc(4); readSync(fd, magic, 0, 4, 0); return magic.equals(Buffer.from([127, 69, 76, 70])); }
|
|
26
|
+
catch { return false; } finally { if (fd !== undefined) closeSync(fd); }
|
|
27
|
+
});
|
|
28
|
+
} catch { /* An unsupported wrapper cannot widen the filesystem mounts. */ }
|
|
29
|
+
if (!binary) return { unavailable: 'unsupported_browser_layout' };
|
|
30
|
+
const root = dirname(binary);
|
|
31
|
+
// A broad system-bin bind would expose shell/network helpers. Only browser
|
|
32
|
+
// distribution files, runtime libraries and fonts are in the private root.
|
|
33
|
+
if (!['/opt/google/chrome', '/opt/google/chrome-beta', '/opt/google/chrome-unstable',
|
|
34
|
+
'/usr/lib/chromium', '/usr/lib/chromium-browser', '/opt/microsoft/msedge', '/opt/brave.com/brave'].includes(root))
|
|
35
|
+
return { unavailable: 'unsupported_browser_layout' };
|
|
36
|
+
const args = ['--unshare-all', '--die-with-parent', '--new-session', '--cap-drop', 'ALL',
|
|
37
|
+
'--proc', '/proc', '--dev', '/dev', '--size', String(B.scratchBytes), '--tmpfs', '/tmp',
|
|
38
|
+
'--size', String(B.profileBytes), '--tmpfs', '/profile', '--size', '1048576', '--tmpfs', '/home', '--size', '1048576', '--tmpfs', '/etc',
|
|
39
|
+
'--ro-bind', root, '/browser'];
|
|
40
|
+
for (const dir of ['/usr/lib', '/lib', '/lib64', '/usr/share/fonts', '/usr/share/fontconfig', '/etc/fonts'])
|
|
41
|
+
if (existsSync(dir)) args.push('--ro-bind', dir, dir);
|
|
42
|
+
args.push('--clearenv', '--setenv', 'HOME', '/profile', '--setenv', 'TMPDIR', '/tmp', '--setenv', 'LANG', 'C.UTF-8',
|
|
43
|
+
'--', `/browser/${binary.slice(root.length + 1)}`,
|
|
44
|
+
'--headless=new', '--remote-debugging-pipe', '--user-data-dir=/profile', '--no-first-run', '--no-default-browser-check',
|
|
45
|
+
'--disable-gpu', '--disable-dev-shm-usage', '--disable-extensions', '--disable-background-networking',
|
|
46
|
+
'--disable-component-update', '--disable-sync', '--disable-breakpad', '--disable-crash-reporter',
|
|
47
|
+
'--disable-domain-reliability', '--disable-default-apps', '--no-pings', '--no-proxy-server',
|
|
48
|
+
'--disable-quic', '--disable-features=MediaRouter,OptimizationHints,AutofillServerCommunication',
|
|
49
|
+
'--renderer-process-limit=2', '--js-flags=--max-old-space-size=128', '--force-color-profile=srgb',
|
|
50
|
+
`--window-size=${B.width},${B.height}`, 'about:blank');
|
|
51
|
+
return { command: bwrap, args, env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function capturePublicReference({ url, signal, attemptId = randomUUID() }, {
|
|
55
|
+
launch = referenceBrowserLaunch, spawnBrowser = spawn, networkFactory = createReferenceNetwork,
|
|
56
|
+
} = {}) {
|
|
57
|
+
const attemptedAt = new Date().toISOString();
|
|
58
|
+
const capture = { version: 1, attemptId, requestedUrl: url, attemptedAt, capturedAt: null, viewport: { width: B.width, height: B.height },
|
|
59
|
+
observation: { access: 'unknown', observedAt: attemptedAt }, contentAssessment: 'unverified' };
|
|
60
|
+
try { publicReferenceUrl(url); } catch { return { ...capture, reason: 'blocked_destination', observation: { ...capture.observation, access: 'permission_denied', permissionSource: 'flowviant' } }; }
|
|
61
|
+
if (signal?.aborted) return { ...capture, reason: 'aborted' };
|
|
62
|
+
const spec = launch();
|
|
63
|
+
if (spec.unavailable) return { ...capture, reason: spec.unavailable, observation: { ...capture.observation, access: 'unavailable' } };
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const network = networkFactory({ signal: controller.signal });
|
|
66
|
+
let child, cdp, timer, abort, interrupt, started = false, stderr = '';
|
|
67
|
+
let documents = 0, requests = 0, blocked = 0, mainFrame, target, session, loaded = false, pending = 0, networkFailure;
|
|
68
|
+
const responses = new Map();
|
|
69
|
+
const finish = (access, reason) => ({ ...capture, blockedRequests: blocked, ...(reason ? { reason } : {}),
|
|
70
|
+
observation: { ...capture.observation, observedAt: new Date().toISOString(), access,
|
|
71
|
+
...(['blocked_destination', 'blocked_method'].includes(reason) ? { access: 'permission_denied', permissionSource: 'flowviant' } : {}) } });
|
|
72
|
+
const interrupted = new Promise(resolve => {
|
|
73
|
+
interrupt = (access, reason) => { controller.abort(); if (child) killGroup(child); resolve(finish(access, reason)); };
|
|
74
|
+
abort = () => interrupt('unknown', 'aborted');
|
|
75
|
+
if (signal?.aborted) abort(); else signal?.addEventListener('abort', abort, { once: true });
|
|
76
|
+
timer = setTimeout(() => interrupt(!started ? 'unavailable' : loaded ? 'partial' : 'failed', 'timeout'), B.timeoutMs);
|
|
77
|
+
});
|
|
78
|
+
const run = async () => {
|
|
79
|
+
child = spawnBrowser(spec.command, spec.args, { env: spec.env, detached: true, stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] });
|
|
80
|
+
child.stderr.on('data', data => { stderr = (stderr + data.toString()).slice(-4000); });
|
|
81
|
+
cdp = createCdpClient(child.stdio[3], child.stdio[4], { maxMessageBytes: 8 * 1024 * 1024 });
|
|
82
|
+
for (const fd of [3, 4]) child.stdio[fd].on('error', () => {});
|
|
83
|
+
child.once('error', () => cdp.close('Browser unavailable.'));
|
|
84
|
+
child.once('exit', () => cdp.close('Browser exited.'));
|
|
85
|
+
await cdp.send('Browser.getVersion');
|
|
86
|
+
started = true;
|
|
87
|
+
await cdp.send('Browser.setDownloadBehavior', { behavior: 'deny' });
|
|
88
|
+
// Only our page runs. Popups, out-of-process frames and workers are closed
|
|
89
|
+
// while paused; in-process requests still pass the broker. A bypass has
|
|
90
|
+
// no network route, no user home and no host credential files to reach.
|
|
91
|
+
cdp.on(message => {
|
|
92
|
+
const p = message.params ?? {};
|
|
93
|
+
if (message.method === 'Target.attachedToTarget' && p.targetInfo?.targetId !== target) {
|
|
94
|
+
if (p.waitingForDebugger && typeof p.targetInfo?.targetId === 'string') { blocked++; cdp.send('Target.closeTarget', { targetId: p.targetInfo.targetId }).catch(() => {}); }
|
|
95
|
+
}
|
|
96
|
+
if (message.sessionId !== session) return;
|
|
97
|
+
if (message.method === 'Page.lifecycleEvent' && p.frameId === mainFrame && ['DOMContentLoaded', 'load'].includes(p.name)) loaded = true;
|
|
98
|
+
if (message.method !== 'Fetch.requestPaused') return;
|
|
99
|
+
pending++;
|
|
100
|
+
void (async () => {
|
|
101
|
+
try {
|
|
102
|
+
if (controller.signal.aborted) throw new Error('aborted');
|
|
103
|
+
if (++requests > B.requests) { interrupt(loaded ? 'partial' : 'failed', 'request_limit'); return; }
|
|
104
|
+
if (p.resourceType === 'Document' && ++documents > B.documents) { interrupt(loaded ? 'partial' : 'failed', 'navigation_limit'); return; }
|
|
105
|
+
const response = await network.request(p.request.url, p.request.method);
|
|
106
|
+
if (p.resourceType === 'Document' && p.frameId === mainFrame) responses.set(publicReferenceUrl(p.request.url).href, { status: response.status,
|
|
107
|
+
challenge: response.headers.some(h => h.name.toLowerCase() === 'cf-mitigated' && h.value.toLowerCase() === 'challenge') });
|
|
108
|
+
await cdp.send('Fetch.fulfillRequest', { requestId: p.requestId, responseCode: response.status, responseHeaders: response.headers, body: response.body.toString('base64') }, session);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.reason === 'byte_limit' || error.reason === 'request_limit') { interrupt(loaded ? 'partial' : 'failed', error.reason); return; }
|
|
111
|
+
if (p.resourceType === 'Document' && p.frameId === mainFrame) networkFailure = error.reason ?? 'navigation_failed';
|
|
112
|
+
blocked++;
|
|
113
|
+
await cdp.send('Fetch.failRequest', { requestId: p.requestId, errorReason: 'BlockedByClient' }, session).catch(() => {});
|
|
114
|
+
} finally { pending--; }
|
|
115
|
+
})();
|
|
116
|
+
});
|
|
117
|
+
const context = await cdp.send('Target.createBrowserContext', { disposeOnDetach: true });
|
|
118
|
+
const created = await cdp.send('Target.createTarget', { url: 'about:blank', browserContextId: context.browserContextId });
|
|
119
|
+
target = created.targetId;
|
|
120
|
+
session = (await cdp.send('Target.attachToTarget', { targetId: target, flatten: true })).sessionId;
|
|
121
|
+
await cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, session);
|
|
122
|
+
await cdp.send('Page.enable', {}, session);
|
|
123
|
+
await cdp.send('Page.setLifecycleEventsEnabled', { enabled: true }, session);
|
|
124
|
+
await cdp.send('Network.enable', {}, session);
|
|
125
|
+
await cdp.send('Network.setBypassServiceWorker', { bypass: true }, session);
|
|
126
|
+
await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }, session);
|
|
127
|
+
await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] }, session);
|
|
128
|
+
await cdp.send('Emulation.setDeviceMetricsOverride', { width: B.width, height: B.height, deviceScaleFactor: 1, mobile: false }, session);
|
|
129
|
+
mainFrame = (await cdp.send('Page.getFrameTree', {}, session)).frameTree.frame.id;
|
|
130
|
+
await cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
|
|
131
|
+
const nav = await cdp.send('Page.navigate', { url }, session);
|
|
132
|
+
if (nav.errorText) return finish('failed', networkFailure ?? 'navigation_failed');
|
|
133
|
+
const until = Date.now() + 12_000;
|
|
134
|
+
while ((!loaded || pending) && Date.now() < until && !controller.signal.aborted) await new Promise(resolve => setTimeout(resolve, 50));
|
|
135
|
+
if (controller.signal.aborted) return finish('unknown', 'aborted');
|
|
136
|
+
const frame = (await cdp.send('Page.getFrameTree', {}, session)).frameTree.frame;
|
|
137
|
+
let finalUrl;
|
|
138
|
+
try { publicReferenceUrl(frame.url); finalUrl = frame.url; } catch { return finish('failed', 'unverified_document'); }
|
|
139
|
+
const response = responses.get(publicReferenceUrl(finalUrl).href);
|
|
140
|
+
if (!response || frame.unreachableUrl || !loaded) return finish('failed', networkFailure ?? 'unverified_document');
|
|
141
|
+
capture.finalUrl = finalUrl;
|
|
142
|
+
capture.observation.finalUrl = finalUrl;
|
|
143
|
+
capture.observation.httpStatus = response.status;
|
|
144
|
+
// Content remains untrusted source data. Restrict what evaluation returns;
|
|
145
|
+
// there is no tool for arbitrary JS, clicks, forms or document mutation.
|
|
146
|
+
const content = await cdp.send('Runtime.evaluate', { returnByValue: true, timeout: 2000, expression: `JSON.stringify({text:(document.body?.innerText||'').slice(0,${B.textChars}),links:Array.from(document.querySelectorAll('a[href]')).slice(0,${B.links}).map(a=>({url:a.href.slice(0,4096),text:(a.innerText||'').slice(0,160)}))})` }, session);
|
|
147
|
+
const parsed = JSON.parse(content.result?.value ?? '{}');
|
|
148
|
+
capture.text = typeof parsed.text === 'string' ? parsed.text.slice(0, B.textChars) : '';
|
|
149
|
+
capture.capturedAt = new Date().toISOString();
|
|
150
|
+
capture.links = Array.isArray(parsed.links) ? parsed.links.slice(0, B.links).flatMap(link => {
|
|
151
|
+
try { publicReferenceUrl(link.url); return typeof link.text === 'string' ? [{ url: link.url, text: link.text.slice(0, 160) }] : []; } catch { return []; }
|
|
152
|
+
}) : [];
|
|
153
|
+
const shot = await cdp.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }, session);
|
|
154
|
+
const png = Buffer.from(shot.data ?? '', 'base64');
|
|
155
|
+
if (!png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return finish('partial', 'screenshot_unavailable');
|
|
156
|
+
if (png.length > B.screenshotBytes) return finish('partial', 'screenshot_limit');
|
|
157
|
+
capture.screenshot = { mimeType: 'image/png', data: png.toString('base64') };
|
|
158
|
+
capture.capturedAt = new Date().toISOString();
|
|
159
|
+
const after = (await cdp.send('Page.getFrameTree', {}, session)).frameTree.frame;
|
|
160
|
+
if (after.loaderId !== frame.loaderId || after.url !== frame.url || pending) {
|
|
161
|
+
delete capture.screenshot; delete capture.text; delete capture.links; capture.capturedAt = null;
|
|
162
|
+
return finish('partial', 'document_changed');
|
|
163
|
+
}
|
|
164
|
+
return finish(response.challenge ? 'challenge' : response.status >= 400 ? 'failed' : blocked ? 'partial' : 'loaded');
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
return await Promise.race([run().catch(() => finish(started ? 'failed' : 'unavailable', SANDBOX_FAILURE_RE.test(stderr) ? 'browser_sandbox_unavailable' : started ? 'browser_failed' : 'isolation_launch_failed')), interrupted]);
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer); signal?.removeEventListener('abort', abort);
|
|
170
|
+
controller.abort(); network.close(); cdp?.close('Reference capture ended.');
|
|
171
|
+
if (child) {
|
|
172
|
+
const exited = new Promise(resolve => { if (child.exitCode !== null || child.signalCode !== null) resolve(); else { child.once('exit', resolve); child.once('error', resolve); } });
|
|
173
|
+
killGroup(child);
|
|
174
|
+
let reapTimer;
|
|
175
|
+
await Promise.race([exited, new Promise(resolve => { reapTimer = setTimeout(resolve, 2000); })]);
|
|
176
|
+
clearTimeout(reapTimer);
|
|
177
|
+
for (const stream of child.stdio ?? []) stream?.destroy?.();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|