flowviant 0.97.0 → 0.100.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/README.md +15 -13
- package/bin/cli.mjs +108 -28
- package/bin/lib/agentPlan.mjs +159 -37
- package/bin/lib/artifacts.mjs +136 -26
- 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 +166 -71
- package/bin/lib/config.mjs +36 -9
- package/bin/lib/credentials.mjs +29 -5
- package/bin/lib/deploy.mjs +364 -30
- package/bin/lib/desktopContract.mjs +227 -0
- package/bin/lib/env.mjs +130 -13
- package/bin/lib/fleet.mjs +121 -30
- package/bin/lib/git.mjs +131 -3
- package/bin/lib/hooks/readGuard.mjs +24 -0
- package/bin/lib/instance.mjs +129 -14
- package/bin/lib/knowledge.mjs +180 -60
- package/bin/lib/landed.mjs +38 -30
- package/bin/lib/launchCommand.mjs +23 -0
- package/bin/lib/listeners.mjs +211 -0
- package/bin/lib/localSessions.mjs +29 -4
- package/bin/lib/login.mjs +36 -12
- package/bin/lib/machines.mjs +24 -14
- package/bin/lib/mcp-cli.mjs +4 -3
- package/bin/lib/preflight.mjs +2 -1
- 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 +16 -11
- package/bin/lib/resources.mjs +46 -4
- package/bin/lib/runtimeOfferings.mjs +75 -0
- package/bin/lib/runtimes.mjs +71 -9
- package/bin/lib/shipMerge.mjs +8 -4
- package/bin/lib/trace.mjs +16 -6
- package/bin/lib/update.mjs +114 -25
- package/bin/lib/work.mjs +1192 -4584
- 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 +1151 -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 -3
package/bin/lib/artifacts.mjs
CHANGED
|
@@ -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
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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,
|
|
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 >
|
|
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)
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/bin/lib/authproxy.mjs
CHANGED
|
@@ -116,6 +116,10 @@ const MAX_SOURCES = 500;
|
|
|
116
116
|
|
|
117
117
|
const digest = (s) => createHash('sha256').update(String(s)).digest();
|
|
118
118
|
|
|
119
|
+
/** An Authorization header in the Basic scheme — the only shape the gate's own
|
|
120
|
+
* password ever arrives in, and so the only one it grades. */
|
|
121
|
+
const isBasic = (v) => typeof v === 'string' && /^\s*basic(\s|$)/i.test(v);
|
|
122
|
+
|
|
119
123
|
/** Constant-time over sha256 digests, so length never leaks and a missing
|
|
120
124
|
* header costs the same as a wrong one. */
|
|
121
125
|
function sameSecret(a, b) {
|
|
@@ -137,7 +141,18 @@ function sameSecret(a, b) {
|
|
|
137
141
|
* URL under attack. A single source is blocked on its own, at MAX_FAILED,
|
|
138
142
|
* without ending anybody else's share.
|
|
139
143
|
*/
|
|
140
|
-
export function startAuthProxy({
|
|
144
|
+
export function startAuthProxy({
|
|
145
|
+
targetPort,
|
|
146
|
+
// The address that reaches the ATTRIBUTED socket (listeners.mjs `originFor`)
|
|
147
|
+
// — `::1` for a dev server bound to the IPv6 loopback. Defaults to the IPv4
|
|
148
|
+
// loopback it always dialled, for a caller that measured nothing.
|
|
149
|
+
targetHost = '127.0.0.1',
|
|
150
|
+
log,
|
|
151
|
+
onAbuse,
|
|
152
|
+
grantSecret,
|
|
153
|
+
shareId,
|
|
154
|
+
authorizeUrl,
|
|
155
|
+
}) {
|
|
141
156
|
// ALL THREE OR NONE. Two of the three is a gate that cannot bounce anybody:
|
|
142
157
|
// a secret with no authorize URL has nowhere to send them, an authorize URL
|
|
143
158
|
// with no secret cannot verify what comes back. An older SERVER sends none of
|
|
@@ -217,7 +232,36 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
217
232
|
// whole thing down. An expired-but-validly-signed grant must never count
|
|
218
233
|
// either, or a viewer who left a tab open overnight closes the share on
|
|
219
234
|
// their own reload.
|
|
220
|
-
|
|
235
|
+
//
|
|
236
|
+
// AND ONLY A *BASIC* HEADER IS A PASSWORD ATTEMPT (audit 2026-09-24). Any
|
|
237
|
+
// Authorization header used to be graded against the gate password, so a
|
|
238
|
+
// previewed SPA calling its own `/api` with `Authorization: Bearer …` was
|
|
239
|
+
// answered 401 with a Basic challenge — over a valid grant cookie — and 25
|
|
240
|
+
// such calls blocked that viewer, eight viewers tore the share down, and
|
|
241
|
+
// the origin never saw its own header. A Bearer (or any other scheme) is
|
|
242
|
+
// the APP's credential: it is not ours to grade and it rides through.
|
|
243
|
+
//
|
|
244
|
+
// A VALID GRANT COOKIE IS ASKED FIRST, for the same reason: an app that
|
|
245
|
+
// itself speaks Basic sends a header that is not our password, and a
|
|
246
|
+
// viewer the app has vouched for must not be graded on it. A grant is
|
|
247
|
+
// HMAC-verified, so asking it first opens no brute-force path.
|
|
248
|
+
let grantVerdict = 'none';
|
|
249
|
+
if (grants) {
|
|
250
|
+
// EVERY value for our name, not the first — a duplicate must not shadow.
|
|
251
|
+
for (const raw of cookieValues(req.headers.cookie, GRANT_COOKIE)) {
|
|
252
|
+
const r = verifyGrant(raw, { secret: grantSecret, shareId });
|
|
253
|
+
if (r.ok) return 'ok';
|
|
254
|
+
if (r.reason === 'exp' && grantVerdict === 'none') {
|
|
255
|
+
lastExpired = r.payload;
|
|
256
|
+
grantVerdict = 'expired';
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (grantVerdict === 'none' && String(req.headers.cookie ?? '').includes(GRANT_COOKIE)) {
|
|
260
|
+
grantVerdict = 'forged';
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (isBasic(req.headers['authorization'])) {
|
|
221
265
|
// A blocked source is refused BEFORE the comparison — a block that
|
|
222
266
|
// still grades guesses would let the brute force run to a correct hit.
|
|
223
267
|
if (sourceBlocked(req)) return 'blocked';
|
|
@@ -230,18 +274,7 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
230
274
|
noteFailure(req);
|
|
231
275
|
return 'badpass';
|
|
232
276
|
}
|
|
233
|
-
|
|
234
|
-
if (!grants) return 'none';
|
|
235
|
-
// EVERY value for our name, not the first — a duplicate must not shadow.
|
|
236
|
-
for (const raw of cookieValues(req.headers.cookie, GRANT_COOKIE)) {
|
|
237
|
-
const r = verifyGrant(raw, { secret: grantSecret, shareId });
|
|
238
|
-
if (r.ok) return 'ok';
|
|
239
|
-
if (r.reason === 'exp') {
|
|
240
|
-
lastExpired = r.payload;
|
|
241
|
-
return 'expired';
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return String(req.headers.cookie ?? '').includes(GRANT_COOKIE) ? 'forged' : 'none';
|
|
277
|
+
return grantVerdict;
|
|
245
278
|
};
|
|
246
279
|
|
|
247
280
|
/**
|
|
@@ -338,7 +371,10 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
338
371
|
// its request would be us editing their app's input.
|
|
339
372
|
const forwardOpts = (req) => {
|
|
340
373
|
const headers = { ...req.headers };
|
|
341
|
-
|
|
374
|
+
// Only when it IS the gate credential. Anything else in Authorization is
|
|
375
|
+
// the previewed app's own (a Bearer to its API, its own Basic realm) and
|
|
376
|
+
// the app breaks without it; ours must still never reach branch code.
|
|
377
|
+
if (sameSecret(headers.authorization, expected)) delete headers.authorization;
|
|
342
378
|
delete headers['proxy-authorization'];
|
|
343
379
|
// ONLY OURS. The driver's app owns its own cookies and breaks without them;
|
|
344
380
|
// our grant is a signed bearer token and must not reach branch code.
|
|
@@ -346,7 +382,7 @@ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId,
|
|
|
346
382
|
if (rest) headers.cookie = rest;
|
|
347
383
|
else delete headers.cookie; // never send a bare empty `cookie:`
|
|
348
384
|
return {
|
|
349
|
-
host:
|
|
385
|
+
host: targetHost,
|
|
350
386
|
port: targetPort,
|
|
351
387
|
method: req.method,
|
|
352
388
|
path: req.url,
|
package/bin/lib/childEnv.mjs
CHANGED
|
@@ -223,9 +223,41 @@ export const DEPLOY_KEEP_NAMES = [...DEPLOY_KEEP];
|
|
|
223
223
|
* never leave the box either way; names would, and a name is enough to tell
|
|
224
224
|
* somebody which credential to go looking for.
|
|
225
225
|
*/
|
|
226
|
+
/**
|
|
227
|
+
* …AND THE DAEMON'S OWN CREDENTIALS, REDACTED AND NEVER ADMITTED (2026-09-24,
|
|
228
|
+
* the audit).
|
|
229
|
+
*
|
|
230
|
+
* The list above was written about the deploy lane, and it left out every
|
|
231
|
+
* credential the DAEMON itself runs on: the machine credential
|
|
232
|
+
* (`FLOWVIANT_MACHINE_TOKEN`, or its permanent `FLOWVIANT_FLEET` fallback on a
|
|
233
|
+
* headless box), and whatever signs the operator's CLIs and `gh` in. Those are
|
|
234
|
+
* the values a CLI turn can most easily reach — a turn inherits this process's
|
|
235
|
+
* environment, and `env` is one Bash call — and a turn trace, an answer and an
|
|
236
|
+
* artifact are all posted to the server. Redacting a value we never pass costs
|
|
237
|
+
* nothing (the `CF_*` argument above), so they join the REDACTION list only;
|
|
238
|
+
* `DEPLOY_KEEP` is untouched and `childEnv` still hands none of them to anybody.
|
|
239
|
+
*
|
|
240
|
+
* The machine credential is ALSO caught by shape in `env.mjs` (`fva_…`),
|
|
241
|
+
* because the copy that matters most is not in any environment variable at all:
|
|
242
|
+
* it is every project's `fleetToken` in `~/.flowviant/credentials.json`, plus
|
|
243
|
+
* the per-turn work/capture tokens the server mints. The names here are belt.
|
|
244
|
+
*/
|
|
245
|
+
const REDACT_ONLY = [
|
|
246
|
+
'CF_API_TOKEN',
|
|
247
|
+
'CF_ACCOUNT_ID',
|
|
248
|
+
'FLOWVIANT_MACHINE_TOKEN',
|
|
249
|
+
'FLOWVIANT_FLEET',
|
|
250
|
+
'ANTHROPIC_API_KEY',
|
|
251
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
252
|
+
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
253
|
+
'OPENAI_API_KEY',
|
|
254
|
+
'GH_TOKEN',
|
|
255
|
+
'GITHUB_TOKEN',
|
|
256
|
+
];
|
|
257
|
+
|
|
226
258
|
export function processEnvSecrets() {
|
|
227
259
|
const out = [];
|
|
228
|
-
for (const name of [...DEPLOY_KEEP,
|
|
260
|
+
for (const name of [...DEPLOY_KEEP, ...REDACT_ONLY]) {
|
|
229
261
|
const value = process.env[name];
|
|
230
262
|
if (typeof value === 'string' && value) out.push({ name, value });
|
|
231
263
|
}
|