flowviant 0.17.0 → 0.18.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/lib/authproxy.mjs +95 -0
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/live.mjs +46 -1
- package/bin/lib/preview.mjs +36 -7
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in basic-auth reverse proxy for previews (`.flowviant/preview.json`
|
|
3
|
+
* "auth": true). The public tunnel is otherwise a capability URL — anyone with
|
|
4
|
+
* it reaches the running app. This gates it: cloudflared → this proxy → dev
|
|
5
|
+
* server, with a generated password the app surfaces to the reviewer.
|
|
6
|
+
*
|
|
7
|
+
* Minimal + dependency-free: forwards HTTP, and pipes WS upgrades (HMR) — the
|
|
8
|
+
* browser re-sends the cached Basic-auth header on same-origin upgrades, so HMR
|
|
9
|
+
* still authenticates. If a proxy request fails the page just 502s; it never
|
|
10
|
+
* touches the default (no-auth) preview path.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createServer, request } from 'node:http';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
/** Start the proxy in front of a dev server on `targetPort`. Resolves
|
|
17
|
+
* { port, user, password, stop }. Binds loopback only (cloudflared connects
|
|
18
|
+
* locally); the password is what gates the public tunnel. */
|
|
19
|
+
export function startAuthProxy({ targetPort, log }) {
|
|
20
|
+
const user = 'preview';
|
|
21
|
+
const password = randomBytes(9).toString('base64url'); // ~12 url-safe chars
|
|
22
|
+
const expected = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
|
|
23
|
+
const authed = (req) => req.headers['authorization'] === expected;
|
|
24
|
+
|
|
25
|
+
const forwardOpts = (req) => ({
|
|
26
|
+
host: '127.0.0.1',
|
|
27
|
+
port: targetPort,
|
|
28
|
+
method: req.method,
|
|
29
|
+
path: req.url,
|
|
30
|
+
headers: req.headers,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const server = createServer((req, res) => {
|
|
34
|
+
if (!authed(req)) {
|
|
35
|
+
res.writeHead(401, {
|
|
36
|
+
'WWW-Authenticate': 'Basic realm="Flowviant preview"',
|
|
37
|
+
'Content-Type': 'text/plain',
|
|
38
|
+
});
|
|
39
|
+
res.end('This preview is password-protected. Enter the password shown in Flowviant.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const proxyReq = request(forwardOpts(req), (proxyRes) => {
|
|
43
|
+
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
|
44
|
+
proxyRes.pipe(res);
|
|
45
|
+
});
|
|
46
|
+
proxyReq.on('error', () => {
|
|
47
|
+
if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
48
|
+
res.end('preview origin not reachable');
|
|
49
|
+
});
|
|
50
|
+
req.pipe(proxyReq);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// WS upgrade (HMR). The browser resends the Basic-auth header on same-origin
|
|
54
|
+
// upgrades, so we can gate it too, then pipe the two sockets together.
|
|
55
|
+
server.on('upgrade', (req, socket, head) => {
|
|
56
|
+
if (!authed(req)) {
|
|
57
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Flowviant preview"\r\n\r\n');
|
|
58
|
+
socket.destroy();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const proxyReq = request(forwardOpts(req));
|
|
62
|
+
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
|
63
|
+
const headerLines = Object.entries(proxyRes.headers).map(([k, v]) => `${k}: ${v}`);
|
|
64
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\n${headerLines.join('\r\n')}\r\n\r\n`);
|
|
65
|
+
if (proxyHead && proxyHead.length) proxySocket.unshift(proxyHead);
|
|
66
|
+
proxySocket.pipe(socket);
|
|
67
|
+
socket.pipe(proxySocket);
|
|
68
|
+
proxySocket.on('error', () => socket.destroy());
|
|
69
|
+
socket.on('error', () => proxySocket.destroy());
|
|
70
|
+
});
|
|
71
|
+
proxyReq.on('error', () => socket.destroy());
|
|
72
|
+
if (head && head.length) proxyReq.write(head);
|
|
73
|
+
proxyReq.end();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
server.on('error', () => resolve(null)); // couldn't bind → caller falls back to no proxy
|
|
78
|
+
server.listen(0, '127.0.0.1', () => {
|
|
79
|
+
const port = server.address().port;
|
|
80
|
+
log?.(`auth proxy on :${port} — preview is password-gated`);
|
|
81
|
+
resolve({
|
|
82
|
+
port,
|
|
83
|
+
user,
|
|
84
|
+
password,
|
|
85
|
+
stop: () => {
|
|
86
|
+
try {
|
|
87
|
+
server.close();
|
|
88
|
+
} catch {
|
|
89
|
+
/* already closed */
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
package/bin/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
|
|
7
|
-
export const VERSION = '0.
|
|
7
|
+
export const VERSION = '0.18.0';
|
|
8
8
|
|
|
9
9
|
// Credential stored by `flowviant login` (device auth) — the no-token,
|
|
10
10
|
// no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
|
package/bin/lib/live.mjs
CHANGED
|
@@ -78,6 +78,23 @@ function clearLiveTarget(intentId, kind) {
|
|
|
78
78
|
}).catch(() => {});
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// Report WHY a preview didn't come up into the task thread, so the reason is
|
|
82
|
+
// visible in the app (not just the daemon console). The card grace-window shows
|
|
83
|
+
// "starting…"; this is the honest terminal state when it can't.
|
|
84
|
+
const PREVIEW_NOTE_URL = FLEET_URL.replace(/\/agents\/?$/, '/preview-note');
|
|
85
|
+
function postPreviewNote(intentId, text) {
|
|
86
|
+
return fetch(PREVIEW_NOTE_URL, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
Authorization: `Bearer ${FLEET_TOKEN}`,
|
|
90
|
+
'User-Agent': USER_AGENT,
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
},
|
|
93
|
+
signal: AbortSignal.timeout(10_000),
|
|
94
|
+
body: JSON.stringify({ intentId, text }),
|
|
95
|
+
}).catch(() => {});
|
|
96
|
+
}
|
|
97
|
+
|
|
81
98
|
// Safe mode's curated toolset. Bash is scoped to the specific CLIs the agent
|
|
82
99
|
// needs (git/gh/npm/bun) — NOT bare `Bash`, which would auto-approve arbitrary
|
|
83
100
|
// shell (rm -rf, curl|sh, reading ~/.ssh) and defeat the point of safe mode.
|
|
@@ -693,6 +710,12 @@ export async function runLiveWorker({
|
|
|
693
710
|
'no live preview: no runnable web frontend found (searched the repo root, web/frontend/client/…, and apps/* + packages/*). If your app is elsewhere or not vite/next/astro/etc., add .flowviant/preview.json: {"ui":{"cmd":"cd <dir> && npm install && npm run dev","port":5173}}.'
|
|
694
711
|
)}`
|
|
695
712
|
);
|
|
713
|
+
if (intentId) {
|
|
714
|
+
await postPreviewNote(
|
|
715
|
+
intentId,
|
|
716
|
+
'No live preview: no runnable web frontend found (searched the repo root, common frontend dirs, and apps/* + packages/*). If this task has a web app, add a `.flowviant/preview.json` pointing at it.',
|
|
717
|
+
);
|
|
718
|
+
}
|
|
696
719
|
return;
|
|
697
720
|
}
|
|
698
721
|
// Zero-config win: when we found the app in a subdir, say where, so it's
|
|
@@ -704,6 +727,7 @@ export async function runLiveWorker({
|
|
|
704
727
|
// worktree is missing) so DB/auth-backed paths like sign-in don't 500.
|
|
705
728
|
copyLocalEnvFiles(repoRoot, cwd, (m) => info(`${label} ${c.dim(m)}`));
|
|
706
729
|
info(`${label} ${c.dim('starting a live preview of the branch for review…')}`);
|
|
730
|
+
let lastPreviewLog = ''; // captured so a failure's reason reaches the app
|
|
707
731
|
preview = await startPreview({
|
|
708
732
|
worktree: cwd,
|
|
709
733
|
kind,
|
|
@@ -711,8 +735,21 @@ export async function runLiveWorker({
|
|
|
711
735
|
port: entry.port,
|
|
712
736
|
env: entry.env, // optional: extra env from .flowviant/preview.json
|
|
713
737
|
hostHeader: entry.hostHeader, // optional: override/disable the Host rewrite
|
|
714
|
-
|
|
738
|
+
auth: entry.auth === true, // optional: password-gate the public tunnel
|
|
739
|
+
log: (m) => {
|
|
740
|
+
lastPreviewLog = m;
|
|
741
|
+
info(`${label} ${c.dim(m)}`);
|
|
742
|
+
},
|
|
715
743
|
});
|
|
744
|
+
if (!preview) {
|
|
745
|
+
// Dev server crashed on boot / tunnel never came up — surface the reason
|
|
746
|
+
// (the last log line is the specific failure) in the thread, not just the
|
|
747
|
+
// console, so the reviewer isn't left guessing.
|
|
748
|
+
await postPreviewNote(
|
|
749
|
+
intentId,
|
|
750
|
+
`Live preview didn't start — ${lastPreviewLog || 'the dev server did not come up'}. (Full output is in the daemon console.)`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
716
753
|
if (preview) {
|
|
717
754
|
onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
|
|
718
755
|
await registerLiveTarget(intentId, kind, preview.url);
|
|
@@ -724,6 +761,14 @@ export async function runLiveWorker({
|
|
|
724
761
|
if (previewTarget) void registerLiveTarget(previewTarget.intentId, previewTarget.kind, previewTarget.url);
|
|
725
762
|
}, PREVIEW_HEARTBEAT_MS);
|
|
726
763
|
previewHeartbeat.unref?.();
|
|
764
|
+
// Auth on: post the password into the thread so the reviewer can enter it
|
|
765
|
+
// at the browser prompt (the tunnel is otherwise a capability URL).
|
|
766
|
+
if (preview.auth && intentId) {
|
|
767
|
+
await postPreviewNote(
|
|
768
|
+
intentId,
|
|
769
|
+
`🔒 This live preview is password-protected. At the browser prompt, sign in with user \`${preview.auth.user}\` and password \`${preview.auth.password}\`.`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
727
772
|
ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
|
|
728
773
|
}
|
|
729
774
|
};
|
package/bin/lib/preview.mjs
CHANGED
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
* "cmd": "<start dev server>", // required
|
|
18
18
|
* "port": 5173, // required
|
|
19
19
|
* "env": { "FOO": "bar" }, // optional — extra env for the dev server
|
|
20
|
-
* "hostHeader": "localhost"
|
|
20
|
+
* "hostHeader": "localhost", // optional — Host sent to the origin;
|
|
21
21
|
* // false disables the rewrite (for apps
|
|
22
22
|
* // that need their real public Host)
|
|
23
|
+
* "auth": true // optional — gate the public tunnel
|
|
24
|
+
* // behind a generated password (shown
|
|
25
|
+
* // in the app); off by default
|
|
23
26
|
* },
|
|
24
27
|
* "api": { "cmd": "<start api>", "port": 8787 } }
|
|
25
28
|
*/
|
|
@@ -28,6 +31,7 @@ import { spawn, execFileSync } from 'node:child_process';
|
|
|
28
31
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, readdirSync, rmSync } from 'node:fs';
|
|
29
32
|
import { join } from 'node:path';
|
|
30
33
|
import { homedir, platform, arch } from 'node:os';
|
|
34
|
+
import { startAuthProxy } from './authproxy.mjs';
|
|
31
35
|
|
|
32
36
|
// ── Config: explicit file, else infer from package.json ────────────────────
|
|
33
37
|
|
|
@@ -297,6 +301,7 @@ export async function startPreview({
|
|
|
297
301
|
port,
|
|
298
302
|
env: extraEnv,
|
|
299
303
|
hostHeader,
|
|
304
|
+
auth,
|
|
300
305
|
log,
|
|
301
306
|
timeoutMs = 180_000,
|
|
302
307
|
}) {
|
|
@@ -339,6 +344,7 @@ export async function startPreview({
|
|
|
339
344
|
let settled = false;
|
|
340
345
|
let tunnel = null;
|
|
341
346
|
let tunnelStarted = false;
|
|
347
|
+
let authProxy = null; // opt-in basic-auth proxy in front of the dev server
|
|
342
348
|
let out = '';
|
|
343
349
|
const tail = () => out.trim().split('\n').slice(-15).join('\n');
|
|
344
350
|
const killGroup = (child) => {
|
|
@@ -356,6 +362,11 @@ export async function startPreview({
|
|
|
356
362
|
const stop = () => {
|
|
357
363
|
forgetPreviewPid(server.pid);
|
|
358
364
|
forgetPreviewPid(tunnel?.pid);
|
|
365
|
+
try {
|
|
366
|
+
authProxy?.stop();
|
|
367
|
+
} catch {
|
|
368
|
+
/* already closed */
|
|
369
|
+
}
|
|
359
370
|
killGroup(server);
|
|
360
371
|
killGroup(tunnel);
|
|
361
372
|
};
|
|
@@ -370,22 +381,40 @@ export async function startPreview({
|
|
|
370
381
|
resolve(val);
|
|
371
382
|
};
|
|
372
383
|
|
|
373
|
-
// Open the tunnel once we know the real port (detected or fallback).
|
|
374
|
-
|
|
384
|
+
// Open the tunnel once we know the real port (detected or fallback). When
|
|
385
|
+
// auth is opted in, put the password proxy in front and tunnel to THAT.
|
|
386
|
+
const openTunnel = async (p) => {
|
|
375
387
|
if (tunnelStarted || settled) return;
|
|
376
388
|
tunnelStarted = true;
|
|
377
389
|
clearTimeout(bindTimer);
|
|
390
|
+
let tunnelPort = p;
|
|
391
|
+
if (auth) {
|
|
392
|
+
authProxy = await startAuthProxy({ targetPort: p, log });
|
|
393
|
+
if (settled) {
|
|
394
|
+
authProxy?.stop(); // torn down while the proxy was coming up — don't leak it
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (authProxy) tunnelPort = authProxy.port;
|
|
398
|
+
else log?.('auth proxy failed to start — tunneling WITHOUT a password.');
|
|
399
|
+
}
|
|
378
400
|
log?.(`preview: dev server on :${p} — opening the tunnel…`);
|
|
379
401
|
// --http-host-header: send the origin the Host it expects (default
|
|
380
402
|
// localhost — see hostRewrite above). Passes Vite/webpack/Next host checks
|
|
381
403
|
// with zero repo config; skipped when preview.json sets hostHeader:false.
|
|
382
|
-
const args = ['tunnel', '--url', `http://localhost:${
|
|
404
|
+
const args = ['tunnel', '--url', `http://localhost:${tunnelPort}`];
|
|
383
405
|
if (hostRewrite) args.push('--http-host-header', hostRewrite);
|
|
384
406
|
tunnel = spawn(cf, args, { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
385
407
|
recordPreviewPid(tunnel.pid, 'cloudflared'); // signature for orphan reaping
|
|
386
408
|
const onTunnel = (d) => {
|
|
387
409
|
const m = TUNNEL_RE.exec(d.toString());
|
|
388
|
-
if (m)
|
|
410
|
+
if (m) {
|
|
411
|
+
finish({
|
|
412
|
+
url: m[0],
|
|
413
|
+
kind,
|
|
414
|
+
stop,
|
|
415
|
+
auth: authProxy ? { user: authProxy.user, password: authProxy.password } : undefined,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
389
418
|
};
|
|
390
419
|
tunnel.stdout.on('data', onTunnel);
|
|
391
420
|
tunnel.stderr.on('data', onTunnel);
|
|
@@ -398,7 +427,7 @@ export async function startPreview({
|
|
|
398
427
|
out = (out + s).slice(-4000);
|
|
399
428
|
if (!tunnelStarted) {
|
|
400
429
|
const m = BIND_RE.exec(s);
|
|
401
|
-
if (m) openTunnel(Number(m[1]));
|
|
430
|
+
if (m) void openTunnel(Number(m[1]));
|
|
402
431
|
}
|
|
403
432
|
};
|
|
404
433
|
server.stdout.on('data', onServer);
|
|
@@ -417,7 +446,7 @@ export async function startPreview({
|
|
|
417
446
|
|
|
418
447
|
// If the server never prints a URL we recognize (quiet server), tunnel to the
|
|
419
448
|
// configured port as a last resort.
|
|
420
|
-
bindTimer = setTimeout(() => openTunnel(port), 30_000);
|
|
449
|
+
bindTimer = setTimeout(() => void openTunnel(port), 30_000);
|
|
421
450
|
timer = setTimeout(() => {
|
|
422
451
|
log?.(
|
|
423
452
|
`preview tunnel did not come up in ${Math.round(timeoutMs / 1000)}s — skipping.${
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|