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.
@@ -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
+ }
@@ -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.17.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
- log: (m) => info(`${label} ${c.dim(m)}`),
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
  };
@@ -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" // optional — Host sent to the origin;
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
- const openTunnel = (p) => {
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:${p}`];
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) finish({ url: m[0], kind, stop });
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.17.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": {