flowviant 0.51.2 → 0.53.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.
@@ -1,44 +1,124 @@
1
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.
2
+ * The password gate in front of a shared preview. cloudflared → this proxy →
3
+ * the dev server the DRIVER started in their own worktree.
6
4
  *
7
- * Minimal + dependency-free: forwards HTTP, and pipes WS upgrades (HMR) the
5
+ * MANDATORY, not opt-in (2026-08-21). It was `.flowviant/preview.json`
6
+ * "auth": true, defaulting OFF, and its caller logged "tunneling WITHOUT a
7
+ * password" to a console nobody reads and opened the tunnel anyway. A tunnel
8
+ * publishes a worktree holding the project's materialized dev secrets; there is
9
+ * no honest default but closed. `startAuthProxy` returning null now means the
10
+ * share is ABORTED and the machine says why.
11
+ *
12
+ * Minimal and dependency-free: forwards HTTP, and pipes WS upgrades (HMR) — the
8
13
  * 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.
14
+ * still authenticates.
15
+ *
16
+ * Three things this file gets wrong easily, all of them fixed here and all of
17
+ * them worth keeping fixed:
18
+ * - the credential must NOT reach the origin. `headers: req.headers` forwarded
19
+ * `authorization` verbatim, handing the gate password to whatever code the
20
+ * branch happens to be running. It is stripped now.
21
+ * - the comparison is over a secret, so it is constant-time over a digest
22
+ * rather than `===` over a string.
23
+ * - `stop()` was a bare `server.close()`, which refuses NEW connections and
24
+ * leaves live ones alone — so a held HMR websocket kept the page alive for
25
+ * the one most-engaged viewer after teardown. Live sockets are tracked and
26
+ * destroyed.
11
27
  */
12
28
 
13
29
  import { createServer, request } from 'node:http';
14
- import { randomBytes } from 'node:crypto';
30
+ import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
31
+
32
+ /** Failed attempts before the proxy stops answering at all. A quick tunnel's
33
+ * hostname is unguessable, so this is not the primary control — it is what
34
+ * turns a discovered URL from an offline guessing target into a visible,
35
+ * self-closing incident. */
36
+ const MAX_FAILED = 25;
37
+
38
+ const digest = (s) => createHash('sha256').update(String(s)).digest();
39
+
40
+ /** Constant-time over sha256 digests, so length never leaks and a missing
41
+ * header costs the same as a wrong one. */
42
+ function sameSecret(a, b) {
43
+ try {
44
+ return timingSafeEqual(digest(a ?? ''), digest(b ?? ''));
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
15
49
 
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 }) {
50
+ /**
51
+ * Start the gate in front of a dev server on `targetPort`. Resolves
52
+ * { port, user, password, stop } or NULL, which the caller must treat as a
53
+ * hard failure. Binds loopback only; cloudflared connects locally, and the
54
+ * password is what gates the public hostname.
55
+ *
56
+ * `onAbuse` fires once, after MAX_FAILED rejected attempts, so the caller can
57
+ * tear the whole share down rather than leaving a URL under attack.
58
+ */
59
+ export function startAuthProxy({ targetPort, log, onAbuse }) {
20
60
  const user = 'preview';
21
- const password = randomBytes(9).toString('base64url'); // ~12 url-safe chars
61
+ // 24 bytes 32 url-safe chars. It was 9 bytes, chosen when this was an
62
+ // opt-in convenience; it is the only thing between a public hostname and a
63
+ // worktree now.
64
+ const password = randomBytes(24).toString('base64url');
22
65
  const expected = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64');
23
- const authed = (req) => req.headers['authorization'] === expected;
24
66
 
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
- });
67
+ let failed = 0;
68
+ let abused = false;
69
+ const authed = (req) => {
70
+ if (abused) return false;
71
+ if (sameSecret(req.headers['authorization'], expected)) {
72
+ failed = 0;
73
+ return true;
74
+ }
75
+ // A bare GET with no header is the browser's first move on every load, and
76
+ // it is answered with a 401 challenge — it is not an attempt.
77
+ if (req.headers['authorization']) failed += 1;
78
+ if (failed >= MAX_FAILED && !abused) {
79
+ abused = true;
80
+ log?.(`preview gate: ${failed} failed attempts — closing the share.`);
81
+ try {
82
+ onAbuse?.();
83
+ } catch {
84
+ /* the caller's teardown is best-effort */
85
+ }
86
+ }
87
+ return false;
88
+ };
89
+
90
+ // The gate credential is OURS and stops here. Everything else is passed
91
+ // through untouched: the origin is the driver's own dev server and rewriting
92
+ // its request would be us editing their app's input.
93
+ const forwardOpts = (req) => {
94
+ const headers = { ...req.headers };
95
+ delete headers.authorization;
96
+ delete headers['proxy-authorization'];
97
+ return {
98
+ host: '127.0.0.1',
99
+ port: targetPort,
100
+ method: req.method,
101
+ path: req.url,
102
+ headers,
103
+ };
104
+ };
105
+
106
+ const challenge = (res) => {
107
+ res.writeHead(401, {
108
+ 'WWW-Authenticate': 'Basic realm="Flowviant preview"',
109
+ 'Content-Type': 'text/plain',
110
+ // A preview is a moving target by definition; nothing about it should sit
111
+ // in a cache the viewer cannot see.
112
+ 'Cache-Control': 'no-store',
113
+ });
114
+ res.end('This preview is password-protected. Enter the password shown in Flowviant.');
115
+ };
116
+
117
+ // Every live socket, so stop() can actually end the ones already talking.
118
+ const sockets = new Set();
32
119
 
