flowviant 0.55.3 → 0.56.1

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.
@@ -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,113 @@
1
+ /**
2
+ * THE ENVIRONMENT A SPAWNED COMMAND GETS — and, more to the point, what it does
3
+ * not get.
4
+ *
5
+ * WHY THIS FILE EXISTS: `deploy.mjs` carried
6
+ *
7
+ * const env = { ...process.env, ...deployCreds() };
8
+ * delete env.FLEET_TOKEN; // ... keep it out of a command that might echo its env
9
+ *
10
+ * and that `delete` was a LIVE NO-OP. `FLEET_TOKEN` is a JS module constant in
11
+ * `config.mjs`; the environment variable is `FLOWVIANT_FLEET`. So the machine
12
+ * credential sat in the environment of every deploy command — including
13
+ * `target.build`, which is a string the REPO controls — under a confident
14
+ * comment saying it did not. That is the whole argument for an allowlist: a
15
+ * denylist is a claim about a set you cannot see, and it rots into a lie the
16
+ * moment one identifier is wrong or one new secret is added upstream.
17
+ *
18
+ * SO: BUILT FROM `{}`, NEVER FROM `{...process.env}` MINUS NAMES. Anything not
19
+ * named below is absent by construction, and a secret added to the daemon's
20
+ * environment next year is absent without anybody remembering this file.
21
+ *
22
+ * WHAT THIS IS NOT. A spawned command runs as the SAME UID as the daemon.
23
+ * `~/.flowviant/credentials.json` and `~/.flowviant/env-keypair.json` are 0600
24
+ * and readable by it. This is a control against ACCIDENT AND INHERITANCE — a
25
+ * crash reporter, a framework error page that dumps `process.env`, a build log,
26
+ * a process that echoes its own environment — and it is NOT confinement. Real
27
+ * confinement is a separate uid or a namespace and is not in this product. No
28
+ * surface may describe anything here as "sandboxed" or "isolated".
29
+ */
30
+
31
+ /**
32
+ * The complete kept set. Two groups, and both are here for a reason that bit
33
+ * somebody:
34
+ *
35
+ * - the basics a process needs to exist at all;
36
+ * - the TOOLCHAIN SHIMS. An operator on nvm, asdf, volta, pnpm or bun has a
37
+ * PATH that points into a version-manager directory, and without these the
38
+ * PATH we hand over resolves to nothing — the command fails with ENOENT and
39
+ * the failure looks like a bad command rather than a stripped environment.
40
+ */
41
+ const KEEP = [
42
+ 'PATH',
43
+ 'HOME',
44
+ 'USER',
45
+ 'LOGNAME',
46
+ 'SHELL',
47
+ 'TZ',
48
+ 'LANG',
49
+ 'LC_ALL',
50
+ 'TMPDIR',
51
+ // Toolchain shims.
52
+ 'NVM_DIR',
53
+ 'NVM_BIN',
54
+ 'ASDF_DIR',
55
+ 'ASDF_DATA_DIR',
56
+ 'VOLTA_HOME',
57
+ 'PNPM_HOME',
58
+ 'BUN_INSTALL',
59
+ 'N_PREFIX',
60
+ 'XDG_CACHE_HOME',
61
+ 'XDG_DATA_HOME',
62
+ ];
63
+
64
+ /**
65
+ * A child environment for a command Flowviant runs on the operator's behalf.
66
+ *
67
+ * `extra` is layered LAST and is the caller's own material — deploy
68
+ * credentials, or nothing. It is never repo-supplied: the deleted preview
69
+ * feature let `.flowviant/preview.json` contribute an `env` map that was
70
+ * layered last and therefore won every collision, which is how a branch got to
71
+ * set `PATH`.
72
+ */
73
+ export function childEnv({ cwd, extra } = {}) {
74
+ const env = {};
75
+ for (const k of KEEP) {
76
+ if (typeof process.env[k] === 'string') env[k] = process.env[k];
77
+ }
78
+ // Set by us rather than inherited.
79
+ //
80
+ // TERM=dumb: a process that believes it owns a TTY draws progress bars and
81
+ // spinners into a pipe forever, which is unreadable in a log tail and pins a
82
+ // CPU on some tools.
83
+ env.TERM = 'dumb';
84
+ // BROWSER=none: nothing should try to open a browser on a headless box. This
85
+ // is the one survivor of the deleted feature's env extras, and it is
86
+ // anti-annoyance rather than security.
87
+ env.BROWSER = 'none';
88
+ if (cwd) env.PWD = cwd;
89
+ // Deliberately NOT set: NODE_ENV (asserting 'development' would be Flowviant
90
+ // choosing what the framework should decide) and PORT (we never hint a port —
91
+ // the port is DISCOVERED by cwd attribution, and a hinted one has no
92
+ // attribution behind it).
93
+ return extra ? { ...env, ...extra } : env;
94
+ }
95
+
96
+ /** The names this deliberately drops, for the test to assert against. NOT the
97
+ * mechanism — the mechanism is the allowlist above, and this list is only ever
98
+ * a sample of what it excludes. Adding a name here changes nothing. */
99
+ export const DROPPED_SAMPLE = [
100
+ 'FLOWVIANT_FLEET',
101
+ 'FLOWVIANT_FLEET_URL',
102
+ 'FLOWVIANT_API_URL',
103
+ 'FLOWVIANT_MCP_URL',
104
+ 'FLOWVIANT_MODEL',
105
+ 'ANTHROPIC_API_KEY',
106
+ 'ANTHROPIC_AUTH_TOKEN',
107
+ 'GH_TOKEN',
108
+ 'GITHUB_TOKEN',
109
+ 'SSH_AUTH_SOCK',
110
+ 'CLOUDFLARE_API_TOKEN',
111
+ 'AWS_SECRET_ACCESS_KEY',
112
+ 'npm_config__authToken',
113
+ ];
@@ -16,6 +16,7 @@ import { join } from 'node:path';
16
16
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
17
17
  import { c, note, ok, warn } from './ui.mjs';
