flowviant 0.52.0 → 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.
- package/bin/lib/authproxy.mjs +131 -32
- package/bin/lib/config.mjs +16 -0
- package/bin/lib/fleet.mjs +36 -15
- package/bin/lib/listeners.mjs +269 -0
- package/bin/lib/preview.mjs +294 -390
- package/bin/lib/work.mjs +211 -3
- package/package.json +1 -1
package/bin/lib/authproxy.mjs
CHANGED
|
@@ -1,44 +1,124 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
10
|
-
*
|
|
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
|
-
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
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?.(`
|
|
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
|
});
|
package/bin/lib/config.mjs
CHANGED
|
@@ -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,
|
|
@@ -77,7 +78,7 @@ import { detectRuntimes, knownSkills, pickRuntimeFor, RUNTIMES } from './runtime
|
|
|
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"
|
|
@@ -374,6 +384,14 @@ export async function runFleetDaemon() {
|
|
|
374
384
|
const nextByAgent = new Map();
|
|
375
385
|
let leaseTtlSeconds = 24 * 60 * 60; // updated from each roster response
|
|
376
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.
|
|
377
395
|
const workers = new Map(); // agentId -> { state, promise, wt, label }
|
|
378
396
|
let daemonAlive = true; // flipped false on shutdown so the stream stops reconnecting
|
|
379
397
|
let stream = null; // push channel handle (set once the loop is set up)
|
|
@@ -413,15 +431,10 @@ export async function runFleetDaemon() {
|
|
|
413
431
|
} catch {
|
|
414
432
|
/* best-effort */
|
|
415
433
|
}
|
|
416
|
-
// Stop the detached preview (dev server + cloudflared tunnel) — it's its
|
|
417
|
-
// own process group and survives our exit, otherwise leaking a port-bound
|
|
418
|
-
// server + a live tunnel serving a stale branch until reboot.
|
|
419
|
-
try {
|
|
420
|
-
w.state.stopPreview?.();
|
|
421
|
-
} catch {
|
|
422
|
-
/* best-effort */
|
|
423
|
-
}
|
|
424
434
|
}
|
|
435
|
+
// Detached tunnels survive our exit by design, so leaving them would strand
|
|
436
|
+
// a public hostname until the box rebooted.
|
|
437
|
+
shutdownPreviews();
|
|
425
438
|
};
|
|
426
439
|
process.on('SIGINT', () => {
|
|
427
440
|
console.log('');
|
|
@@ -593,6 +606,10 @@ export async function runFleetDaemon() {
|
|
|
593
606
|
processWorkTurns,
|
|
594
607
|
processShipJobs,
|
|
595
608
|
processDiffJobs,
|
|
609
|
+
processPreviewJobs,
|
|
610
|
+
livePreviewIds,
|
|
611
|
+
retirePreviews,
|
|
612
|
+
shutdownPreviews,
|
|
596
613
|
retireWorkSessions,
|
|
597
614
|
reportWorktrees,
|
|
598
615
|
shutdownWork,
|
|
@@ -1153,7 +1170,7 @@ export async function runFleetDaemon() {
|
|
|
1153
1170
|
for (;;) {
|
|
1154
1171
|
let roster;
|
|
1155
1172
|
try {
|
|
1156
|
-
roster = await fetchRoster(buildHave());
|
|
1173
|
+
roster = await fetchRoster(buildHave(), livePreviewIds());
|
|
1157
1174
|
} catch (e) {
|
|
1158
1175
|
if (e.auth) {
|
|
1159
1176
|
fail(`${e.message} — credential revoked or invalid. Shutting down.`);
|
|
@@ -1215,11 +1232,20 @@ export async function runFleetDaemon() {
|
|
|
1215
1232
|
// AFTER the work/ship intake: retirement is the server saying which
|
|
1216
1233
|
// sessions are LIVE, and the guards above (chains, shipping) are populated
|
|
1217
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);
|
|
1218
1240
|
retireWorkSessions(roster.activeWorkSessions);
|
|
1219
1241
|
// Diffs somebody has open and is waiting on. Project-scoped rather than
|
|
1220
1242
|
// per-session: `git show` runs from the repo ROOT, which can see a closed
|
|
1221
1243
|
// tab's branch and a shipped commit on main alike.
|
|
1222
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);
|
|
1223
1249
|
// …and what the SURVIVING ones hold: branch, ahead-of-base, diffstat.
|
|
1224
1250
|
// Throttled inside, never awaited — a `git status` the human cannot run
|
|
1225
1251
|
// themselves from a browser, relayed. After retirement so a directory that
|
|
@@ -1323,11 +1349,6 @@ export async function runFleetDaemon() {
|
|
|
1323
1349
|
} catch {
|
|
1324
1350
|
/* best-effort */
|
|
1325
1351
|
}
|
|
1326
|
-
try {
|
|
1327
|
-
w.state.stopPreview?.();
|
|
1328
|
-
} catch {
|
|
1329
|
-
/* best-effort */
|
|
1330
|
-
}
|
|
1331
1352
|
// Only poll mode's per-lane tree dies with the lane. A live lane owns no
|
|
1332
1353
|
// checkout: the task it was building has its own, which must SURVIVE —
|
|
1333
1354
|
// removing a lane requeues its task, and the next lane to pick that task
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is LISTENING inside a session's worktree.
|
|
3
|
+
*
|
|
4
|
+
* The Workbench preview never starts an app. The driver runs their own dev
|
|
5
|
+
* server in their own tab, exactly as they would in a terminal, and this file
|
|
6
|
+
* is how the machine NOTICES — a browser has no `ss -ltnp` to run, so the
|
|
7
|
+
* daemon runs it. That direction is the whole design: a control that can only
|
|
8
|
+
* exist once the machine has measured the thing it acts on cannot invent a
|
|
9
|
+
* state, cannot guess a port, and cannot time out waiting for a cold start.
|
|
10
|
+
*
|
|
11
|
+
* A listener is attributed to a session by the CWD OF THE PROCESS HOLDING THE
|
|
12
|
+
* SOCKET, never by the port number. Ports are global to the box; a worktree is
|
|
13
|
+
* not. Without that attribution `share_preview(5432)` tunnels Postgres and
|
|
14
|
+
* `share_preview(<a teammate's port>)` publishes somebody else's worktree — so
|
|
15
|
+
* this measurement is a security control, not a convenience, and it is why the
|
|
16
|
+
* MCP tool must not ship before it.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately NOT a probe: nothing here connects to the port, sends bytes, or
|
|
19
|
+
* asks what is on the other end. It reads the kernel's own socket table. A
|
|
20
|
+
* daemon that spoke to whatever the driver happened to be running would be a
|
|
21
|
+
* second actor in their session.
|
|
22
|
+
*
|
|
23
|
+
* Linux (including WSL2) reads /proc. macOS shells out to lsof twice. Windows
|
|
24
|
+
* reports NOTHING and says so through the empty array — the same answer
|
|
25
|
+
* `stillOurs` gives, and the same rule the rest of the product keeps: an
|
|
26
|
+
* unmeasured thing renders nothing rather than rendering "none".
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { execFileSync } from 'node:child_process';
|
|
30
|
+
import { createConnection } from 'node:net';
|
|
31
|
+
import { readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
|
|
32
|
+
import { platform } from 'node:os';
|
|
33
|
+
import { sep } from 'node:path';
|
|
34
|
+
|
|
35
|
+
/** A box with more processes than this is not one we walk per sweep. The scan
|
|
36
|
+
* is one readlink per pid and runs every reconcile; this is the runaway
|
|
37
|
+
* bound, not a capacity statement. */
|
|
38
|
+
const MAX_PIDS = 4000;
|
|
39
|
+
/** Rows reported per session. A dev server, its HMR socket and an API is three;
|
|
40
|
+
* twenty is somebody's docker-compose and the extra rows say nothing. */
|
|
41
|
+
const MAX_ROWS = 8;
|
|
42
|
+
/** Longest process label we relay. */
|
|
43
|
+
const MAX_LABEL = 24;
|
|
44
|
+
|
|
45
|
+
// ── /proc/net/tcp parsing (linux) ──────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
// local_address is "<hex addr>:<hex port>". The address is little-endian per
|
|
48
|
+
// 4-byte word; the PORT is big-endian. Only the port and the coarse bind scope
|
|
49
|
+
// are worth relaying — a browser cannot reach a loopback bind through a tunnel
|
|
50
|
+
// any differently than an any-bind, but the operator can read the difference.
|
|
51
|
+
function parseLocal(hex) {
|
|
52
|
+
const [addr, port] = String(hex).split(':');
|
|
53
|
+
if (!addr || !port) return null;
|
|
54
|
+
const p = parseInt(port, 16);
|
|
55
|
+
if (!Number.isInteger(p) || p <= 0 || p > 65535) return null;
|
|
56
|
+
const zeros = /^0+$/.test(addr);
|
|
57
|
+
const v4Loopback = addr.toUpperCase() === '0100007F';
|
|
58
|
+
// ::1 in /proc/net/tcp6 is 24 zeros then 01000000 (little-endian per word).
|
|
59
|
+
const v6Loopback = addr.toUpperCase() === '00000000000000000000000001000000';
|
|
60
|
+
return { port: p, bind: zeros ? 'any' : v4Loopback || v6Loopback ? 'loopback' : 'other' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** inode -> { port, bind } for every socket in LISTEN state. */
|
|
64
|
+
function listeningByInode() {
|
|
65
|
+
const out = new Map();
|
|
66
|
+
for (const f of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
|
67
|
+
let text;
|
|
68
|
+
try {
|
|
69
|
+
text = readFileSync(f, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
continue; // no ipv6 stack, or not linux
|
|
72
|
+
}
|
|
73
|
+
for (const line of text.split('\n').slice(1)) {
|
|
74
|
+
const c = line.trim().split(/\s+/);
|
|
75
|
+
if (c.length < 10) continue;
|
|
76
|
+
if (c[3] !== '0A') continue; // TCP_LISTEN
|
|
77
|
+
const local = parseLocal(c[1]);
|
|
78
|
+
if (!local) continue;
|
|
79
|
+
const inode = c[9];
|
|
80
|
+
if (inode && inode !== '0') out.set(inode, local);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function labelFor(pid) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
89
|
+
const first = raw.split('\0').filter(Boolean)[0] || '';
|
|
90
|
+
// The basename only. A full argv is the driver's command line, which can
|
|
91
|
+
// carry a token in an inline env assignment — and the whole argv is never
|
|
92
|
+
// what a reader needs to recognise their own dev server.
|
|
93
|
+
const base = first.split('/').pop() || first;
|
|
94
|
+
return base.slice(0, MAX_LABEL) || null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function scanLinux(worktree) {
|
|
101
|
+
const inodes = listeningByInode();
|
|
102
|
+
if (inodes.size === 0) return [];
|
|
103
|
+
|
|
104
|
+
let root;
|
|
105
|
+
try {
|
|
106
|
+
root = realpathSync(worktree);
|
|
107
|
+
} catch {
|
|
108
|
+
return []; // the directory is gone — retired under us
|
|
109
|
+
}
|
|
110
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
111
|
+
const inside = (p) => p === root || p.startsWith(prefix);
|
|
112
|
+
|
|
113
|
+
let pids;
|
|
114
|
+
try {
|
|
115
|
+
pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d));
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
if (pids.length > MAX_PIDS) pids = pids.slice(0, MAX_PIDS);
|
|
120
|
+
|
|
121
|
+
const found = new Map(); // port -> row
|
|
122
|
+
for (const pid of pids) {
|
|
123
|
+
// Cheap filter FIRST: one readlink rejects almost every process on the box,
|
|
124
|
+
// and only survivors pay for a readdir of their fd table.
|
|
125
|
+
let cwd;
|
|
126
|
+
try {
|
|
127
|
+
cwd = readlinkSync(`/proc/${pid}/cwd`);
|
|
128
|
+
} catch {
|
|
129
|
+
continue; // not ours, or gone
|
|
130
|
+
}
|
|
131
|
+
if (!inside(cwd)) continue;
|
|
132
|
+
|
|
133
|
+
let fds;
|
|
134
|
+
try {
|
|
135
|
+
fds = readdirSync(`/proc/${pid}/fd`);
|
|
136
|
+
} catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
for (const fd of fds) {
|
|
140
|
+
let link;
|
|
141
|
+
try {
|
|
142
|
+
link = readlinkSync(`/proc/${pid}/fd/${fd}`);
|
|
143
|
+
} catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const m = /^socket:\[(\d+)\]$/.exec(link);
|
|
147
|
+
if (!m) continue;
|
|
148
|
+
const hit = inodes.get(m[1]);
|
|
149
|
+
if (!hit) continue;
|
|
150
|
+
if (found.has(hit.port)) continue;
|
|
151
|
+
found.set(hit.port, { port: hit.port, bind: hit.bind, label: labelFor(pid) });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return [...found.values()];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── macOS ──────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
function lsof(args) {
|
|
160
|
+
try {
|
|
161
|
+
return execFileSync('lsof', args, {
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
164
|
+
timeout: 5000,
|
|
165
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
166
|
+
});
|
|
167
|
+
} catch {
|
|
168
|
+
return ''; // lsof absent or nothing matched — both are "no measurement"
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function scanDarwin(worktree) {
|
|
173
|
+
// Pass 1: every listening socket, as pid → ports.
|
|
174
|
+
const byPid = new Map();
|
|
175
|
+
let pid = null;
|
|
176
|
+
for (const line of lsof(['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pn']).split('\n')) {
|
|
177
|
+
if (line.startsWith('p')) pid = line.slice(1);
|
|
178
|
+
else if (line.startsWith('n') && pid) {
|
|
179
|
+
const m = /:(\d+)$/.exec(line.slice(1));
|
|
180
|
+
if (!m) continue;
|
|
181
|
+
const p = Number(m[1]);
|
|
182
|
+
const bind = /^n\*:/.test(line) ? 'any' : /^n(127\.0\.0\.1|\[::1\])/.test(line) ? 'loopback' : 'other';
|
|
183
|
+
if (!byPid.has(pid)) byPid.set(pid, []);
|
|
184
|
+
byPid.get(pid).push({ port: p, bind });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (byPid.size === 0) return [];
|
|
188
|
+
|
|
189
|
+
let root;
|
|
190
|
+
try {
|
|
191
|
+
root = realpathSync(worktree);
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
196
|
+
|
|
197
|
+
// Pass 2: the cwd of exactly those pids, in ONE batched call.
|
|
198
|
+
const out = new Map();
|
|
199
|
+
let cur = null;
|
|
200
|
+
for (const line of lsof(['-a', '-d', 'cwd', '-F', 'pn', '-p', [...byPid.keys()].join(',')]).split('\n')) {
|
|
201
|
+
if (line.startsWith('p')) cur = line.slice(1);
|
|
202
|
+
else if (line.startsWith('n') && cur) {
|
|
203
|
+
const cwd = line.slice(1);
|
|
204
|
+
if (cwd !== root && !cwd.startsWith(prefix)) continue;
|
|
205
|
+
for (const row of byPid.get(cur) || []) {
|
|
206
|
+
if (!out.has(row.port)) out.set(row.port, { ...row, label: null });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return [...out.values()];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── public ─────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Every TCP port in LISTEN held by a process whose cwd is inside `worktree`.
|
|
217
|
+
* Smallest port first, capped. An empty array on an unsupported platform means
|
|
218
|
+
* "we did not look" — callers must not turn it into "nothing is running".
|
|
219
|
+
*/
|
|
220
|
+
export function listenersIn(worktree) {
|
|
221
|
+
if (!worktree) return [];
|
|
222
|
+
let rows;
|
|
223
|
+
try {
|
|
224
|
+
rows = platform() === 'linux' ? scanLinux(worktree) : platform() === 'darwin' ? scanDarwin(worktree) : [];
|
|
225
|
+
} catch {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
return rows.sort((a, b) => a.port - b.port).slice(0, MAX_ROWS);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Does this platform measure listeners at all? The web must render no preview
|
|
232
|
+
* affordance where the answer is no, rather than an empty one. */
|
|
233
|
+
export function listenersSupported() {
|
|
234
|
+
return platform() === 'linux' || platform() === 'darwin';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Is something accepting connections on this loopback port RIGHT NOW?
|
|
239
|
+
*
|
|
240
|
+
* Used at two moments, both of them re-validation rather than discovery: the
|
|
241
|
+
* daemon re-checks a port the server told it to share, and a live share checks
|
|
242
|
+
* that its origin has not died under the tunnel. cloudflared happily outlives a
|
|
243
|
+
* dead dev server and the gate answers a dead origin with 502, so without this
|
|
244
|
+
* the product would print "live" over a 502 — which is Flowviant asserting a
|
|
245
|
+
* state it never measured.
|
|
246
|
+
*
|
|
247
|
+
* A TCP connect and an immediate close: no bytes sent, nothing read.
|
|
248
|
+
*/
|
|
249
|
+
export function isListening(port, timeoutMs = 1500) {
|
|
250
|
+
return new Promise((resolve) => {
|
|
251
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return resolve(false);
|
|
252
|
+
let done = false;
|
|
253
|
+
const finish = (v) => {
|
|
254
|
+
if (done) return;
|
|
255
|
+
done = true;
|
|
256
|
+
try {
|
|
257
|
+
sock.destroy();
|
|
258
|
+
} catch {
|
|
259
|
+
/* already gone */
|
|
260
|
+
}
|
|
261
|
+
resolve(v);
|
|
262
|
+
};
|
|
263
|
+
const sock = createConnection({ port, host: '127.0.0.1' });
|
|
264
|
+
sock.setTimeout(timeoutMs);
|
|
265
|
+
sock.once('connect', () => finish(true));
|
|
266
|
+
sock.once('timeout', () => finish(false));
|
|
267
|
+
sock.once('error', () => finish(false));
|
|
268
|
+
});
|
|
269
|
+
}
|