flowviant 0.51.2 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/authproxy.mjs +131 -32
- package/bin/lib/claude.mjs +16 -3
- package/bin/lib/config.mjs +16 -0
- package/bin/lib/fleet.mjs +52 -16
- package/bin/lib/listeners.mjs +269 -0
- package/bin/lib/preview.mjs +294 -390
- package/bin/lib/prompts.mjs +62 -13
- package/bin/lib/runtimes.mjs +47 -0
- package/bin/lib/work.mjs +218 -4
- package/package.json +1 -1
|
@@ -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
|
+
}
|