18
18
  import { deployCreds, appSecretsFor, scrub, myPubB64 } from './env.mjs';
19
+ import { childEnv } from './childEnv.mjs';
19
20
 
20
21
  const deployUrl = (tail) => FLEET_URL.replace(/\/agents\/?$/, `/${tail}`);
21
22
 
@@ -206,8 +207,18 @@ export function processDeployJobs(jobs, ctx) {
206
207
  }
207
208
 
208
209
  async function runDeploy(job, target, ctx) {
209
- const env = { ...process.env, ...deployCreds() }; // inject infra creds; never a file
210
- delete env.FLEET_TOKEN; // the deploy command has no business reading it; keep it out of a command that might echo its env
210
+ // AN ALLOWLIST, not `{...process.env}` minus names. What stood here was
211
+ //
212
+ // const env = { ...process.env, ...deployCreds() };
213
+ // delete env.FLEET_TOKEN;
214
+ //
215
+ // and that delete was a NO-OP: `FLEET_TOKEN` is a module constant in
216
+ // config.mjs, while the environment variable is `FLOWVIANT_FLEET`. The
217
+ // machine credential was therefore in the environment of every deploy
218
+ // command — and of `target.build`, which is a string the REPO controls —
219
+ // under a comment asserting the opposite. A denylist is a claim about a set
220
+ // you cannot see; this is built from {} instead.
221
+ const env = childEnv({ cwd: ctx.repoRoot, extra: deployCreds() }); // infra creds; never a file
211
222
  const logs = [];
212
223
  // Rollback is a single wrangler command; deploy is build → secrets → deploy.
213
224
  if (job.kind === 'rollback') {
@@ -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
+ }
@@ -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);
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.3",
3
+ "version": "0.56.1",
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",