flowviant 0.96.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.
- package/bin/cli.mjs +13 -3
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +174 -33
- package/bin/lib/authReject.mjs +32 -0
- package/bin/lib/authproxy.mjs +52 -16
- package/bin/lib/childEnv.mjs +33 -1
- package/bin/lib/claude.mjs +377 -45
- package/bin/lib/config.mjs +24 -1
- package/bin/lib/credentials.mjs +27 -4
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/env.mjs +146 -12
- package/bin/lib/fleet.mjs +125 -30
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +162 -0
- package/bin/lib/instance.mjs +115 -14
- package/bin/lib/knowledge.mjs +460 -23
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +2 -2
- package/bin/lib/machines.mjs +16 -7
- package/bin/lib/preview.mjs +95 -6
- package/bin/lib/printable.mjs +42 -0
- package/bin/lib/procRegistry.mjs +65 -0
- package/bin/lib/processes.mjs +20 -7
- package/bin/lib/projectTools.mjs +248 -0
- package/bin/lib/prompts.mjs +354 -32
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +152 -11
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +5 -0
- package/bin/lib/work.mjs +1261 -4476
- package/bin/lib/workAgentMerges.mjs +504 -0
- package/bin/lib/workAgentPlans.mjs +447 -0
- package/bin/lib/workAgentReview.mjs +702 -0
- package/bin/lib/workAgentTurns.mjs +1148 -0
- package/bin/lib/workDiffs.mjs +107 -0
- package/bin/lib/workPreviews.mjs +351 -0
- package/bin/lib/workProcesses.mjs +236 -0
- package/bin/lib/workPullRequests.mjs +313 -0
- package/bin/lib/workShip.mjs +385 -0
- package/bin/lib/worktreeDiff.mjs +99 -43
- 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
|
-
|
|
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
|
-
|
|
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 });
|
package/bin/lib/agentPlan.mjs
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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 =
|
|
63
|
-
if (end > i)
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
*
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
else if (ch === '{') depth++;
|
|
98
|
-
else if (ch === '}' && --depth === 0) return i;
|
|
163
|
+
close.set(i, end);
|
|
99
164
|
}
|
|
100
|
-
return
|
|
165
|
+
return close;
|
|
101
166
|
}
|
|
102
167
|
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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()
|
|
367
|
+
title: clean(r.title.trim(), 300),
|
|
246
368
|
...(typeof r.brief === 'string' && r.brief.trim()
|
|
247
|
-
? { brief: r.brief.trim()
|
|
369
|
+
? { brief: clean(r.brief.trim(), 2000) }
|
|
248
370
|
: {}),
|
|
249
371
|
}))
|
|
250
372
|
: [],
|
package/bin/lib/artifacts.mjs
CHANGED
|
@@ -37,13 +37,27 @@
|
|
|
37
37
|
* this is somebody's disk and one place checking is one deploy from zero) are
|
|
38
38
|
* reported BY NAME and never read. Over `ARTIFACT_MAX_BYTES` likewise.
|
|
39
39
|
*
|
|
40
|
-
* ── TEXT IS SCRUBBED ──
|
|
40
|
+
* ── TEXT IS SCRUBBED; A BINARY CARRYING A SECRET IS WITHHELD ──
|
|
41
41
|
*
|
|
42
42
|
* html, md, svg, json, csv and txt pass through `envScrub` before upload — the
|
|
43
43
|
* turn trace's own scrub, for the trace's own reason: a page the agent wrote
|
|
44
44
|
* can quote a value out of the checkout's `.env`, and an artifact is served to
|
|
45
|
-
* a browser.
|
|
46
|
-
*
|
|
45
|
+
* a browser.
|
|
46
|
+
*
|
|
47
|
+
* The binary types CAN carry text, and this header used to say they could
|
|
48
|
+
* not. A PNG has tEXt chunks, a PDF has streams that are often stored
|
|
49
|
+
* uncompressed, a docx/pptx/xlsx is a zip whose entries may be STORED rather
|
|
50
|
+
* than deflated — and a turn that can write a file can write a secret into
|
|
51
|
+
* any of them. What they cannot be is REWRITTEN: `envScrub` swapping bytes
|
|
52
|
+
* inside one corrupts it, and a corrupted binary served as the thing the agent
|
|
53
|
+
* made is worse than none. So each binary is checked against the same scrub
|
|
54
|
+
* list as exact byte substrings (`secretIn`, env.mjs) before it is uploaded,
|
|
55
|
+
* and a hit SKIPS the file with a warn line naming it and the variable — never
|
|
56
|
+
* a redacted copy, never a by-name row the page would draw as a file it merely
|
|
57
|
+
* could not show. STATED: a value inside a deflated stream is not a substring
|
|
58
|
+
* of the file, so this catches what is stored plainly and nothing more.
|
|
59
|
+
*
|
|
60
|
+
* The SERVER computes the sha256 of what it stored; the one sent here is for
|
|
47
61
|
* the log.
|
|
48
62
|
*
|
|
49
63
|
* ── DELIVERY ──
|
|
@@ -61,15 +75,23 @@
|
|
|
61
75
|
* that cannot show an artifact must not have the CLI told it will.
|
|
62
76
|
*/
|
|
63
77
|
|
|
64
|
-
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';
|
|
65
79
|
import { createHash } from 'node:crypto';
|
|
66
80
|
import { join } from 'node:path';
|
|
81
|
+
import { tmpdir } from 'node:os';
|
|
82
|
+
import { pathToFileURL } from 'node:url';
|
|
83
|
+
import { captureScreenshot } from './shot.mjs';
|
|
67
84
|
|
|
68
85
|
/** Relative to the directory the turn stands in. Under `.flowviant/`, which
|
|
69
86
|
* the exclude file already hides from git (`excludeInWorktree`). */
|
|
70
87
|
export const ARTIFACT_DIR = '.flowviant/artifacts';
|
|
71
88
|
/** The server's per-file ceiling, re-checked here. */
|
|
72
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;
|
|
73
95
|
/** Listed per scan, newest first — the server keeps as many per owner. */
|
|
74
96
|
export const ARTIFACT_MAX_FILES = 40;
|
|
75
97
|
/** A held upload is tried this many times in all, then dropped with a line. */
|
|
@@ -91,6 +113,19 @@ export const ARTIFACT_TYPES = {
|
|
|
91
113
|
json: { mime: 'application/json', text: true },
|
|
92
114
|
csv: { mime: 'text/csv', text: true },
|
|
93
115
|
txt: { mime: 'text/plain', text: true },
|
|
116
|
+
// DOCUMENTS (0.97.0): a Word file, a deck, a sheet or a PDF — what the
|
|
117
|
+
// machine's docx/pptx/xlsx/pdf skills write. Binary, so never REWRITTEN (a
|
|
118
|
+
// scrub inside a zip or a PDF corrupts it) but checked for a known secret's
|
|
119
|
+
// bytes and withheld on a hit — see the header. Under the same cap. The app
|
|
120
|
+
// offers the first three as a download and frames the PDF.
|
|
121
|
+
docx: { mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', text: false },
|
|
122
|
+
pptx: { mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', text: false },
|
|
123
|
+
xlsx: { mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', text: false },
|
|
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 },
|
|
94
129
|
};
|
|
95
130
|
|
|
96
131
|
export function artifactTypeFor(name) {
|
|
@@ -123,23 +158,23 @@ export function scanArtifacts(placeDir) {
|
|
|
123
158
|
} catch {
|
|
124
159
|
return [];
|
|
125
160
|
}
|
|
126
|
-
let names;
|
|
127
|
-
try {
|
|
128
|
-
names = readdirSync(dir);
|
|
129
|
-
} catch {
|
|
130
|
-
return [];
|
|
131
|
-
}
|
|
132
161
|
const out = [];
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 */ }
|
|
141
175
|
}
|
|
142
|
-
}
|
|
176
|
+
};
|
|
177
|
+
walk(dir);
|
|
143
178
|
out.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
|
|
144
179
|
return out.slice(0, ARTIFACT_MAX_FILES);
|
|
145
180
|
}
|
|
@@ -177,32 +212,71 @@ function readNoFollow(path, limit) {
|
|
|
177
212
|
}
|
|
178
213
|
}
|
|
179
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
|
+
|
|
180
249
|
/**
|
|
181
250
|
* ONE UPLOAD BODY, as data — fields plus optional bytes — so a held body is
|
|
182
251
|
* re-sent exactly as it was first built (scrubbed once, never re-read from a
|
|
183
252
|
* disk the agent may have changed since).
|
|
184
253
|
*/
|
|
185
|
-
export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub = (s) => s) {
|
|
254
|
+
export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub = (s) => s, secretIn = () => null) {
|
|
186
255
|
const type = artifactTypeFor(entry.name);
|
|
187
256
|
const fields = {
|
|
188
257
|
...(sessionId ? { sessionId } : { agentId }),
|
|
189
258
|
...(turnId ? { turnId } : {}),
|
|
190
|
-
name: entry.name,
|
|
259
|
+
name: entry.name.split('/').map(safeArtifactName).join('/'),
|
|
191
260
|
bytes: String(entry.size),
|
|
192
261
|
};
|
|
193
262
|
if (!type) return { fields, bytes: null }; // reported by name only
|
|
194
|
-
|
|
263
|
+
const maxBytes = artifactMaxBytesFor(entry.name);
|
|
264
|
+
if (entry.size > maxBytes) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
|
|
195
265
|
let bytes;
|
|
196
266
|
try {
|
|
197
|
-
bytes = readNoFollow(entry.path,
|
|
267
|
+
bytes = readNoFollow(entry.path, maxBytes);
|
|
198
268
|
} catch {
|
|
199
269
|
return null; // vanished, or a symlink swapped in: nothing to say
|
|
200
270
|
}
|
|
201
271
|
if (!bytes) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
|
|
202
|
-
if (type.text) {
|
|
272
|
+
if (!type.text) {
|
|
273
|
+
// A binary is never rewritten; one carrying a known secret is not sent.
|
|
274
|
+
const secret = secretIn(bytes);
|
|
275
|
+
if (secret) return { withheld: secret, fields };
|
|
276
|
+
} else {
|
|
203
277
|
bytes = Buffer.from(String(scrub(bytes.toString('utf8'))), 'utf8');
|
|
204
278
|
// A redaction marker can be longer than the value it replaced.
|
|
205
|
-
if (bytes.byteLength >
|
|
279
|
+
if (bytes.byteLength > maxBytes) {
|
|
206
280
|
return { fields: { ...fields, bytes: String(bytes.byteLength), tooLarge: '1' }, bytes: null };
|
|
207
281
|
}
|
|
208
282
|
}
|
|
@@ -217,12 +291,39 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
|
|
|
217
291
|
};
|
|
218
292
|
}
|
|
219
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
|
+
|
|
220
321
|
/**
|
|
221
322
|
* The reporter a work manager holds for its life. `report` is called once per
|
|
222
323
|
* finished turn and never awaited by the turn (a slow uplink must not hold the
|
|
223
324
|
* next turn behind a readout); `retryPending` rides the settle retry beat.
|
|
224
325
|
*/
|
|
225
|
-
export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, fetchImpl, log = () => {} }) {
|
|
326
|
+
export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, secretIn, fetchImpl, log = () => {} }) {
|
|
226
327
|
const url = String(fleetUrl).replace(/\/agents\/?$/, '/artifact');
|
|
227
328
|
const doFetch = fetchImpl ?? ((...a) => fetch(...a));
|
|
228
329
|
const pending = new Map(); // `${owner}:${name}` -> { body, tries }
|
|
@@ -232,6 +333,7 @@ export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, fetc
|
|
|
232
333
|
const form = new FormData();
|
|
233
334
|
for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
|
|
234
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');
|
|
235
337
|
try {
|
|
236
338
|
const res = await doFetch(url, {
|
|
237
339
|
method: 'POST',
|
|
@@ -255,12 +357,40 @@ export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, fetc
|
|
|
255
357
|
}
|
|
256
358
|
pending.delete(key); // re-insert at the tail, so the cap sheds the oldest
|
|
257
359
|
pending.set(key, { body, tries });
|
|
258
|
-
while (pending.size > MAX_PENDING)
|
|
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
|
+
}
|
|
259
365
|
};
|
|
260
366
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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;
|
|
264
394
|
};
|
|
265
395
|
|
|
266
396
|
let retrying = false;
|
|
@@ -270,7 +400,7 @@ export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, fetc
|
|
|
270
400
|
try {
|
|
271
401
|
for (const [key, held] of [...pending]) {
|
|
272
402
|
if (pending.get(key) !== held) continue; // superseded meanwhile
|
|
273
|
-
await send(key, held.body, held.tries);
|
|
403
|
+
await send(key, held.body, held.tries, genOf.get(key));
|
|
274
404
|
}
|
|
275
405
|
} finally {
|
|
276
406
|
retrying = false;
|
|
@@ -284,11 +414,22 @@ export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, fetc
|
|
|
284
414
|
const changed = changedArtifacts(before, scanArtifacts(placeDir));
|
|
285
415
|
let n = 0;
|
|
286
416
|
for (const entry of changed) {
|
|
287
|
-
const body = buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub);
|
|
417
|
+
const body = buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
|
|
288
418
|
if (!body) continue;
|
|
419
|
+
if (body.withheld) {
|
|
420
|
+
log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
|
|
421
|
+
continue;
|
|
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
|
+
}
|
|
289
428
|
const key = `${sessionId ?? agentId}:${entry.name}`;
|
|
290
429
|
pending.delete(key); // this copy supersedes a held older one
|
|
291
|
-
|
|
430
|
+
const gen = ++generation;
|
|
431
|
+
genOf.set(key, gen); // …and one still in flight
|
|
432
|
+
await send(key, body, 0, gen);
|
|
292
433
|
n++;
|
|
293
434
|
}
|
|
294
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
|
+
}
|