flowviant 0.55.2 → 0.56.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/cli.mjs CHANGED
@@ -237,13 +237,22 @@ if (process.argv[2] === 'env') {
237
237
  // auto-updated daemon's child sees two TTYs; without this it would stop on the
238
238
  // binding confirm below and the machine would stay dark until somebody typed a
239
239
  // key. Same reasoning as the headless case, and the same answer.
240
- const interactive =
241
- Boolean(process.stdin.isTTY && process.stdout.isTTY) && process.env.FLOWVIANT_REEXEC !== '1';
240
+ // `canPrompt()`, not a bare isTTY pair: a BACKGROUNDED job (`flowviant &`) has
241
+ // two TTYs and cannot be asked anything — the first read raises SIGTTIN and the
242
+ // kernel STOPS the process, which is why 0.55.2's timeout did not save it (a
243
+ // stopped process runs no timers). See tty.mjs.
244
+ const { canPrompt, askWithTimeout } = await import('./lib/tty.mjs');
245
+ const interactive = canPrompt() && process.env.FLOWVIANT_REEXEC !== '1';
242
246
 
243
247
  /** How long the one-time binding confirm waits before serving unbound. A person
244
248
  * who just typed `flowviant` answers in seconds; anything longer is a restart
245
249
  * nobody is watching, and the machine must not sit dark for it. */
246
250
  const CONFIRM_TIMEOUT_MS = 20_000;
251
+
252
+ /** The picker's own budget. Longer than the confirm: this one asks you to READ
253
+ * a list before answering, and its fallback costs you a start rather than
254
+ * costing you a binding. */
255
+ const PICK_TIMEOUT_MS = 60_000;
247
256
  const externalToken = process.argv.includes('--fleet') || Boolean(process.env.FLOWVIANT_FLEET);
248
257
 
249
258
  /** Re-exec a plain `flowviant` after an inline login — the login command's own
@@ -283,12 +292,22 @@ if (!FLEET_TOKEN) {
283
292
  );
284
293
  console.log(listLines(choices, creds));
285
294
  console.log(` ${choices.length + 1}. connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`);
286
- const rl = (await import('node:readline/promises')).createInterface({
287
- input: process.stdin,
288
- output: process.stdout,
289
- });
290
- const raw = (await rl.question(`Which project should this daemon serve? [1-${choices.length + 1}] `)).trim();
291
- rl.close();
295
+ // Bounded like the confirm below, and for the same reason — but silence
296
+ // means something DIFFERENT here and the difference is load-bearing. There
297
+ // is a real ambiguity to resolve; serving a guess is the skadooble bug.
298
+ // So no answer REFUSES, which is exactly what this branch already does
299
+ // headless, and the message says how to answer without being present.
300
+ const raw = await askWithTimeout(
301
+ `Which project should this daemon serve? [1-${choices.length + 1}] `,
302
+ PICK_TIMEOUT_MS
303
+ );
304
+ if (raw === null) {
305
+ console.error(
306
+ `\nno answer in ${Math.round(PICK_TIMEOUT_MS / 1000)}s — nothing started. ` +
307
+ `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
308
+ );
309
+ process.exit(1);
310
+ }
292
311
  const n = Number.parseInt(raw, 10);
293
312
  if (n === choices.length + 1) await reexecAfterLogin();
294
313
  const picked = Number.isInteger(n) ? choices[n - 1] : undefined;
@@ -346,26 +365,11 @@ if (!FLEET_TOKEN) {
346
365
  // which is the only person it could ever have been fixed for.
347
366
  const creds = await import('./lib/credentials.mjs');
348
367
  const label = creds.projectLabel(CREDENTIAL.entry);
349
- const rl = (await import('node:readline/promises')).createInterface({
350
- input: process.stdin,
351
- output: process.stdout,
352
- });
353
- const ac = new AbortController();
354
- const timer = setTimeout(() => ac.abort(), CONFIRM_TIMEOUT_MS);
355
- let raw = null; // null = nobody answered
356
- try {
357
- raw = (
358
- await rl.question(
359
- `This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `,
360
- { signal: ac.signal }
361
- )
362
- ).trim().toLowerCase();
363
- } catch {
364
- /* aborted — nobody is at this terminal */
365
- } finally {
366
- clearTimeout(timer);
367
- rl.close();
368
- }
368
+ const answered = await askWithTimeout(
369
+ `This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `,
370
+ CONFIRM_TIMEOUT_MS
371
+ );
372
+ const raw = answered === null ? null : answered.toLowerCase(); // null = nobody answered
369
373
  if (raw === null) {
370
374
  console.log(
371
375
  `\n no answer in ${Math.round(CONFIRM_TIMEOUT_MS / 1000)}s — serving ${label} for this run ` +
@@ -13,6 +13,30 @@
13
13
  * browser re-sends the cached Basic-auth header on same-origin upgrades, so HMR
14
14
  * still authenticates.
15
15
  *
16
+ * TWO DOORS SINCE 0.56.0. The password above is now the AUTOMATION path (curl,
17
+ * Playwright, a native mobile client); the default for a human is a Flowviant
18
+ * session. A cookie-less browser NAVIGATION is bounced to the app, which checks
19
+ * the visitor is signed in and on the project and hands back an HMAC grant this
20
+ * gate verifies offline (`grant.mjs`) before setting a cookie. Four rules that
21
+ * fall out of that and must survive any edit:
22
+ *
23
+ * - `/__fv/` IS RESERVED on this origin. The callback must be answered here,
24
+ * before the auth check (it is by definition the unauthenticated request
25
+ * that establishes authentication) and before forwarding (or the grant lands
26
+ * in the dev server's access log). The gate therefore stops being a pure
27
+ * pass-through, which is a deliberate, documented loss.
28
+ * - NEVER 302 A NON-NAVIGATION. A 302 is re-issued as GET and silently drops
29
+ * the body, so an unauthenticated POST from the previewed app would become a
30
+ * mystery GET instead of a visible 401. This is also exactly what keeps
31
+ * curl, Playwright and native clients on the password path.
32
+ * - NEVER 302 A WEBSOCKET UPGRADE. Browsers do not follow 3xx on an upgrade,
33
+ * they fail the connection — HMR would break in a way that looks like a dead
34
+ * dev server. A cookie-less upgrade stays a 401.
35
+ * - `SameSite=Lax` is chosen, so this gate CANNOT be embedded in a
36
+ * cross-site iframe. The Workbench must not try; making it work would mean
37
+ * `SameSite=None`, which is a different security posture and a conscious
38
+ * re-argument, not a quiet flag change.
39
+ *
16
40
  * Three things this file gets wrong easily, all of them fixed here and all of
17
41
  * them worth keeping fixed:
18
42
  * - the credential must NOT reach the origin. `headers: req.headers` forwarded
@@ -20,6 +44,9 @@
20
44
  * branch happens to be running. It is stripped now.
21
45
  * - the comparison is over a secret, so it is constant-time over a digest
22
46
  * rather than `===` over a string.
47
+ * - the grant cookie must be stripped from the forwarded request too, and
48
+ * ONLY ours: deleting the whole `cookie` header breaks the driver's own app,
49
+ * which legitimately owns its session cookies.
23
50
  * - `stop()` was a bare `server.close()`, which refuses NEW connections and
24
51
  * leaves live ones alone — so a held HMR websocket kept the page alive for
25
52
  * the one most-engaged viewer after teardown. Live sockets are tracked and
@@ -28,6 +55,7 @@
28
55
 
29
56
  import { createServer, request } from 'node:http';
30
57
  import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
58
+ import { GRANT_COOKIE, cookieValues, safePathname, safeRelative, stripCookie, verifyGrant } from './grant.mjs';
31
59
 
32
60
  /** Failed attempts before the proxy stops answering at all. A quick tunnel's
33
61
  * hostname is unguessable, so this is not the primary control — it is what
@@ -56,7 +84,14 @@ function sameSecret(a, b) {
56
84
  * `onAbuse` fires once, after MAX_FAILED rejected attempts, so the caller can
57
85
  * tear the whole share down rather than leaving a URL under attack.
58
86
  */
59
- export function startAuthProxy({ targetPort, log, onAbuse }) {
87
+ export function startAuthProxy({ targetPort, log, onAbuse, grantSecret, shareId, authorizeUrl }) {
88
+ // ALL THREE OR NONE. Two of the three is a gate that cannot bounce anybody:
89
+ // a secret with no authorize URL has nowhere to send them, an authorize URL
90
+ // with no secret cannot verify what comes back. An older SERVER sends none of
91
+ // them, and that degrades to exactly today's behaviour — password only.
92
+ // A MISSING SECRET MUST NEVER DEGRADE TO OPEN: the mandatory-gate invariant
93
+ // above is unchanged, and a null return still aborts the share.
94
+ const grants = Boolean(grantSecret && shareId && authorizeUrl);
60
95
  const user = 'preview';
61
96
  // 24 bytes → 32 url-safe chars. It was 9 bytes, chosen when this was an
62
97
  // opt-in convenience; it is the only thing between a public hostname and a
@@ -66,27 +101,60 @@ export function startAuthProxy({ targetPort, log, onAbuse }) {
66
101
 
67
102
  let failed = 0;
68
103
  let abused = false;
69
- const authed = (req) => {
70
- if (abused) return false;
104
+ /** The payload of the last validly-signed but EXPIRED grant seen, so a
105
+ * tester can be re-bounced with the token inside it. */
106
+ let lastExpired = null;
107
+
108
+ /**
109
+ * A PURE CREDENTIAL PREDICATE — no method, no Accept, no path. That is what
110
+ * lets the websocket upgrade handler reuse it verbatim, and it is why routing
111
+ * decisions live in the request handler instead.
112
+ *
113
+ * Tristate-plus: 'ok' | 'none' | 'expired' | 'forged' | 'badpass'.
114
+ */
115
+ const credential = (req) => {
116
+ if (abused) return 'badpass';
71
117
  if (sameSecret(req.headers['authorization'], expected)) {
72
118
  failed = 0;
73
- return true;
119
+ return 'ok';
74
120
  }
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 */
121
+ // ONLY A WRONG PASSWORD COUNTS AS AN ATTEMPT, and this is a reason rather
122
+ // than a preference. A forged HMAC is not brute-forceable, so counting it
123
+ // buys nothing while counting it would hand any stranger who finds the
124
+ // hostname a 25-request KILL SWITCH on the owner's share, because onAbuse
125
+ // tears the whole thing down. An expired-but-validly-signed grant must
126
+ // never count either, or a viewer who left a tab open overnight closes the
127
+ // share on their own reload. MAX_FAILED keeps its exact meaning, which is
128
+ // also why the 'abuse' ended-reason sentence stays true.
129
+ if (req.headers['authorization']) {
130
+ failed += 1;
131
+ if (failed >= MAX_FAILED && !abused) {
132
+ abused = true;
133
+ log?.(`preview gate: ${failed} failed attempts — closing the share.`);
134
+ try {
135
+ onAbuse?.();
136
+ } catch {
137
+ /* the caller's teardown is best-effort */
138
+ }
85
139
  }
140
+ return 'badpass';
86
141
  }
87
- return false;
142
+
143
+ if (!grants) return 'none';
144
+ // EVERY value for our name, not the first — a duplicate must not shadow.
145
+ for (const raw of cookieValues(req.headers.cookie, GRANT_COOKIE)) {
146
+ const r = verifyGrant(raw, { secret: grantSecret, shareId });
147
+ if (r.ok) return 'ok';
148
+ if (r.reason === 'exp') {
149
+ lastExpired = r.payload;
150
+ return 'expired';
151
+ }
152
+ }
153
+ return String(req.headers.cookie ?? '').includes(GRANT_COOKIE) ? 'forged' : 'none';
88
154
  };
89
155
 
156
+ const authed = (req) => credential(req) === 'ok';
157
+
90
158
  // The gate credential is OURS and stops here. Everything else is passed
91
159
  // through untouched: the origin is the driver's own dev server and rewriting
92
160
  // its request would be us editing their app's input.
@@ -94,6 +162,11 @@ export function startAuthProxy({ targetPort, log, onAbuse }) {
94
162
  const headers = { ...req.headers };
95
163
  delete headers.authorization;
96
164
  delete headers['proxy-authorization'];
165
+ // ONLY OURS. The driver's app owns its own cookies and breaks without them;
166
+ // our grant is a signed bearer token and must not reach branch code.
167
+ const rest = stripCookie(headers.cookie, GRANT_COOKIE);
168
+ if (rest) headers.cookie = rest;
169
+ else delete headers.cookie; // never send a bare empty `cookie:`
97
170
  return {
98
171
  host: '127.0.0.1',
99
172
  port: targetPort,
@@ -111,14 +184,84 @@ export function startAuthProxy({ targetPort, log, onAbuse }) {
111
184
  // in a cache the viewer cannot see.
112
185
  'Cache-Control': 'no-store',
113
186
  });
114
- res.end('This preview is password-protected. Enter the password shown in Flowviant.');
187
+ res.end(
188
+ grants
189
+ ? 'This preview needs a Flowviant session, or the automation password shown in Flowviant.'
190
+ : 'This preview is password-protected. Enter the password shown in Flowviant.'
191
+ );
115
192
  };
116
193
 
117
194
  // Every live socket, so stop() can actually end the ones already talking.
118
195
  const sockets = new Set();
119
196
 
197
+ /** A top-level browser navigation, and nothing else. A 302 is re-issued as
198
+ * GET and drops the body, so bouncing a POST would turn an unauthenticated
199
+ * write into a mystery GET; and this is what keeps curl and native clients
200
+ * on the password path. */
201
+ const isBrowserNav = (req) =>
202
+ (req.method === 'GET' || req.method === 'HEAD') &&
203
+ /text\/html/.test(req.headers.accept || '');
204
+
205
+ const noStore = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };
206
+
207
+ /** Send them to the app to be vouched for. The gate never names its own
208
+ * hostname: it sends the share id, and the SERVER builds the absolute
209
+ * callback from the URL it already stored — so the app never trusts a
210
+ * hostname supplied by the tunnel side. */
211
+ const bounce = (req, res, verdict) => {
212
+ const u = new URL(authorizeUrl);
213
+ u.searchParams.set('s', shareId);
214
+ u.searchParams.set('to', safeRelative(req.url));
215
+ // Re-bounce a tester with the token that was inside their expired grant —
216
+ // they no longer hold the original link, and the server re-checks the hash
217
+ // ONLINE, which is what makes a tester link revocable at all.
218
+ if (verdict === 'expired' && lastExpired?.k === 't' && lastExpired?.r) {
219
+ u.searchParams.set('t', String(lastExpired.r));
220
+ u.searchParams.set('x', '1');
221
+ } else if (verdict === 'expired') {
222
+ u.searchParams.set('x', '1');
223
+ }
224
+ res.writeHead(302, { Location: u.toString(), ...noStore });
225
+ res.end();
226
+ };
227
+
228
+ /** The callback. Answered ENTIRELY here — it never touches the origin. */
229
+ const handleCallback = (req, res) => {
230
+ const u = new URL(req.url, 'http://x');
231
+ const r = verifyGrant(u.searchParams.get('g'), { secret: grantSecret, shareId });
232
+ // NOT a redirect: bouncing a failed callback back to the app is how you
233
+ // build an infinite loop out of a clock skew.
234
+ if (!r.ok) return challenge(res);
235
+ const to = safeRelative(u.searchParams.get('to'));
236
+ const maxAge = Math.max(0, r.payload.exp - Math.floor(Date.now() / 1000));
237
+ // The immediate 302 to a clean path is MANDATORY, not cosmetic: it takes
238
+ // `?g=` out of the address bar, out of the Referer every subresource would
239
+ // carry, and out of browser history. It cannot take it out of cloudflared's
240
+ // access log — which is why the grant is short-lived and share-bound.
241
+ res.writeHead(302, {
242
+ 'Set-Cookie': `${GRANT_COOKIE}=${r.raw}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`,
243
+ Location: to,
244
+ ...noStore,
245
+ });
246
+ res.end();
247
+ };
248
+
120
249
  const server = createServer((req, res) => {
121
- if (!authed(req)) return challenge(res);
250
+ const path = safePathname(req.url);
251
+ // 1. The callback, BEFORE the auth check and BEFORE any forwarding.
252
+ if (grants && path === '/__fv/cb') return handleCallback(req, res);
253
+ // 2. Reserve the prefix so nothing under it is ever proxied.
254
+ if (path.startsWith('/__fv/')) {
255
+ res.writeHead(404, { 'Content-Type': 'text/plain', ...noStore });
256
+ res.end('not found');
257
+ return;
258
+ }
259
+ // 3. One predicate.
260
+ const verdict = credential(req);
261
+ if (verdict !== 'ok') {
262
+ if (grants && verdict !== 'badpass' && isBrowserNav(req)) return bounce(req, res, verdict);
263
+ return challenge(res);
264
+ }
122
265
  const proxyReq = request(forwardOpts(req), (proxyRes) => {
123
266
  res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
124
267
  proxyRes.pipe(res);
@@ -138,6 +281,16 @@ export function startAuthProxy({ targetPort, log, onAbuse }) {
138
281
  // WS upgrade (HMR). The browser resends the Basic-auth header on same-origin
139
282
  // upgrades, so we gate it too, then pipe the two sockets together.
140
283
  server.on('upgrade', (req, socket, head) => {
284
+ // Nothing under the reserved prefix is ever piped to the origin.
285
+ if (safePathname(req.url).startsWith('/__fv/')) {
286
+ socket.destroy();
287
+ return;
288
+ }
289
+ // NEVER 302 HERE — browsers fail an upgrade rather than following a 3xx, so
290
+ // a bounce would read as a dead dev server. The cookie IS sent on a
291
+ // same-origin handshake, so `authed` works unchanged; a cookie-less upgrade
292
+ // stays a 401 and the page's HMR client reconnects once the human has
293
+ // re-authenticated in the main document.
141
294
  if (!authed(req)) {
142
295
  socket.write('HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="Flowviant preview"\r\n\r\n');
143
296
  socket.destroy();
@@ -171,6 +324,9 @@ export function startAuthProxy({ targetPort, log, onAbuse }) {
171
324
  port,
172
325
  user,
173
326
  password,
327
+ // What was ACTUALLY installed, reported back so the app never asserts a
328
+ // door nobody observed.
329
+ gateMode: grants ? 'grant' : 'password',
174
330
  stop: () => {
175
331
  try {
176
332
  server.close();
@@ -0,0 +1,182 @@
1
+ /**
2
+ * THE GATE'S HALF OF THE GRANT — verify only. This file never mints.
3
+ *
4
+ * A shared preview is served AROUND Flowviant: cloudflared → the gate in
5
+ * `authproxy.mjs` → the dev server the driver started. When the gate meets a
6
+ * browser with no cookie it bounces to the app, which checks the visitor is
7
+ * signed in and on the project, and hands back an HMAC grant signed with the
8
+ * per-share secret the roster gave this machine. The gate verifies that grant
9
+ * HERE, offline, and sets a cookie.
10
+ *
11
+ * OFFLINE IS THE WHOLE DESIGN. The daemon is a pull client with no
12
+ * server→daemon request path, and the gate is listening before cloudflared
13
+ * spawns — so a gate that had to reach api.flowviant.com to answer its first
14
+ * request would either delay the tunnel or open a window where the public
15
+ * hostname is un-gated. It also means a network blip cannot turn somebody's
16
+ * working preview into a 502.
17
+ *
18
+ * THIS FILE IS ONE HALF OF A WIRE CONTRACT. The other half is
19
+ * `apps/api/src/lib/agent-runner/previewGrant.ts` in the Flowviant repo, and
20
+ * the two are tested against the same vectors. A change here that the server
21
+ * does not follow shows up as a preview nobody can open.
22
+ *
23
+ * NO CANONICAL-ENCODING CONTRACT, deliberately: the signature covers the
24
+ * RECEIVED payload string bytes and the JSON is parsed only afterwards. Field
25
+ * order, whitespace and key set are therefore irrelevant to verification, which
26
+ * removes an entire failure mode — an unbootstrappable redirect loop caused by
27
+ * two languages disagreeing about how to serialise an object.
28
+ */
29
+
30
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
31
+
32
+ export const GRANT_VERSION = 1;
33
+ const DOMAIN = `fvgrant.v${GRANT_VERSION}.`;
34
+
35
+ /** `__Host-` is required rather than decorative: every quick tunnel is a
36
+ * hostname under the shared `trycloudflare.com` parent, so without the prefix
37
+ * a neighbour with their own tunnel could set a Domain-scoped cookie the
38
+ * browser would also send to ours. The prefix makes the browser refuse any
39
+ * Domain attribute and forces host-only + Secure + Path=/. */
40
+ export const GRANT_COOKIE = '__Host-fv_grant';
41
+
42
+ /** Tolerance past `exp`, one direction only. Absorbs ordinary clock drift
43
+ * between the Worker and this box. */
44
+ export const GRANT_SKEW_MS = 60_000;
45
+
46
+ const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
47
+
48
+ /** Constant-time over sha256 digests, so length never leaks and a missing
49
+ * value costs the same as a wrong one. Never `===` on a signature. */
50
+ function sameDigest(a, b) {
51
+ try {
52
+ const da = createHash('sha256').update(String(a ?? '')).digest();
53
+ const db = createHash('sha256').update(String(b ?? '')).digest();
54
+ return timingSafeEqual(da, db);
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Verify a grant against this share's secret.
62
+ *
63
+ * Returns `{ ok: true, raw, payload }`, or `{ ok: false, reason, payload? }`
64
+ * where reason is one of empty | shape | sig | version | share | exp. The
65
+ * payload rides back on an EXPIRY (and only after the signature checked out) so
66
+ * the caller can re-bounce a tester using the token inside it.
67
+ */
68
+ export function verifyGrant(raw, { secret, shareId, nowMs, skewMs } = {}) {
69
+ // An empty secret is refused BEFORE anything is hashed. Every share created
70
+ // before this feature has none, and null must read as "no SSO gate here",
71
+ // never as "the empty key".
72
+ if (!raw || !secret) return { ok: false, reason: 'empty' };
73
+ const s = String(raw);
74
+ const dot = s.indexOf('.');
75
+ if (dot <= 0 || dot === s.length - 1) return { ok: false, reason: 'shape' };
76
+ const body = s.slice(0, dot);
77
+ const sig = s.slice(dot + 1);
78
+ if (!/^[A-Za-z0-9_-]+$/.test(body) || !/^[A-Za-z0-9_-]+$/.test(sig)) {
79
+ return { ok: false, reason: 'shape' };
80
+ }
81
+
82
+ const expected = b64url(createHmac('sha256', String(secret)).update(DOMAIN + body).digest());
83
+ if (!sameDigest(sig, expected)) return { ok: false, reason: 'sig' };
84
+
85
+ let payload;
86
+ try {
87
+ payload = JSON.parse(Buffer.from(body.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
88
+ } catch {
89
+ return { ok: false, reason: 'shape' };
90
+ }
91
+ if (!payload || typeof payload !== 'object') return { ok: false, reason: 'shape' };
92
+ if (payload.v !== GRANT_VERSION) return { ok: false, reason: 'version' };
93
+ // Binds the grant to THIS share. One machine may serve several; a grant for
94
+ // one must not open another.
95
+ if (payload.s !== shareId) return { ok: false, reason: 'share' };
96
+
97
+ const now = nowMs ?? Date.now();
98
+ const skew = skewMs ?? GRANT_SKEW_MS;
99
+ if (typeof payload.exp !== 'number' || now > payload.exp * 1000 + skew) {
100
+ return { ok: false, reason: 'exp', payload };
101
+ }
102
+ return { ok: true, raw: s, payload };
103
+ }
104
+
105
+ /**
106
+ * EVERY value for `name` in a Cookie header, not just the first.
107
+ *
108
+ * A `__Host-` cookie cannot be shadowed by a Domain-scoped one, but a page on
109
+ * the origin can still call `document.cookie` and a malformed header can carry
110
+ * a duplicate. Checking only the first match would let a planted value shadow
111
+ * the real one and lock the viewer out; checking all of them cannot.
112
+ */
113
+ export function cookieValues(header, name) {
114
+ if (!header) return [];
115
+ const out = [];
116
+ for (const part of String(header).split(';')) {
117
+ const eq = part.indexOf('=');
118
+ if (eq < 0) continue;
119
+ if (part.slice(0, eq).trim() !== name) continue;
120
+ out.push(part.slice(eq + 1).trim());
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Drop ONLY our cookie from a Cookie header, preserving everything else.
127
+ *
128
+ * The whole header must not be deleted: the driver's own app legitimately owns
129
+ * its session cookies and would break. And ours must not be forwarded: a signed
130
+ * grant handed to whatever code the branch happens to be running is a bearer
131
+ * token given to the untrusted side of this boundary — the same bug the
132
+ * `authorization` strip already encodes, and that one shipped once.
133
+ *
134
+ * Returns the remaining header, or '' when nothing is left (the caller must
135
+ * then delete the header rather than send an empty one).
136
+ */
137
+ export function stripCookie(header, name) {
138
+ if (!header) return '';
139
+ const kept = String(header)
140
+ .split(';')
141
+ .filter((part) => {
142
+ const eq = part.indexOf('=');
143
+ const key = eq < 0 ? part.trim() : part.slice(0, eq).trim();
144
+ return key !== name;
145
+ })
146
+ .map((p) => p.trim())
147
+ .filter(Boolean);
148
+ return kept.join('; ');
149
+ }
150
+
151
+ /**
152
+ * The pathname of a request, PARSED rather than prefix-matched.
153
+ *
154
+ * `req.url.startsWith('/__fv/cb')` also matches `/__fv/cbXYZ`, and an equality
155
+ * test against `req.url` misses a reordered query string, a bare path with no
156
+ * query, and a fragment. Both mistakes are the difference between the callback
157
+ * being handled and being proxied to somebody's dev server with the grant in
158
+ * its access log.
159
+ */
160
+ export function safePathname(url) {
161
+ try {
162
+ return new URL(String(url ?? '/'), 'http://x').pathname;
163
+ } catch {
164
+ return '/';
165
+ }
166
+ }
167
+
168
+ /**
169
+ * A destination that cannot be read as an origin.
170
+ *
171
+ * `//evil.com` and `/\evil.com` are protocol-relative to a browser, so a naive
172
+ * "starts with /" check turns the callback into an open redirect on the tunnel
173
+ * origin. Anything that is not a single-slash relative path becomes '/'.
174
+ */
175
+ export function safeRelative(raw) {
176
+ if (!raw) return '/';
177
+ const s = String(raw);
178
+ if (s.length > 512) return '/';
179
+ if (/[\r\n]/.test(s)) return '/';
180
+ if (!/^\/(?![/\\])/.test(s)) return '/';
181
+ return s;
182
+ }
@@ -27,17 +27,23 @@ export function addLocalBinToPath() {
27
27
  }
28
28
  }
29
29
 
30
- /** TTY-guarded y/N. Non-interactive (no TTY) never auto-installs → returns false
31
- * so a headless/cron run just prints the manual instructions instead. */
30
+ /** TTY-guarded y/N. Non-interactive never auto-installs → returns false so a
31
+ * headless/cron run just prints the manual instructions instead.
32
+ *
33
+ * `canPrompt()` rather than `stdin.isTTY`, and BOUNDED: preflight runs before
34
+ * the daemon serves anything, so a question here that cannot be answered is a
35
+ * machine that never starts. A backgrounded job has a TTY and cannot be asked
36
+ * — the read raises SIGTTIN and the kernel stops the process. Silence is `no`,
37
+ * which is already what this returns when nobody is there. */
32
38
  export async function promptYesNo(question, defaultYes) {
33
- if (!process.stdin.isTTY) return false;
34
- const { createInterface } = await import('node:readline');
35
- const rl = createInterface({ input: process.stdin, output: process.stdout });
36
- const answer = await new Promise((res) =>
37
- rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `, res),
39
+ const { canPrompt, askWithTimeout } = await import('./tty.mjs');
40
+ if (!canPrompt()) return false;
41
+ const answer = await askWithTimeout(
42
+ `${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `,
43
+ 30_000
38
44
  );
39
- rl.close();
40
- const a = answer.trim().toLowerCase();
45
+ if (answer === null) return false;
46
+ const a = answer.toLowerCase();
41
47
  if (!a) return defaultYes;
42
48
  return a === 'y' || a === 'yes';
43
49
  }
@@ -311,7 +311,7 @@ const TAIL_BYTES = 2000;
311
311
  /**
312
312
  * Gate `port` behind a password and publish it on a quick tunnel.
313
313
  *
314
- * Resolves { url, user, password, stop } on success, or { error } — a sentence
314
+ * Resolves { url, user, password, gateMode, stop } on success, or { error } — a sentence
315
315
  * from this machine, to be relayed as-is. It never resolves a URL without a
316
316
  * password, and it never returns a tunnel whose origin was not listening when
317
317
  * we checked.
@@ -348,6 +348,11 @@ export async function openTunnel({
348
348
  onTunnelGone,
349
349
  stillServing,
350
350
  probeMs = 20_000,
351
+ // The members-gate triple, all three or none. Absent = an older server, or a
352
+ // password-mode share: the gate runs exactly as it always has.
353
+ grantSecret,
354
+ shareId,
355
+ authorizeUrl,
351
356
  }) {
352
357
  // Re-validate at the machine. The server checked this port against the last
353
358
  // report; reports are up to a minute old and a dev server is a process a
@@ -392,6 +397,13 @@ export async function openTunnel({
392
397
  gate = await startAuthProxy({
393
398
  targetPort: port,
394
399
  log,
400
+ // Never logged, never written to previews.json, never in the reap
401
+ // signature, never in argv or a child env — /proc/<pid>/cmdline is
402
+ // world-readable and this box also runs the driver's dev server and every
403
+ // CLI turn. In-process only.
404
+ grantSecret,
405
+ shareId,
406
+ authorizeUrl,
395
407
  onAbuse: () => {
396
408
  stop();
397
409
  try {
@@ -483,7 +495,7 @@ export async function openTunnel({
483
495
  }
484
496
  });
485
497
 
486
- finish({ url: m[0], user: gate.user, password: gate.password, stop });
498
+ finish({ url: m[0], user: gate.user, password: gate.password, gateMode: gate.gateMode, stop });
487
499
  };
488
500
 
489
501
  tunnel.stdout.on('data', onOut);
@@ -0,0 +1,111 @@
1
+ /**
2
+ * NO PROMPT ON THE START PATH MAY STOP THE DAEMON.
3
+ *
4
+ * This exists because 0.55.2 added a 20-second timeout to the repo-binding
5
+ * confirm and it did not work in the one case that mattered most. `flowviant &`
6
+ * from an interactive shell puts the process in a background process group; the
7
+ * first TTY read raises SIGTTIN (and a TTY write SIGTTOU), whose DEFAULT
8
+ * disposition is to STOP the process. A stopped process runs no timers, so the
9
+ * AbortController never fires — the guard was on the wrong side of the thing it
10
+ * was guarding against. The shell prints `[1]+ Stopped` and nothing else: no
11
+ * banner, no error, no poll, forever, and `bg` does not rescue it.
12
+ *
13
+ * Two independent defences, because either one alone has a hole:
14
+ *
15
+ * 1. `canPrompt()` — do not ask at all unless we are the terminal's FOREGROUND
16
+ * process group. `stdin.isTTY` is true for a backgrounded job, so it cannot
17
+ * answer this on its own; the foreground group is what actually decides
18
+ * whether a read will succeed or be signalled.
19
+ *
20
+ * 2. `askWithTimeout()` — while asking, install no-op SIGTTIN/SIGTTOU handlers.
21
+ * A handler (even an empty one) replaces the default STOP, so a misjudged
22
+ * foreground check degrades to a read that fails or hangs — and a hang is
23
+ * something the timer can now actually interrupt, because the process is
24
+ * still running.
25
+ *
26
+ * The detection is best-effort by design and FAILS TOWARDS ASKING: an unknown
27
+ * platform returns true, because refusing to prompt a human who IS there is a
28
+ * worse failure than a prompt that times out on its own.
29
+ */
30
+
31
+ import { readFileSync } from 'node:fs';
32
+ import { execFileSync } from 'node:child_process';
33
+
34
+ /**
35
+ * Is this process in the controlling terminal's foreground process group?
36
+ *
37
+ * Linux: BOTH numbers come out of one `/proc/self/stat` read — field 5 is our
38
+ * own `pgrp` and field 8 is `tpgid`, the foreground group of our controlling
39
+ * terminal. Parsed from AFTER the last ')' because field 2 is the executable
40
+ * name and may itself contain spaces and parentheses.
41
+ *
42
+ * NOT `process.getpgrp()`: it DOES NOT EXIST in Node (verified on 24.16 — it
43
+ * throws `TypeError: process.getpgrp is not a function`). The first cut of this
44
+ * file called it, the throw was swallowed by the fail-open catch below, and the
45
+ * function therefore returned `true` unconditionally — a detector that always
46
+ * says "yes, a human is here" is not a detector, and only the signal handlers
47
+ * in `askWithTimeout` were doing any work. Caught by probing this function in
48
+ * isolation on a real pty; nothing else would have shown it, because the
49
+ * fallback it degraded to still behaves acceptably.
50
+ *
51
+ * Elsewhere: ask `ps` for both. Unknown: assume foreground (see the header).
52
+ */
53
+ export function inForeground() {
54
+ try {
55
+ if (process.platform === 'linux') {
56
+ const stat = readFileSync('/proc/self/stat', 'utf8');
57
+ const after = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/);
58
+ const pgrp = Number(after[2]); // state ppid PGRP session tty_nr tpgid
59
+ const tpgid = Number(after[5]);
60
+ if (!Number.isFinite(tpgid) || !Number.isFinite(pgrp)) return true;
61
+ if (tpgid <= 0) return true; // no controlling terminal — nothing to be behind
62
+ return tpgid === pgrp;
63
+ }
64
+ const out = execFileSync('ps', ['-o', 'tpgid=,pgid=', '-p', String(process.pid)], {
65
+ encoding: 'utf8',
66
+ stdio: ['ignore', 'pipe', 'ignore'],
67
+ timeout: 2000,
68
+ }).trim().split(/\s+/);
69
+ const tpgid = Number(out[0]);
70
+ const pgrp = Number(out[1]);
71
+ if (!Number.isFinite(tpgid) || !Number.isFinite(pgrp) || tpgid <= 0) return true;
72
+ return tpgid === pgrp;
73
+ } catch {
74
+ return true;
75
+ }
76
+ }
77
+
78
+ /** A human is at this terminal AND can actually be reached by a question. */
79
+ export function canPrompt() {
80
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY) && inForeground();
81
+ }
82
+
83
+ /**
84
+ * Ask, and come back no matter what. Resolves the trimmed answer, or `null`
85
+ * when nobody answered within `timeoutMs` — the caller decides what silence
86
+ * means, because it is not the same answer everywhere (the binding confirm
87
+ * serves unbound; the project picker refuses, exactly as it does headless).
88
+ */
89
+ export async function askWithTimeout(query, timeoutMs) {
90
+ const noop = () => {};
91
+ // Replacing the DEFAULT disposition is the whole point — an empty handler is
92
+ // enough, and it is what keeps the timer below able to run at all.
93
+ process.on('SIGTTIN', noop);
94
+ process.on('SIGTTOU', noop);
95
+ const rl = (await import('node:readline/promises')).createInterface({
96
+ input: process.stdin,
97
+ output: process.stdout,
98
+ });
99
+ const ac = new AbortController();
100
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
101
+ try {
102
+ return (await rl.question(query, { signal: ac.signal })).trim();
103
+ } catch {
104
+ return null; // aborted, or the read failed because we are not in front
105
+ } finally {
106
+ clearTimeout(timer);
107
+ rl.close();
108
+ process.off('SIGTTIN', noop);
109
+ process.off('SIGTTOU', noop);
110
+ }
111
+ }
package/bin/lib/work.mjs CHANGED
@@ -519,8 +519,34 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
519
519
  }
520
520
 
521
521
  if (!Number.isInteger(port) || port <= 0 || port > 65535) continue;
522
+
523
+ // VALIDATE THE MEMBERS-GATE TRIPLE AT THE BOUNDARY, the way sessionId and
524
+ // port already are — one place doing the check is one deploy away from
525
+ // being zero places. A malformed value is DROPPED rather than errored and
526
+ // the share opens password-only: a gate is never degraded to open, but it
527
+ // is also never left un-opened over a field we could not read.
528
+ const secret = /^[A-Za-z0-9_-]{32,128}$/.test(String(job?.secret ?? ''))
529
+ ? String(job.secret)
530
+ : null;
531
+ const shareId = isSafePathSegment(String(job?.shareId ?? '')) ? String(job.shareId) : null;
532
+ let authorizeUrl = null;
533
+ try {
534
+ const u = new URL(String(job?.authorizeUrl ?? ''));
535
+ if (u.protocol === 'https:') authorizeUrl = u.toString();
536
+ } catch {
537
+ /* not a URL — password-only, which is honest */
538
+ }
539
+ // All three or none: two of the three is a gate that cannot bounce.
540
+ const gateOk = Boolean(secret && shareId && authorizeUrl);
541
+
522
542
  // Already serving exactly this. Re-opening would replace a working URL
523
543
  // somebody may be looking at right now.
544
+ //
545
+ // NOTE this key is (session, port) and NOT the secret. Rotating a secret
546
+ // under a LIVE share is deliberately unsupported: `requestPreview`
547
+ // early-returns on a live row of the same port, so a new secret only ever
548
+ // arrives with a genuinely new row, by which time this map has been
549
+ // cleared. Anyone adding rotation must widen the key first.
524
550
  if (livePreviews.get(sessionId)?.port === port) continue;
525
551
  if (previewClaiming.has(sessionId)) continue;
526
552
  previewClaiming.add(sessionId);
@@ -566,6 +592,7 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
566
592
  // a bare TCP probe green, and the share's URL+password would serve
567
593
  // a worktree nobody consented to publish.
568
594
  stillServing: async () => listenersIn(wt).some((l) => l.port === port),
595
+ ...(gateOk ? { grantSecret: secret, shareId, authorizeUrl } : {}),
569
596
  // The gate closed itself after repeated failed passwords. Stored,
570
597
  // so the incident is visible — and the entry is dropped so the
571
598
  // owner can re-share the port without restarting the daemon.
@@ -588,7 +615,15 @@ export function createWorkManager({ repoRoot, baseDir, baseRef, getMcpUrl, getLe
588
615
  return;
589
616
  }
590
617
  livePreviews.set(sessionId, { port, url: t.url, stop: t.stop });
591
- await postPreview({ sessionId, url: t.url, user: t.user, password: t.password });
618
+ // The gate we ACTUALLY installed, so the app never asserts a door
619
+ // nobody observed. An older server ignores the field.
620
+ await postPreview({
621
+ sessionId,
622
+ url: t.url,
623
+ user: t.user,
624
+ password: t.password,
625
+ gate: t.gateMode,
626
+ });
592
627
  } finally {
593
628
  previewClaiming.delete(sessionId);
594
629
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.55.2",
3
+ "version": "0.56.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,12 +8,16 @@
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
+ "!bin/lib/*.test.mjs",
11
12
  "README.md",
12
13
  "LICENSE"
13
14
  ],
14
15
  "engines": {
15
16
  "node": ">=20"
16
17
  },
18
+ "scripts": {
19
+ "test": "node --test bin/lib/*.test.mjs"
20
+ },
17
21
  "dependencies": {
18
22
  "@anthropic-ai/claude-agent-sdk": "^0.3.0",
19
23
  "libsodium-wrappers-sumo": "^0.8.4",