33
120
  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
- }
121
+ if (!authed(req)) return challenge(res);
42
122
  const proxyReq = request(forwardOpts(req), (proxyRes) => {
43
123
  res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
44
124
  proxyRes.pipe(res);
@@ -50,8 +130,13 @@ export function startAuthProxy({ targetPort, log }) {
50
130
  req.pipe(proxyReq);
51
131
  });
52
132
 
133
+ server.on('connection', (socket) => {
134
+ sockets.add(socket);
135
+ socket.on('close', () => sockets.delete(socket));
136
+ });
137
+
53
138
  // 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.
139
+ // upgrades, so we gate it too, then pipe the two sockets together.
55
140
  server.on('upgrade', (req, socket, head) => {
56
141
  if (!authed(req)) {
57
142
  socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Flowviant preview"\r\n\r\n');
@@ -65,6 +150,8 @@ export function startAuthProxy({ targetPort, log }) {
65
150
  if (proxyHead && proxyHead.length) proxySocket.unshift(proxyHead);
66
151
  proxySocket.pipe(socket);
67
152
  socket.pipe(proxySocket);
153
+ sockets.add(proxySocket);
154
+ proxySocket.on('close', () => sockets.delete(proxySocket));
68
155
  proxySocket.on('error', () => socket.destroy());
69
156
  socket.on('error', () => proxySocket.destroy());
70
157
  });
@@ -74,10 +161,12 @@ export function startAuthProxy({ targetPort, log }) {
74
161
  });
75
162
 
76
163
  return new Promise((resolve) => {
77
- server.on('error', () => resolve(null)); // couldn't bind → caller falls back to no proxy
164
+ // Could not bind → the caller ABORTS the share. There is no no-proxy path
165
+ // to fall back to any more.
166
+ server.on('error', () => resolve(null));
78
167
  server.listen(0, '127.0.0.1', () => {
79
168
  const port = server.address().port;
80
- log?.(`auth proxy on :${port} — preview is password-gated`);
169
+ log?.(`preview gate on :${port}`);
81
170
  resolve({
82
171
  port,
83
172
  user,
@@ -88,6 +177,16 @@ export function startAuthProxy({ targetPort, log }) {
88
177
  } catch {
89
178
  /* already closed */
90
179
  }
180
+ // close() only stops NEW connections. An open HMR socket would keep
181
+ // serving the viewer who is still looking at it.
182
+ for (const s of sockets) {
183
+ try {
184
+ s.destroy();
185
+ } catch {
186
+ /* already gone */
187
+ }
188
+ }
189
+ sockets.clear();
91
190
  },
92
191
  });
93
192
  });
@@ -197,7 +197,7 @@ const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n
197
197
  // every intermediate text block still NARRATES, but only the final `result`
198
198
  // event contributes text — otherwise the same sentences arrive twice, once as
199
199
  // they stream and once in the result, and the tab posts the duplicate.
200
- function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult }) {
200
+ function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult, onInit }) {
201
201
  let ev;
202
202
  try {
203
203
  ev = JSON.parse(line);
@@ -224,6 +224,19 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromR
224
224
  push(humanizeToolUse(b.name, b.input || {}, cwd));
225
225
  }
226
226
  }
