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 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
- The daemon never executes anything the repository declares. An earlier version read a `.flowviant/preview.json` from the branch and spawned the command it named — that start path was removed in 0.53.0 and is not coming back; see the header of `bin/lib/preview.mjs` for exactly what it did, so nobody rebuilds it.
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
- do not add scripts or notes in its folder. Flowviant materializes index.glb
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;
@@ -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 };
@@ -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) chunks.push(d.subarray(start));
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(reason) {
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
 
@@ -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 browsers), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
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 browsers), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
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
+ }