flowviant 0.97.0 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +136 -26
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +166 -71
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +130 -13
  12. package/bin/lib/fleet.mjs +99 -28
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +24 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +180 -60
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +16 -11
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +71 -9
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1192 -4584
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
package/bin/cli.mjs CHANGED
@@ -487,6 +487,10 @@ function listLines(entries, { projectRowLabel }) {
487
487
  .join('\n');
488
488
  }
489
489
 
490
+ /** A repo binding the person just consented to, persisted by runFleetDaemon
491
+ * only after the instance lock is taken — never before a refusal. */
492
+ let afterLock = null;
493
+
490
494
  if (!FLEET_TOKEN) {
491
495
  if (CREDENTIAL.error) {
492
496
  console.error(`error: ${CREDENTIAL.error}. \`flowviant projects\` lists what is stored.`);
@@ -589,7 +593,12 @@ if (!FLEET_TOKEN) {
589
593
  console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
590
594
  }
591
595
  adoptStoredCredential(picked);
592
- creds.selectStoredProject(picked.projectId, { bindRepoRoot: repoRoot ?? undefined });
596
+ // BOUND ONLY ONCE THE START HAS THE LOCK. Written here, the answer moved
597
+ // the binding even when the instance lock then refused the start — a live
598
+ // daemon for the same project in its own checkout — and that daemon's next
599
+ // unattended self-update re-exec'd into a store that no longer bound its
600
+ // repo, found no match headless, and exited with the machine dark.
601
+ afterLock = () => creds.selectStoredProject(picked.projectId, { bindRepoRoot: repoRoot ?? undefined });
593
602
  console.log(`serving ${creds.projectLabel(picked)}${repoRoot ? ` from ${repoRoot}` : ''}.`);
594
603
  } else if (CREDENTIAL.choices?.length) {
595
604
  const creds = await import('./lib/credentials.mjs');
@@ -646,7 +655,8 @@ if (!FLEET_TOKEN) {
646
655
  `without tying it to this repo. Run \`flowviant\` here and answer to make it stick.`
647
656
  );
648
657
  } else if (raw === '' || raw === 'y' || raw === 'yes') {
649
- creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
658
+ // After the lock, for the reason the picker's bind gives above.
659
+ afterLock = () => creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
650
660
  } else {
651
661
  console.error(
652
662
  `nothing started. Connect this repo to its own project with \`flowviant login\`, ` +
@@ -656,4 +666,4 @@ if (!FLEET_TOKEN) {
656
666
  }
657
667
  }
658
668
 
659
- await runFleetDaemon();
669
+ await runFleetDaemon({ afterLock });
@@ -25,6 +25,10 @@
25
25
  * Run: node --test bin/lib/agentPlan.test.mjs
26
26
  */
27
27
 
28
+ // THE UPLINK SCRUBBER, as the DEFAULT for the two readers whose every string
29
+ // goes to the server (2026-09-24, the audit). See `parseTurnResult`.
30
+ import { scrub as envScrub } from './env.mjs';
31
+
28
32
  /** The biggest a single proposal may be. Bounds on a machine, not a policy:
29
33
  * the server caps these again at its own boundary. */
30
34
  const MAX_AGENTS = 20;
@@ -53,14 +57,60 @@ const MAX_NOTE = 1000;
53
57
  * about escapes and nobody notices, because the disagreement only shows up on a
54
58
  * card title with a quote in it.
55
59
  */
56
- function candidateObjects(raw) {
57
- const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
60
+ /**
61
+ * BOUNDED, BECAUSE THE TEXT IS A MODEL'S (2026-09-24, the audit).
62
+ *
63
+ * The scan used to walk from EVERY `{` to the end of the text, and parse every
64
+ * balanced span it found — quadratic on both counts. A final message of eighty
65
+ * thousand `{` (a quoted fixture, brace padding, a turn steered into writing
66
+ * one) took nine seconds of synchronous CPU, and while it ran nothing else on
67
+ * the machine moved: no poll, no lease renewal, no other lane. Three bounds,
68
+ * none of which a real answer comes near:
69
+ *
70
+ * · only the LAST `MAX_SCAN_CHARS` are scanned — every contract here puts the
71
+ * object LAST, and the fenced block is still looked for in the whole text;
72
+ * · each `{`'s closing brace is found ONCE (see `closers`), so the scan is a
73
+ * single right-to-left pass instead of one walk per brace;
74
+ * · only spans that can open an object (`{` then a key or `}`) are handed to
75
+ * JSON.parse. Every TOP-LEVEL span is parsed — top-level spans are
76
+ * disjoint, so together they are at most the scanned window — and the
77
+ * NESTED ones (a span inside an earlier candidate) share a budget of
78
+ * `MAX_NESTED_PARSES` spans and `MAX_NESTED_CHARS` characters, so a run of
79
+ * nested braces that parse is not re-parsed thousands of times.
80
+ *
81
+ * THE BUDGET NEVER REACHES A TOP-LEVEL SPAN, and the review found why it must
82
+ * not: the first cut counted every span from the left against one budget, so
83
+ * an answer that followed 250 JSON lines of a quoted fixture (or one large
84
+ * nested document) was never parsed at all — the turn read as `nothing` and a
85
+ * delivered agent went to Stuck. The contract puts the answer LAST and at the
86
+ * top level, which is the one span a budget must never cost.
87
+ */
88
+ const MAX_SCAN_CHARS = 256 * 1024;
89
+ const MAX_NESTED_PARSES = 200;
90
+ const MAX_NESTED_CHARS = 4 * 1024 * 1024;
91
+
92
+ function candidateObjects(text) {
93
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
94
+ const raw = text.length > MAX_SCAN_CHARS ? text.slice(text.length - MAX_SCAN_CHARS) : text;
58
95
  const bodies = [];
59
96
  if (fenced) bodies.push(fenced[1]);
97
+ const close = closers(raw);
98
+ let topEnd = -1;
99
+ let nestedParses = 0;
100
+ let nestedChars = 0;
60
101
  for (let i = 0; i < raw.length; i++) {
61
102
  if (raw[i] !== '{') continue;
62
- const end = balanced(raw, i);
63
- if (end > i) bodies.push(raw.slice(i, end + 1));
103
+ const end = close.get(i);
104
+ if (!(end > i) || !/^\{\s*["}]/.test(raw.slice(i, i + 64))) continue;
105
+ if (i > topEnd) {
106
+ topEnd = end;
107
+ } else {
108
+ const len = end - i + 1;
109
+ if (nestedParses >= MAX_NESTED_PARSES || nestedChars + len > MAX_NESTED_CHARS) continue;
110
+ nestedParses++;
111
+ nestedChars += len;
112
+ }
113
+ bodies.push(raw.slice(i, end + 1));
64
114
  }
65
115
  const out = [];
66
116
  for (const body of bodies) {
@@ -75,32 +125,51 @@ function candidateObjects(raw) {
75
125
  return out;
76
126
  }
77
127
 
78
- function extract(raw) {
79
- return candidateObjects(raw).find((v) => Array.isArray(v.agents)) ?? null;
80
- }
81
-
82
- /** The index of the `}` that closes the `{` at `from`, or -1. Skips string
83
- * literals so a brace inside a card title cannot end the object early. */
84
- function balanced(s, from) {
85
- let depth = 0;
86
- let inStr = false;
87
- let esc = false;
88
- for (let i = from; i < s.length; i++) {
89
- const ch = s[i];
90
- if (inStr) {
91
- if (esc) esc = false;
92
- else if (ch === '\\') esc = true;
93
- else if (ch === '"') inStr = false;
94
- continue;
128
+ /**
129
+ * For every `{`, the index of the `}` that closes it when read as the START of
130
+ * an object (outside any string), or -1 — the answer `balanced` gives, for all
131
+ * of them in one pass.
132
+ *
133
+ * Right to left, so every `{` AFTER the one being closed already has its
134
+ * answer. A walk that meets such a `{` outside a string jumps straight to that
135
+ * brace's closer — the string state from there on is the same for both walks,
136
+ * so the nested object ends where it ends — and if that brace never closes,
137
+ * neither does this one. What is left for each walk is its own top level.
138
+ */
139
+ function closers(s) {
140
+ const close = new Map();
141
+ for (let i = s.lastIndexOf('{'); i >= 0; i = i > 0 ? s.lastIndexOf('{', i - 1) : -1) {
142
+ let inStr = false;
143
+ let esc = false;
144
+ let end = -1;
145
+ for (let k = i + 1; k < s.length; k++) {
146
+ const ch = s[k];
147
+ if (inStr) {
148
+ if (esc) esc = false;
149
+ else if (ch === '\\') esc = true;
150
+ else if (ch === '"') inStr = false;
151
+ continue;
152
+ }
153
+ if (ch === '"') inStr = true;
154
+ else if (ch === '{') {
155
+ const inner = close.get(k);
156
+ if (inner === undefined || inner < 0) break;
157
+ k = inner;
158
+ } else if (ch === '}') {
159
+ end = k;
160
+ break;
161
+ }
95
162
  }
96
- if (ch === '"') inStr = true;
97
- else if (ch === '{') depth++;
98
- else if (ch === '}' && --depth === 0) return i;
163
+ close.set(i, end);
99
164
  }
100
- return -1;
165
+ return close;
101
166
  }
102
167
 
103
- export function parseProposal(text) {
168
+ function extract(raw) {
169
+ return candidateObjects(raw).find((v) => Array.isArray(v.agents)) ?? null;
170
+ }
171
+
172
+ export function parseProposal(text, scrub = envScrub) {
104
173
  const raw = String(text ?? '');
105
174
  const parsed = extract(raw);
106
175
  if (!parsed) return null;
@@ -117,7 +186,7 @@ export function parseProposal(text) {
117
186
  if (taskIds.length === 0) continue;
118
187
  agents.push({
119
188
  tempId: String(g.tempId || `a${i + 1}`).slice(0, 64),
120
- name: String(g.name ?? '').slice(0, MAX_NAME),
189
+ name: String(scrub(String(g.name ?? ''))).slice(0, MAX_NAME),
121
190
  taskIds,
122
191
  ...(Number.isFinite(g.pointsBudget) && g.pointsBudget > 0
123
192
  ? { pointsBudget: Math.min(Math.round(g.pointsBudget), 100_000) }
@@ -150,13 +219,45 @@ export function parseProposal(text) {
150
219
  }
151
220
  if (agents.length === 0) return null;
152
221
  return {
153
- agents,
222
+ agents: uniqueTempIds(agents),
154
223
  ...(typeof parsed.note === 'string' && parsed.note.trim()
155
- ? { note: parsed.note.slice(0, MAX_NOTE) }
224
+ ? { note: String(scrub(parsed.note)).slice(0, MAX_NOTE) }
156
225
  : {}),
157
226
  };
158
227
  }
159
228
 
229
+ /**
230
+ * A DAEMON BELT ON THE SAME COLLISION THE SERVER ALREADY REFUSES TO SHIP
231
+ * (2026-09-24, the audit's A4a CROSS 5).
232
+ *
233
+ * Two groups can share one tempId two ways: the model repeats itself, or one
234
+ * group NAMES none and defaults to the positional `a${i+1}` a differently-
235
+ * ordered group already claimed. The web keys `draftByTask` / `proposedCards`
236
+ * by tempId (boardLanes.ts), so two containers sharing one id is not cosmetic —
237
+ * per-group Start/Decline on either one then deletes BOTH, because
238
+ * `planAfterGroup` filters `.agents` by tempId and both match.
239
+ *
240
+ * Kept a byte-for-byte copy of the server's own rule
241
+ * (`agentPlan.ts`'s `uniqueTempIds`, the boundary every daemon version passes
242
+ * through) rather than re-derived, so the two ends cannot disagree about which
243
+ * of two colliding agents keeps the id the model wrote. FIRST occurrence wins
244
+ * unchanged; each later collision is suffixed `-2`, `-3`, … off the ORIGINAL
245
+ * id (not the previous suffix), staying inside the 64-char bound the schema
246
+ * already enforces upstream.
247
+ *
248
+ * This is a BELT, not the fix: an older daemon proposing the same collision is
249
+ * still covered by the server's identical transform at its own boundary.
250
+ */
251
+ function uniqueTempIds(list) {
252
+ const used = new Set();
253
+ return list.map((g) => {
254
+ let id = g.tempId;
255
+ for (let n = 2; used.has(id); n++) id = `${g.tempId.slice(0, 58)}-${n}`;
256
+ used.add(id);
257
+ return id === g.tempId ? g : { ...g, tempId: id };
258
+ });
259
+ }
260
+
160
261
  /**
161
262
  * The running account, off whichever shape the turn ended with — `{ progress }`
162
263
  * or nothing at all.
@@ -181,9 +282,9 @@ export function parseProposal(text) {
181
282
  * a machine to have clamped.
182
283
  */
183
284
  const MAX_PROGRESS = 8000;
184
- const progressOf = (v) => {
285
+ const progressOf = (v, scrub) => {
185
286
  const t = typeof v.progress === 'string' ? v.progress.trim() : '';
186
- return t ? { progress: t.slice(0, MAX_PROGRESS) } : {};
287
+ return t ? { progress: String(scrub(t)).slice(0, MAX_PROGRESS) } : {};
187
288
  };
188
289
 
189
290
  /**
@@ -222,7 +323,28 @@ const progressOf = (v) => {
222
323
  * caller omits the field rather than sending an empty string, so the server
223
324
  * keeps the last account that was true instead of blanking the head.
224
325
  */
225
- export function parseTurnResult(text) {
326
+ /*
327
+ * ── EVERY STRING IS SCRUBBED HERE, BEFORE ITS CAP (2026-09-24, the audit) ──
328
+ *
329
+ * `raised` cards and a proposal's `note` and names reached the server with no
330
+ * scrub at all — the caller redacted `answer` and `progress` and nothing
331
+ * else — so a build turn that quoted `STRIPE_SECRET_KEY=sk_live_…` in a
332
+ * raised card's brief filed it, verbatim, as an Open card every member reads
333
+ * and later agents are prompted with; and a planner note quoting one became a
334
+ * notification body. And `answer` was cut at 8000 HERE, before the caller's
335
+ * scrub, which is the straddling-cut bug `progress` and the precheck already
336
+ * record: `scrub` matches whole values, so a credential across the cut kept
337
+ * its prefix. So the scrub rides in, as `parsePrecheck`'s does, and runs over
338
+ * each whole field first.
339
+ *
340
+ * DEFAULTED TO THE REAL SCRUBBER, unlike the precheck's identity default: the
341
+ * production callers pass nothing, and a redaction that depends on each caller
342
+ * remembering is the one that is missing the day it matters. Scrubbing twice
343
+ * (the caller still scrubs `answer`) is harmless — a redaction marker contains
344
+ * no secret to match.
345
+ */
346
+ export function parseTurnResult(text, scrub = envScrub) {
347
+ const clean = (s, cap) => String(scrub(s)).slice(0, cap);
226
348
  for (const v of candidateObjects(String(text ?? ''))) {
227
349
  if (v.status === 'blocked') {
228
350
  const question = typeof v.question === 'string' ? v.question.trim() : '';
@@ -230,21 +352,21 @@ export function parseTurnResult(text) {
230
352
  // parks an agent with nothing to reply to. Treated as `nothing`, which
231
353
  // at least says truthfully that the machine went quiet.
232
354
  if (!question) continue;
233
- return { outcome: 'question', answer: question.slice(0, 8000), ...progressOf(v) };
355
+ return { outcome: 'question', answer: clean(question, 8000), ...progressOf(v, scrub) };
234
356
  }
235
357
  if (v.status === 'delivered') {
236
358
  return {
237
359
  outcome: 'delivered',
238
- answer: (typeof v.summary === 'string' ? v.summary : '').slice(0, 8000),
239
- ...progressOf(v),
360
+ answer: typeof v.summary === 'string' ? clean(v.summary, 8000) : '',
361
+ ...progressOf(v, scrub),
240
362
  raised: Array.isArray(v.raised)
241
363
  ? v.raised
242
364
  .filter((r) => r && typeof r.title === 'string' && r.title.trim())
243
365
  .slice(0, 10)
244
366
  .map((r) => ({
245
- title: r.title.trim().slice(0, 300),
367
+ title: clean(r.title.trim(), 300),
246
368
  ...(typeof r.brief === 'string' && r.brief.trim()
247
- ? { brief: r.brief.trim().slice(0, 2000) }
369
+ ? { brief: clean(r.brief.trim(), 2000) }
248
370
  : {}),
249
371
  }))
250
372
  : [],
@@ -75,15 +75,23 @@
75
75
  * that cannot show an artifact must not have the CLI told it will.
76
76
  */
77
77
 
78
- import { constants, closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync } from 'node:fs';
78
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
79
79
  import { createHash } from 'node:crypto';
80
80
  import { join } from 'node:path';
81
+ import { tmpdir } from 'node:os';
82
+ import { pathToFileURL } from 'node:url';
83
+ import { captureScreenshot } from './shot.mjs';
81
84
 
82
85
  /** Relative to the directory the turn stands in. Under `.flowviant/`, which
83
86
  * the exclude file already hides from git (`excludeInWorktree`). */
84
87
  export const ARTIFACT_DIR = '.flowviant/artifacts';
85
88
  /** The server's per-file ceiling, re-checked here. */
86
89
  export const ARTIFACT_MAX_BYTES = 2 * 1024 * 1024;
90
+ export const ARTIFACT_BINARY_MODEL_MAX_BYTES = 20 * 1024 * 1024;
91
+ export const DESIGN_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
92
+ const PREVIEW_CSP = "default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com; style-src 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net/npm/ https://unpkg.com https://fonts.googleapis.com; img-src data: blob:; font-src data: https://fonts.gstatic.com; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
93
+ export const artifactMaxBytesFor = (name) => /\.(?:glb|bin)$/i.test(name)
94
+ ? ARTIFACT_BINARY_MODEL_MAX_BYTES : ARTIFACT_MAX_BYTES;
87
95
  /** Listed per scan, newest first — the server keeps as many per owner. */
88
96
  export const ARTIFACT_MAX_FILES = 40;
89
97
  /** A held upload is tried this many times in all, then dropped with a line. */
@@ -114,6 +122,10 @@ export const ARTIFACT_TYPES = {
114
122
  pptx: { mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', text: false },
115
123
  xlsx: { mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', text: false },
116
124
  pdf: { mime: 'application/pdf', text: false },
125
+ gltf: { mime: 'model/gltf+json', text: true },
126
+ obj: { mime: 'model/obj', text: true },
127
+ glb: { mime: 'model/gltf-binary', text: false },
128
+ bin: { mime: 'application/octet-stream', text: false },
117
129
  };
118
130
 
119
131
  export function artifactTypeFor(name) {
@@ -146,23 +158,23 @@ export function scanArtifacts(placeDir) {
146
158
  } catch {
147
159
  return [];
148
160
  }
149
- let names;
150
- try {
151
- names = readdirSync(dir);
152
- } catch {
153
- return [];
154
- }
155
161
  const out = [];
156
- for (const name of names) {
157
- if (name.startsWith('.')) continue; // editor swap files, `.DS_Store`
158
- try {
159
- const st = lstatSync(join(dir, name));
160
- if (!st.isFile()) continue; // symlinks, directories, sockets: nothing
161
- out.push({ name, path: join(dir, name), size: st.size, mtimeMs: st.mtimeMs });
162
- } catch {
163
- /* vanished between the list and the stat */
162
+ const walk = (at, prefix = '', depth = 0) => {
163
+ if (depth > 4) return;
164
+ let names;
165
+ try { names = readdirSync(at); } catch { return; }
166
+ for (const part of names) {
167
+ if (part.startsWith('.')) continue;
168
+ const name = prefix ? `${prefix}/${part}` : part;
169
+ const path = join(at, part);
170
+ try {
171
+ const st = lstatSync(path);
172
+ if (st.isDirectory() && !st.isSymbolicLink()) walk(path, name, depth + 1);
173
+ else if (st.isFile() && !st.isSymbolicLink()) out.push({ name, path, size: st.size, mtimeMs: st.mtimeMs });
174
+ } catch { /* vanished */ }
164
175
  }
165
- }
176
+ };
177
+ walk(dir);
166
178
  out.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
167
179
  return out.slice(0, ARTIFACT_MAX_FILES);
168
180
  }
@@ -200,6 +212,40 @@ function readNoFollow(path, limit) {
200
212
  }
201
213
  }
202
214
 
215
+ /** FNV-1a, 32-bit, as 8 hex — verbatim against the server's `fnv1a8`
216
+ * (apps/api/src/routes/sessionsAttachments.routes.ts). */
217
+ function artifactFnv1a8(s) {
218
+ let h = 0x811c9dc5;
219
+ for (let i = 0; i < s.length; i++) {
220
+ h ^= s.charCodeAt(i);
221
+ h = Math.imul(h, 0x01000193);
222
+ }
223
+ return (h >>> 0).toString(16).padStart(8, '0');
224
+ }
225
+ const ARTIFACT_SAFE_EXT_RE = /^[A-Za-z0-9]{1,10}$/;
226
+ /**
227
+ * `safeFileName`, verbatim in effect (apps/api/src/routes/sessionsAttachments.
228
+ * routes.ts): keeps the extension through a cut. Applied to each relative
229
+ * path component, so a model under `scene/` keeps that relation to its page.
230
+ * This only differs from a component on an over-long name — but that is exactly the case the
231
+ * server's own `safeFileName(rawName)` would otherwise re-cut DIFFERENTLY
232
+ * from whatever this reported, since a bare truncation and an
233
+ * extension-preserving one disagree past 80 characters. Applying the same
234
+ * rule here means the server's re-application is a no-op and the name a
235
+ * design or research delivery is judged by is the one this machine reported.
236
+ */
237
+ function safeArtifactName(raw) {
238
+ if (typeof raw === 'string' && raw.length <= 80 && /^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(raw) && !raw.endsWith('.')) return raw;
239
+ const clean = String(raw ?? '').replace(/[^A-Za-z0-9._-]/g, '_');
240
+ if (clean.length <= 80) return clean || 'artifact';
241
+ const dot = clean.lastIndexOf('.');
242
+ const ext = dot > 0 && ARTIFACT_SAFE_EXT_RE.test(clean.slice(dot + 1)) ? clean.slice(dot + 1) : '';
243
+ const stem = ext ? clean.slice(0, dot) : clean;
244
+ const tag = `-${artifactFnv1a8(clean)}`;
245
+ const room = 80 - tag.length - (ext ? ext.length + 1 : 0);
246
+ return `${stem.slice(0, room)}${tag}${ext ? `.${ext}` : ''}`;
247
+ }
248
+
203
249
  /**
204
250
  * ONE UPLOAD BODY, as data — fields plus optional bytes — so a held body is
205
251
  * re-sent exactly as it was first built (scrubbed once, never re-read from a
@@ -210,14 +256,15 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
210
256
  const fields = {
211
257
  ...(sessionId ? { sessionId } : { agentId }),
212
258
  ...(turnId ? { turnId } : {}),
213
- name: entry.name,
259
+ name: entry.name.split('/').map(safeArtifactName).join('/'),
214
260
  bytes: String(entry.size),
215
261
  };
216
262
  if (!type) return { fields, bytes: null }; // reported by name only
217
- if (entry.size > ARTIFACT_MAX_BYTES) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
263
+ const maxBytes = artifactMaxBytesFor(entry.name);
264
+ if (entry.size > maxBytes) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
218
265
  let bytes;
219
266
  try {
220
- bytes = readNoFollow(entry.path, ARTIFACT_MAX_BYTES);
267
+ bytes = readNoFollow(entry.path, maxBytes);
221
268
  } catch {
222
269
  return null; // vanished, or a symlink swapped in: nothing to say
223
270
  }
@@ -229,7 +276,7 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
229
276
  } else {
230
277
  bytes = Buffer.from(String(scrub(bytes.toString('utf8'))), 'utf8');
231
278
  // A redaction marker can be longer than the value it replaced.
232
- if (bytes.byteLength > ARTIFACT_MAX_BYTES) {
279
+ if (bytes.byteLength > maxBytes) {
233
280
  return { fields: { ...fields, bytes: String(bytes.byteLength), tooLarge: '1' }, bytes: null };
234
281
  }
235
282
  }
@@ -244,6 +291,33 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
244
291
  };
245
292
  }
246
293
 
294
+ /** Render the scrubbed HTML, never the checkout's unsanitized source. A
295
+ * failed browser measurement is explicit; older daemon reports are absent. */
296
+ export async function renderDesignPreview(bytes, capture = captureScreenshot) {
297
+ const dir = mkdtempSync(join(tmpdir(), 'flowviant-design-'));
298
+ try {
299
+ const html = join(dir, 'design.html');
300
+ const png = join(dir, 'design.png');
301
+ // A fresh Chrome profile has no Flowviant credentials. Put the artifact
302
+ // box's network fence before any model-written markup executes.
303
+ writeFileSync(html, Buffer.concat([
304
+ Buffer.from(`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">`),
305
+ bytes,
306
+ ]));
307
+ const result = await capture({ url: pathToFileURL(html).href, out: png, width: 720, height: 450, timeoutMs: 15_000 });
308
+ if (!result.ok) return { renderState: 'unavailable' };
309
+ const image = readFileSync(png);
310
+ if (image.byteLength > DESIGN_PREVIEW_MAX_BYTES || !image.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) {
311
+ return { renderState: 'unavailable' };
312
+ }
313
+ return { renderState: 'rendered', preview: image };
314
+ } catch {
315
+ return { renderState: 'unavailable' };
316
+ } finally {
317
+ rmSync(dir, { recursive: true, force: true });
318
+ }
319
+ }
320
+
247
321
  /**
248
322
  * The reporter a work manager holds for its life. `report` is called once per
249
323
  * finished turn and never awaited by the turn (a slow uplink must not hold the
@@ -259,6 +333,7 @@ export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, secr
259
333
  const form = new FormData();
260
334
  for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
261
335
  if (body.bytes) form.set('file', new Blob([body.bytes]), body.fields.name);
336
+ if (body.preview) form.set('preview', new Blob([body.preview], { type: 'image/png' }), 'preview.png');
262
337
  try {
263
338
  const res = await doFetch(url, {
264
339
  method: 'POST',
@@ -282,12 +357,40 @@ export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, secr
282
357
  }
283
358
  pending.delete(key); // re-insert at the tail, so the cap sheds the oldest
284
359
  pending.set(key, { body, tries });
285
- while (pending.size > MAX_PENDING) pending.delete(pending.keys().next().value);
360
+ while (pending.size > MAX_PENDING) {
361
+ const shed = pending.keys().next().value;
362
+ pending.delete(shed);
363
+ if (!chains.has(shed)) genOf.delete(shed);
364
+ }
286
365
  };
287
366
 
288
- const send = async (key, body, tries) => {
289
- if (await post(body)) pending.delete(key);
290
- else hold(key, body, tries + 1);
367
+ // ONE COPY OF A FILE IS IN FLIGHT AT A TIME, AND ONLY THE NEWEST MAY
368
+ // DECIDE. Each report of a (owner, name) takes a fresh generation, and every
369
+ // post for that key is chained behind the previous one — so a slow retry of
370
+ // an OLD body can neither land on the server after the newer copy (the
371
+ // server upserts by name, so the last write wins) nor re-hold itself over
372
+ // the newer one when it fails. A body superseded before it went out is not
373
+ // sent at all; one superseded while in flight has its answer discarded.
374
+ let generation = 0;
375
+ const genOf = new Map(); // key -> the generation allowed to decide
376
+ const chains = new Map(); // key -> the tail of that key's post chain
377
+
378
+ const send = (key, body, tries, gen) => {
379
+ const run = (chains.get(key) ?? Promise.resolve()).then(async () => {
380
+ if (genOf.get(key) !== gen) return; // superseded before it went out
381
+ const ok = await post(body);
382
+ if (genOf.get(key) !== gen) return; // a newer copy decides for this key
383
+ if (ok) pending.delete(key);
384
+ else hold(key, body, tries + 1);
385
+ });
386
+ const tail = run.catch(() => {});
387
+ chains.set(key, tail);
388
+ tail.then(() => {
389
+ if (chains.get(key) !== tail) return;
390
+ chains.delete(key);
391
+ if (!pending.has(key)) genOf.delete(key); // settled: nothing left to order
392
+ });
393
+ return run;
291
394
  };
292
395
 
293
396
  let retrying = false;
@@ -297,7 +400,7 @@ export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, secr
297
400
  try {
298
401
  for (const [key, held] of [...pending]) {
299
402
  if (pending.get(key) !== held) continue; // superseded meanwhile
300
- await send(key, held.body, held.tries);
403
+ await send(key, held.body, held.tries, genOf.get(key));
301
404
  }
302
405
  } finally {
303
406
  retrying = false;
@@ -317,9 +420,16 @@ export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, secr
317
420
  log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
318
421
  continue;
319
422
  }
423
+ if (body.bytes && /\.html?$/i.test(entry.name)) {
424
+ const measured = await renderDesignPreview(body.bytes);
425
+ body.fields.renderState = measured.renderState;
426
+ if (measured.preview) body.preview = measured.preview;
427
+ }
320
428
  const key = `${sessionId ?? agentId}:${entry.name}`;
321
429
  pending.delete(key); // this copy supersedes a held older one
322
- await send(key, body, 0);
430
+ const gen = ++generation;
431
+ genOf.set(key, gen); // …and one still in flight
432
+ await send(key, body, 0, gen);
323
433
  n++;
324
434
  }
325
435
  return n;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * DID THE API REJECT THIS CREDENTIAL, OR DID SOMETHING IN FRONT OF IT ANSWER?
3
+ *
4
+ * A 401 or 403 on a machine-token route used to be read as "the credential is
5
+ * revoked" on sight, and the daemon exited for good — exit 0, so nothing
6
+ * restarts it. But Cloudflare sits in front of the API and answers this client
7
+ * class with its own 403s (a bot-management challenge, a 1020 firewall rule, a
8
+ * Browser Integrity Check false positive), as an HTML page. One of those on one
9
+ * poll took an unattended machine offline over a credential that was still
10
+ * valid: in-flight turns expired into Stuck, queued presses failed, and the
11
+ * board said the machine was gone.
12
+ *
13
+ * The API's own refusal always wears its JSON envelope — `app.onError` renders
14
+ * every HTTPException, including the machine-token middleware's "Token
15
+ * revoked", "Invalid machine token" and "Not a machine credential", as
16
+ * `{ success: false, error }` — so THAT is the credential rejection. Anything
17
+ * else carrying the same status is somebody else's answer, and the caller
18
+ * retries it like any other failed request.
19
+ *
20
+ * Reads the body, so call it only on a 401/403 whose body is not needed.
21
+ */
22
+ export async function credentialRejected(res) {
23
+ if (res?.status !== 401 && res?.status !== 403) return false;
24
+ const type = String(res.headers?.get?.('content-type') ?? '');
25
+ if (!/\bapplication\/json\b/i.test(type)) return false;
26
+ try {
27
+ const body = await res.json();
28
+ return Boolean(body) && body.success === false;
29
+ } catch {
30
+ return false; // not the API's envelope — an edge page mislabelled as JSON
31
+ }
32
+ }