227
+ } else if (ev.type === 'system' && ev.subtype === 'init') {
228
+ // WHAT THIS MACHINE'S CLI CAN BE ASKED FOR BY NAME. The init event is the
229
+ // CLI's OWN answer — it has already resolved personal skills, this repo's
230
+ // skills, plugins and whatever the project settings enable or disable — so
231
+ // reading it costs nothing and cannot drift the way a `~/.claude/skills`
232
+ // scan of our own would. `skills` (rather than `slash_commands`) is the
233
+ // deliberate narrowing: the 50-odd commands beside it are the CLI's own
234
+ // interactive furniture (/clear, /model, /compact), and offering those in a
235
+ // relayed tab would be an offer wired to nothing.
236
+ //
237
+ // Only ever REPORTED, never enforced. Flowviant does not decide what your
238
+ // Claude can do; it relays what your Claude said it has.
239
+ if (Array.isArray(ev.skills)) onInit?.({ skills: ev.skills.map(String) });
227
240
  } else if (ev.type === 'result') {
228
241
  // The final assistant text (carries WIKI_DONE / REGROUND_DONE).
229
242
  if (typeof ev.result === 'string') appendText(ev.result + '\n');
@@ -247,7 +260,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromR
247
260
  // returned string for sentinel detection, and each activity is handed to
248
261
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
249
262
  // off and keep the raw text passthrough + line sentinels.
250
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
263
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onInit, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
251
264
  return new Promise((resolve) => {
252
265
  const rt = runtimeById(runtime);
253
266
  if (!rt.args) {
@@ -359,7 +372,7 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
359
372
  /** One line of the child's stdout, in whichever dialect it speaks. */
360
373
  const onLine = (line) => {
361
374
  if (!rt.parse)
362
- return handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult });
375
+ return handleStreamLine(line, { cwd, emit, onActivity, appendText, answerFromResult, onInit });
363
376
  const ev = rt.parse(line, cwd);
364
377
  if (!ev) return;
365
378
  // The conversation id, when the runtime announces one (codex's
@@ -1,5 +1,6 @@
1
1
  /** Parsed configuration: env vars, CLI flags, and the chosen credentials. */
2
2
 
3
+ import { randomBytes } from 'node:crypto';
3
4
  import { readFileSync } from 'node:fs';
4
5
  import { dirname, join } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
@@ -168,6 +169,21 @@ export const ALLOW_PATCHES =
168
169
  // them (Node's default UA is treated as a bot). Claude Code sends its own UA.
169
170
  export const USER_AGENT = `flowviant/${VERSION}`;
170
171
 
172
+ /**
173
+ * WHICH DAEMON PROCESS this is — not which credential.
174
+ *
175
+ * Two daemons legitimately share one fleet token (that is exactly what
176
+ * `machineDaemonsDisagree` reports, and the instance lock added in 0.51.2
177
+ * cannot see a peer OLDER than itself). So the token cannot identify a lease
178
+ * holder, and anything that must be done exactly once needs this instead.
179
+ *
180
+ * Regenerated every start, deliberately: a daemon that restarted is a daemon
181
+ * that lost whatever it was holding, and a stale lease should not follow it
182
+ * back. Sent on the poll as `di`; its ABSENCE is what an older daemon looks
183
+ * like, and the server hands preview work to nobody who cannot name themselves.
184
+ */
185
+ export const DAEMON_INSTANCE = randomBytes(12).toString('hex');
186
+
171
187
  // The ONE credential. `tokens` (FLOWVIANT_TOKEN / FLOWVIANT_TOKENS / --token /
172
188
  // --tokens) stood beside it and carried WORKER tokens into the pre-daemon loop;
173
189
  // that principal owns zero tools since dispatch was deleted, and the kind can no
package/bin/lib/fleet.mjs CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  USER_AGENT,
24
24
  MCP_URL,
25
25
  SAFE,
26
+ DAEMON_INSTANCE,
26
27
  POLL_SECONDS,
27
28
  MAX_CONCURRENT,
28
29
  IDLE_SECONDS,
@@ -73,11 +74,11 @@ import {
73
74
  } from './env.mjs';
74
75
  import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
75
76
  import { machineSnapshot } from './resources.mjs';
76
- import { detectRuntimes, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
77
+ import { detectRuntimes, knownSkills, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
77
78
  import { createWorkManager } from './work.mjs';
78
79
  import { scanLocalSessions } from './localSessions.mjs';
79
80
 
80
- async function fetchRoster(haveIds) {
81
+ async function fetchRoster(haveIds, livePreviewSessionIds = []) {
81
82
  const url = new URL(FLEET_URL);
82
83
  if (haveIds.length) url.searchParams.set('have', haveIds.join(','));
83
84
  // What this machine will run at once. The server grows lanes to meet waiting
@@ -98,6 +99,15 @@ async function fetchRoster(haveIds) {
98
99
  // so a team can see whether the shared box runs wide open, and enforces
99
100
  // nothing (membership is the consent boundary). Older servers ignore it.
100
101
  url.searchParams.set('safe', SAFE ? '1' : '0');
102
+ // WHICH PROCESS, so the server can lease preview work to exactly one of two
103
+ // daemons on one credential. Older servers ignore unknown params.
104
+ url.searchParams.set('di', DAEMON_INSTANCE);
105
+ // Which shares this machine is still serving. It rides the poll rather than
106
+ // taking an endpoint of its own: one beat, no floor, and the stale window is
107
+ // the reconcile interval instead of minutes — which matters, because a share
108
+ // the server still calls live is a 530 on somebody's phone. Always set, even
109
+ // empty: '' means "serving none", absent would mean "an older daemon".
110
+ url.searchParams.set('pv', livePreviewSessionIds.join(','));
101
111
  // WHICH CLIs this machine actually has, so the app can stop guessing.
102
112
  //
103
113
  // Until now every surface that listed Gemini or Codex said "not wired up yet"
@@ -118,6 +128,21 @@ async function fetchRoster(haveIds) {
118
128
  } catch {
119
129
  /* detection is best-effort — a probe must never fail the poll */
120
130
  }
131
+ // WHAT THE CLI CAN BE ASKED FOR BY NAME, so the composer can autocomplete a
132
+ // `/` the way the terminal does. Learned from the init event of a turn we
133
+ // already ran (runtimes.mjs) — never probed, because spawning a CLI to fill a
134
+ // dropdown would spend the operator's quota on an affordance.
135
+ //
136
+ // NOT SENT until a turn has taught us: absent means "no turn has run here
137
+ // yet", and the app renders no menu rather than asserting this machine has no
138
+ // skills. An empty report, though, IS a fact and is sent as such — hence the
139
+ // null check rather than a truthiness check on the array.
140
+ try {
141
+ const skills = knownSkills();
142
+ if (skills !== null) url.searchParams.set('skills', skills.join(','));
143
+ } catch {
144
+ /* best-effort — the poll must never fail on a readout */
145
+ }
121
146
  // Env-sync identity + materialized version (the Settings "env vN" chip).
122
147
  try {
123
148
  for (const [k, v] of Object.entries(await envQueryParams())) {
@@ -359,6 +384,14 @@ export async function runFleetDaemon() {
359
384
  const nextByAgent = new Map();
360
385
  let leaseTtlSeconds = 24 * 60 * 60; // updated from each roster response
361
386
  let mcpUrl = MCP_URL;
387
+ // DEAD, and kept only because unpicking it is a rewire rather than a
388
+ // deletion: nothing calls `workers.set` anywhere in this tree. It held
389
+ // DISPATCH lanes, and the server has sent `agents: []` permanently since
390
+ // 2026-08-19, so every loop below iterates nothing. Two `stopPreview` calls
391
+ // hung off it until 2026-08-21 and read as live preview wiring; they were
392
+ // deleted, not rewired. When the Workbench preview lands, its teardown is
393
+ // keyed on sessionId and belongs beside retireWorkSessions in work.mjs —
394
+ // NOT here.
362
395
  const workers = new Map(); // agentId -> { state, promise, wt, label }
363
396
  let daemonAlive = true; // flipped false on shutdown so the stream stops reconnecting
364
397
  let stream = null; // push channel handle (set once the loop is set up)
@@ -398,15 +431,10 @@ export async function runFleetDaemon() {
398
431
  } catch {
399
432
  /* best-effort */
400
433
  }
401
- // Stop the detached preview (dev server + cloudflared tunnel) — it's its
402
- // own process group and survives our exit, otherwise leaking a port-bound
403
- // server + a live tunnel serving a stale branch until reboot.
404
- try {
405
- w.state.stopPreview?.();
406
- } catch {
407
- /* best-effort */
408
- }
409
434
  }
435
+ // Detached tunnels survive our exit by design, so leaving them would strand
436
+ // a public hostname until the box rebooted.
437
+ shutdownPreviews();
410
438
  };
411
439
  process.on('SIGINT', () => {
412
440
  console.log('');
@@ -578,6 +606,10 @@ export async function runFleetDaemon() {
578
606
  processWorkTurns,
579
607
  processShipJobs,
580
608
  processDiffJobs,
609
+ processPreviewJobs,
610
+ livePreviewIds,
611
+ retirePreviews,
612
+ shutdownPreviews,
581
613
  retireWorkSessions,
582
614
  reportWorktrees,
583
615
  shutdownWork,
@@ -1138,7 +1170,7 @@ export async function runFleetDaemon() {
1138
1170
  for (;;) {
1139
1171
  let roster;
1140
1172
  try {
1141
- roster = await fetchRoster(buildHave());
1173
+ roster = await fetchRoster(buildHave(), livePreviewIds());
1142
1174
  } catch (e) {
1143
1175
  if (e.auth) {
1144
1176
  fail(`${e.message} — credential revoked or invalid. Shutting down.`);
@@ -1200,11 +1232,20 @@ export async function runFleetDaemon() {
1200
1232
  // AFTER the work/ship intake: retirement is the server saying which
1201
1233
  // sessions are LIVE, and the guards above (chains, shipping) are populated
1202
1234
  // by the intake this same tick.
1235
+ // BEFORE retirement, and the order is load-bearing: `git worktree remove`
1236
+ // under a running dev server leaves it serving bytes from open file handles
1237
+ // in a directory that no longer exists — a human is shown the wrong thing
1238
+ // and nothing errors anywhere.
1239
+ retirePreviews(roster.activeWorkSessions);
1203
1240
  retireWorkSessions(roster.activeWorkSessions);
1204
1241
  // Diffs somebody has open and is waiting on. Project-scoped rather than
1205
1242
  // per-session: `git show` runs from the repo ROOT, which can see a closed
1206
1243
  // tab's branch and a shipped commit on main alike.
1207
1244
  processDiffJobs(roster.diffJobs);
1245
+ // Shares to open or tear down. CLAIMED before acted on — two daemons on one
1246
+ // credential are both handed this array, and both opening a tunnel strands
1247
+ // a public hostname nobody can settle.
1248
+ processPreviewJobs(roster.previewJobs);
1208
1249
  // …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
1209
1250
  // Throttled inside, never awaited — a `git status` the human cannot run
1210
1251
  // themselves from a browser, relayed. After retirement so a directory that
@@ -1308,11 +1349,6 @@ export async function runFleetDaemon() {
1308
1349
  } catch {
1309
1350
  /* best-effort */
1310
1351
  }
1311
- try {
1312
- w.state.stopPreview?.();
1313
- } catch {
1314
- /* best-effort */
1315
- }
1316
1352
  // Only poll mode's per-lane tree dies with the lane. A live lane owns no
1317
1353
  // checkout: the task it was building has its own, which must SURVIVE —
1318
1354
  // removing a lane requeues its task, and the next lane to pick that task