flowviant 0.17.0 → 0.19.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 +57 -3
- 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.19.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.
|
|
@@ -108,7 +125,16 @@ answered…" or teammate line as a new instruction and adapt. There is NO termin
|
|
|
108
125
|
and NO interactive prompt — your only channel to a human is the flowviant MCP
|
|
109
126
|
tools. When you hit a decision only a human can make, call report_blocker (with
|
|
110
127
|
options when you can) and then STOP your turn — do not spin or guess; you will be
|
|
111
|
-
resumed with the answer.
|
|
128
|
+
resumed with the answer. As you satisfy each "done when" criterion, call
|
|
129
|
+
attach_evidence for it — proof the reviewer can SEE without running anything.
|
|
130
|
+
Match the evidence to what you built: backend/API work → a request/response
|
|
131
|
+
capture or a data sample showing the write; a single screen → a screenshot.
|
|
132
|
+
CRITICAL for a multi-step FLOW (login, signup, checkout): a screenshot of one
|
|
133
|
+
page does NOT prove the flow works — you MUST prove the whole path end to end.
|
|
134
|
+
Best: write an e2e/integration test that DRIVES the flow (fill form → submit →
|
|
135
|
+
assert the post-login/success state) and attach its test_output; if you have a
|
|
136
|
+
browser tool (e.g. Playwright), also attach a screen recording of it running.
|
|
137
|
+
Never let a static screenshot stand in for a flow. When the work is done: open ONE draft PR (git push +
|
|
112
138
|
gh pr create --draft), call attach_pr, then call complete with a plain-language
|
|
113
139
|
summary of what you built AND a criteria self-report (index into the brief's
|
|
114
140
|
"done when" list + met true/false + a short note per item). That summary +
|
|
@@ -133,7 +159,7 @@ function seedPrompt(runId, brief, transcript, resumedInPlace) {
|
|
|
133
159
|
? [``, `Conversation so far (you may be resuming — pick up where this left off):`, transcript]
|
|
134
160
|
: []),
|
|
135
161
|
``,
|
|
136
|
-
`${transcript ? 'Continue' : 'Begin'}. Post a short plan first as a Markdown list (one numbered line per step), then: report_progress as you go; report_blocker + stop if you hit a human decision; open a draft PR, attach_pr, then complete (summary + criteria self-report — your delivery card) when done.`,
|
|
162
|
+
`${transcript ? 'Continue' : 'Begin'}. Post a short plan first as a Markdown list (one numbered line per step), then: report_progress as you go; attach_evidence for each "done when" criterion as you satisfy it (test output, a request/response, a data sample, or a screenshot — so it's reviewable without running anything); report_blocker + stop if you hit a human decision; open a draft PR, attach_pr, then complete (summary + criteria self-report — your delivery card) when done.`,
|
|
137
163
|
].join('\n');
|
|
138
164
|
}
|
|
139
165
|
|
|
@@ -693,6 +719,12 @@ export async function runLiveWorker({
|
|
|
693
719
|
'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
720
|
)}`
|
|
695
721
|
);
|
|
722
|
+
if (intentId) {
|
|
723
|
+
await postPreviewNote(
|
|
724
|
+
intentId,
|
|
725
|
+
'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.',
|
|
726
|
+
);
|
|
727
|
+
}
|
|
696
728
|
return;
|
|
697
729
|
}
|
|
698
730
|
// Zero-config win: when we found the app in a subdir, say where, so it's
|
|
@@ -704,6 +736,7 @@ export async function runLiveWorker({
|
|
|
704
736
|
// worktree is missing) so DB/auth-backed paths like sign-in don't 500.
|
|
705
737
|
copyLocalEnvFiles(repoRoot, cwd, (m) => info(`${label} ${c.dim(m)}`));
|
|
706
738
|
info(`${label} ${c.dim('starting a live preview of the branch for review…')}`);
|
|
739
|
+
let lastPreviewLog = ''; // captured so a failure's reason reaches the app
|
|
707
740
|
preview = await startPreview({
|
|
708
741
|
worktree: cwd,
|
|
709
742
|
kind,
|
|
@@ -711,8 +744,21 @@ export async function runLiveWorker({
|
|
|
711
744
|
port: entry.port,
|
|
712
745
|
env: entry.env, // optional: extra env from .flowviant/preview.json
|
|
713
746
|
hostHeader: entry.hostHeader, // optional: override/disable the Host rewrite
|
|
714
|
-
|
|
747
|
+
auth: entry.auth === true, // optional: password-gate the public tunnel
|
|
748
|
+
log: (m) => {
|
|
749
|
+
lastPreviewLog = m;
|
|
750
|
+
info(`${label} ${c.dim(m)}`);
|
|
751
|
+
},
|
|
715
752
|
});
|
|
753
|
+
if (!preview) {
|
|
754
|
+
// Dev server crashed on boot / tunnel never came up — surface the reason
|
|
755
|
+
// (the last log line is the specific failure) in the thread, not just the
|
|
756
|
+
// console, so the reviewer isn't left guessing.
|
|
757
|
+
await postPreviewNote(
|
|
758
|
+
intentId,
|
|
759
|
+
`Live preview didn't start — ${lastPreviewLog || 'the dev server did not come up'}. (Full output is in the daemon console.)`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
716
762
|
if (preview) {
|
|
717
763
|
onPreview?.(stopPreview); // hand the daemon a stop handle for shutdown
|
|
718
764
|
await registerLiveTarget(intentId, kind, preview.url);
|
|
@@ -724,6 +770,14 @@ export async function runLiveWorker({
|
|
|
724
770
|
if (previewTarget) void registerLiveTarget(previewTarget.intentId, previewTarget.kind, previewTarget.url);
|
|
725
771
|
}, PREVIEW_HEARTBEAT_MS);
|
|
726
772
|
previewHeartbeat.unref?.();
|
|
773
|
+
// Auth on: post the password into the thread so the reviewer can enter it
|
|
774
|
+
// at the browser prompt (the tunnel is otherwise a capability URL).
|
|
775
|
+
if (preview.auth && intentId) {
|
|
776
|
+
await postPreviewNote(
|
|
777
|
+
intentId,
|
|
778
|
+
`🔒 This live preview is password-protected. At the browser prompt, sign in with user \`${preview.auth.user}\` and password \`${preview.auth.password}\`.`,
|
|
779
|
+
);
|
|
780
|
+
}
|
|
727
781
|
ok(`${label} ${c.dim('live preview ready — open the node to drive it in your review')}`);
|
|
728
782
|
}
|
|
729
783
|
};
|
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.19.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": {
|