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.
@@ -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.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. When the work is done: open ONE draft PR (git push +
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
- log: (m) => info(`${label} ${c.dim(m)}`),
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
  };
@@ -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.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": {