@tonyclaw/agent-inspector 2.1.9 → 2.1.11
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/.output/cli.js +14671 -397
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-Dv423tJJ.js → CompareDrawer-CcwXsZp3.js} +1 -1
- package/.output/public/assets/{ProxyViewerContainer-6mRRv0F1.js → ProxyViewerContainer-DFFxBlvO.js} +17 -17
- package/.output/public/assets/{ReplayDialog-Bury80WZ.js → ReplayDialog-XXiwmD5o.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-Bh07p6Yj.js → RequestAnatomy-DTCh37so.js} +1 -1
- package/.output/public/assets/{ResponseView-C8_ZLXNd.js → ResponseView-CbFYw9xP.js} +1 -1
- package/.output/public/assets/{StreamingChunkSequence-BKvT1lyU.js → StreamingChunkSequence-CTLMzUxV.js} +1 -1
- package/.output/public/assets/_sessionId-DAwcZWfu.js +1 -0
- package/.output/public/assets/index-B6sDoCll.js +1 -0
- package/.output/public/assets/{main-D9YPHKL0.js → main-C-lua4Uq.js} +2 -2
- package/.output/server/{_sessionId-D2z4VD7h.mjs → _sessionId-CO0WzOBc.mjs} +2 -2
- package/.output/server/_ssr/{CompareDrawer-DNDkVh7T.mjs → CompareDrawer-_OnPOi0O.mjs} +2 -2
- package/.output/server/_ssr/{ProxyViewerContainer-CI-fjnaf.mjs → ProxyViewerContainer-Bfy-cjD3.mjs} +23 -25
- package/.output/server/_ssr/{ReplayDialog-Dx4um0uS.mjs → ReplayDialog-DBZyQFFR.mjs} +3 -3
- package/.output/server/_ssr/{RequestAnatomy-CKPBN_PB.mjs → RequestAnatomy-C7m6HJOG.mjs} +2 -2
- package/.output/server/_ssr/{ResponseView-3wkt24-z.mjs → ResponseView-CM2nFoqa.mjs} +2 -2
- package/.output/server/_ssr/{StreamingChunkSequence-CSK53g5a.mjs → StreamingChunkSequence-Dj61jLHa.mjs} +2 -2
- package/.output/server/_ssr/{index-1uaJ_KuG.mjs → index-BI_nCxdk.mjs} +2 -2
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-KEX70DxH.mjs → router-WBmANYRw.mjs} +136 -119
- package/.output/server/{_tanstack-start-manifest_v-VhueDt-w.mjs → _tanstack-start-manifest_v-lODJPovZ.mjs} +1 -1
- package/.output/server/index.mjs +60 -60
- package/package.json +1 -1
- package/src/cli.ts +89 -0
- package/src/components/proxy-viewer/LogEntryHeader.tsx +25 -26
- package/src/proxy/identityProxy.ts +193 -0
- package/src/proxy/socketTracker.ts +387 -27
- package/.output/public/assets/_sessionId-DnxfC3cB.js +0 -1
- package/.output/public/assets/index-B1naZUTb.js +0 -1
|
@@ -31,6 +31,43 @@ const SocketSchema = z
|
|
|
31
31
|
// Deduplicate concurrent lookups for the same port
|
|
32
32
|
const inflight = new Map<number, Promise<ClientInfo>>();
|
|
33
33
|
|
|
34
|
+
// --- Recent-client scan cache -------------------------------------------
|
|
35
|
+
// `getClientInfo` no longer needs a per-request client port; it scans all
|
|
36
|
+
// current connections to our listen port and returns the freshest. We cache
|
|
37
|
+
// the result for a short window so a burst of requests from one client
|
|
38
|
+
// converges on a single subprocess call.
|
|
39
|
+
const RECENT_CLIENT_TTL_MS = 1000;
|
|
40
|
+
let recentInflight: Promise<ClientInfo> | null = null;
|
|
41
|
+
let recentCache: ClientInfo | null = null;
|
|
42
|
+
let recentCacheExpiresAt = 0;
|
|
43
|
+
|
|
44
|
+
/** The port the proxy is listening on. Falls back to the dev default. */
|
|
45
|
+
function getProxyPort(): number {
|
|
46
|
+
// The proxy server exposes its chosen port via the `PROXY_PORT` env var.
|
|
47
|
+
// Default to the well-known dev port so the scan still works in dev mode
|
|
48
|
+
// (where `proxy.ts` reads from `getConfig()` instead of an env var).
|
|
49
|
+
const raw = process.env["PROXY_PORT"];
|
|
50
|
+
if (raw !== undefined) {
|
|
51
|
+
const parsed = parseInt(raw, 10);
|
|
52
|
+
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535) return parsed;
|
|
53
|
+
}
|
|
54
|
+
return 25947;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getRecentClientFromCache(): ClientInfo | null {
|
|
58
|
+
if (recentCache === null) return null;
|
|
59
|
+
if (Date.now() >= recentCacheExpiresAt) {
|
|
60
|
+
recentCache = null;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return recentCache;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function setRecentClientCache(info: ClientInfo): void {
|
|
67
|
+
recentCache = info;
|
|
68
|
+
recentCacheExpiresAt = Date.now() + RECENT_CLIENT_TTL_MS;
|
|
69
|
+
}
|
|
70
|
+
|
|
34
71
|
function evictCacheIfNeeded(): void {
|
|
35
72
|
while (cache.size > MAX_CACHE_SIZE) {
|
|
36
73
|
const oldestKey = cache.keys().next().value;
|
|
@@ -54,17 +91,62 @@ function setCache(port: number, info: ClientInfo): void {
|
|
|
54
91
|
}
|
|
55
92
|
|
|
56
93
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
94
|
+
* Best-effort remote-port extraction across runtimes. Tries, in order:
|
|
95
|
+
* 1. TanStack Start / Nitro: `request.context.nitro.node.req.socket` (h3
|
|
96
|
+
* wraps the underlying Node request, where the socket lives).
|
|
97
|
+
* 2. Bun-style: `request.socket` accessor.
|
|
98
|
+
* 3. Direct `socket` / `connection` properties on the request (legacy Node
|
|
99
|
+
* IncomingMessage-style wrapping).
|
|
100
|
+
* Returns `null` when nothing matches — the caller will then leave `pid`
|
|
101
|
+
* (and downstream `clientPid`) as `null`.
|
|
59
102
|
*/
|
|
60
103
|
export function extractRemotePort(request: Request): number | null {
|
|
104
|
+
const candidateSockets: unknown[] = [];
|
|
105
|
+
|
|
106
|
+
// 1) Nitro / h3 path. TanStack Start attaches the h3 event under
|
|
107
|
+
// `request.context.nitro`, and the underlying Node request lives at
|
|
108
|
+
// `event.node.req`. The socket may be on `req.socket` or
|
|
109
|
+
// `req.connection` depending on the Node version / TLS config.
|
|
110
|
+
const requestAsUnknown: unknown = request;
|
|
111
|
+
const ctx = pick(requestAsUnknown, "context");
|
|
112
|
+
const nitro = pick(ctx, "nitro");
|
|
113
|
+
const node = pick(nitro, "node");
|
|
114
|
+
const innerReq = pick(node, "req");
|
|
115
|
+
const sock = pick(innerReq, "socket") ?? pick(innerReq, "connection");
|
|
116
|
+
if (sock !== undefined) candidateSockets.push(sock);
|
|
117
|
+
|
|
118
|
+
// 2) Bun-style direct socket accessor on the Request.
|
|
61
119
|
const descriptor = Object.getOwnPropertyDescriptor(request, "socket");
|
|
62
|
-
if (descriptor
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
120
|
+
if (descriptor !== undefined && descriptor.get === undefined) {
|
|
121
|
+
candidateSockets.push(descriptor.value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 3) Raw socket/connection properties on the request itself.
|
|
125
|
+
const directSocket = pick(requestAsUnknown, "socket");
|
|
126
|
+
if (directSocket !== undefined) candidateSockets.push(directSocket);
|
|
127
|
+
const directConnection = pick(requestAsUnknown, "connection");
|
|
128
|
+
if (directConnection !== undefined) candidateSockets.push(directConnection);
|
|
129
|
+
|
|
130
|
+
for (const candidate of candidateSockets) {
|
|
131
|
+
const parsed = SocketSchema.safeParse(candidate);
|
|
132
|
+
if (!parsed.success) continue;
|
|
133
|
+
const remotePort = parsed.data.remotePort;
|
|
134
|
+
if (remotePort === undefined || remotePort === null) continue;
|
|
135
|
+
return remotePort;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
141
|
+
return value !== undefined && value !== null && typeof value === "object";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Read a property from an `unknown` without triggering lint rules that
|
|
145
|
+
* forbid type assertions. Returns `undefined` for non-object inputs or
|
|
146
|
+
* when the property is missing. */
|
|
147
|
+
function pick(value: unknown, key: string): unknown {
|
|
148
|
+
if (!isObject(value)) return undefined;
|
|
149
|
+
return value[key];
|
|
68
150
|
}
|
|
69
151
|
|
|
70
152
|
/**
|
|
@@ -226,37 +308,315 @@ async function lookupProcessInfo(
|
|
|
226
308
|
}
|
|
227
309
|
|
|
228
310
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
311
|
+
* Scan the OS connection table for the freshest client→proxy connection and
|
|
312
|
+
* return the matching PID plus its cwd/projectFolder.
|
|
231
313
|
*/
|
|
232
|
-
|
|
233
|
-
const port =
|
|
234
|
-
|
|
314
|
+
async function lookupRecentClient(): Promise<ClientInfo> {
|
|
315
|
+
const port = getProxyPort();
|
|
316
|
+
const platform = process.platform;
|
|
317
|
+
try {
|
|
318
|
+
if (platform === "win32") {
|
|
319
|
+
// Get-NetTCPConnection gives us CreationTime on the client-side
|
|
320
|
+
// (-RemotePort) entries, which we sort descending to pick the freshest.
|
|
321
|
+
const psScript = [
|
|
322
|
+
`$rows = Get-NetTCPConnection -RemotePort ${port} -State Established -ErrorAction SilentlyContinue`,
|
|
323
|
+
`if ($rows) {`,
|
|
324
|
+
` $rows = $rows | Sort-Object CreationTime -Descending`,
|
|
325
|
+
` $top = $rows | Select-Object -First 1`,
|
|
326
|
+
` if ($top) { Write-Output ("$($top.OwningProcess)|$($top.LocalPort)") }`,
|
|
327
|
+
`}`,
|
|
328
|
+
].join("; ");
|
|
329
|
+
const { stdout } = await execFileAsync(
|
|
330
|
+
"powershell.exe",
|
|
331
|
+
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", psScript],
|
|
332
|
+
{ windowsHide: true, timeout: 3000, maxBuffer: 16 * 1024 },
|
|
333
|
+
);
|
|
334
|
+
const trimmed = stdout.trim();
|
|
335
|
+
if (trimmed === "") return { port: null, pid: null, cwd: null, projectFolder: null };
|
|
336
|
+
const [pidStr, portStr] = trimmed.split("|");
|
|
337
|
+
const pid = parseInt(pidStr ?? "", 10);
|
|
338
|
+
const clientPort = parseInt(portStr ?? "", 10);
|
|
339
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
340
|
+
return { port: null, pid: null, cwd: null, projectFolder: null };
|
|
341
|
+
}
|
|
342
|
+
const { cwd, projectFolder } = await lookupProcessInfo(pid);
|
|
343
|
+
return {
|
|
344
|
+
port: Number.isInteger(clientPort) && clientPort > 0 ? clientPort : null,
|
|
345
|
+
pid,
|
|
346
|
+
cwd,
|
|
347
|
+
projectFolder,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Unix: netstat with -p prints `pid/program` next to each entry. Pick
|
|
352
|
+
// the row matching our proxy port on the remote side (i.e. the client's
|
|
353
|
+
// ephemeral port + the proxy's listen port). When multiple clients are
|
|
354
|
+
// connected we take the highest ephemeral port — Linux/macOS assign
|
|
355
|
+
// ports in ascending order, so the largest one is the freshest.
|
|
356
|
+
const { stdout } = await execAsync(
|
|
357
|
+
`netstat -tanp 2>/dev/null | grep ':${port}' | grep ESTAB || ss -tanp 2>/dev/null | grep ':${port}' | grep ESTAB`,
|
|
358
|
+
{ shell: "/bin/sh" },
|
|
359
|
+
);
|
|
360
|
+
const lines = stdout
|
|
361
|
+
.trim()
|
|
362
|
+
.split("\n")
|
|
363
|
+
.filter((line) => line.includes("ESTAB"));
|
|
364
|
+
const clientPid = pickNewestClientPidFromNetstat(lines, port);
|
|
365
|
+
if (clientPid === null) {
|
|
366
|
+
return { port: null, pid: null, cwd: null, projectFolder: null };
|
|
367
|
+
}
|
|
368
|
+
const { cwd, projectFolder } = await lookupProcessInfo(clientPid);
|
|
369
|
+
return { port: null, pid: clientPid, cwd, projectFolder };
|
|
370
|
+
} catch (err) {
|
|
371
|
+
logger.debug(`[socketTracker] Recent-client lookup failed for port ${port}:`, String(err));
|
|
235
372
|
return { port: null, pid: null, cwd: null, projectFolder: null };
|
|
236
373
|
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Parse the output of `netstat -tanp` (or `ss -tanp`) and return the PID
|
|
377
|
+
* on the most recently opened client-side connection to `proxyPort`. */
|
|
378
|
+
function pickNewestClientPidFromNetstat(lines: string[], proxyPort: number): number | null {
|
|
379
|
+
const proxyToken = `:${proxyPort}`;
|
|
380
|
+
type Row = { pid: number; localPort: number; order: number };
|
|
381
|
+
const candidates: Row[] = [];
|
|
382
|
+
let order = 0;
|
|
383
|
+
for (const line of lines) {
|
|
384
|
+
order++;
|
|
385
|
+
const cols = line.trim().split(/\s+/);
|
|
386
|
+
if (cols.length < 7) continue;
|
|
387
|
+
const localAddr = cols[3] ?? "";
|
|
388
|
+
const remoteAddr = cols[4] ?? "";
|
|
389
|
+
const pidField = cols[6] ?? "";
|
|
390
|
+
// Client-side rows: `RemoteAddress` (5th col) is the proxy's listen port;
|
|
391
|
+
// `LocalAddress` (4th col) is the client's ephemeral port.
|
|
392
|
+
if (!remoteAddr.endsWith(proxyToken)) continue;
|
|
393
|
+
const pidMatch = pidField.match(/^(\d+)/);
|
|
394
|
+
if (pidMatch === null) continue;
|
|
395
|
+
const pid = parseInt(pidMatch[1] ?? "", 10);
|
|
396
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
397
|
+
const localPortMatch = localAddr.match(/:(\d+)$/);
|
|
398
|
+
const localPort = localPortMatch !== null ? parseInt(localPortMatch[1] ?? "", 10) : 0;
|
|
399
|
+
candidates.push({ pid, localPort, order });
|
|
400
|
+
}
|
|
401
|
+
if (candidates.length === 0) return null;
|
|
402
|
+
candidates.sort((a, b) => b.localPort - a.localPort || b.order - a.order);
|
|
403
|
+
return candidates[0]?.pid ?? null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Custom request headers the client (or a wrapper script) may set so we
|
|
407
|
+
* can identify the originating process without scanning the OS connection
|
|
408
|
+
* table. Recognized names:
|
|
409
|
+
* - `X-Agent-Inspector-Client-Pid`
|
|
410
|
+
* - `X-Agent-Inspector-Client-Port`
|
|
411
|
+
* - `X-Agent-Inspector-Client-Cwd`
|
|
412
|
+
* - `X-Agent-Inspector-Client-Project-Folder`
|
|
413
|
+
* Clients should set these via a small launcher script that exports the
|
|
414
|
+
* values from `$$` and `pwd` before invoking the AI tool, e.g.:
|
|
415
|
+
*
|
|
416
|
+
* curl -H "X-Agent-Inspector-Client-Pid: $$" \
|
|
417
|
+
* -H "X-Agent-Inspector-Client-Cwd: $(pwd)" ...
|
|
418
|
+
*/
|
|
419
|
+
const CLIENT_HEADERS = {
|
|
420
|
+
pid: "x-agent-inspector-client-pid",
|
|
421
|
+
port: "x-agent-inspector-client-port",
|
|
422
|
+
cwd: "x-agent-inspector-client-cwd",
|
|
423
|
+
projectFolder: "x-agent-inspector-client-project-folder",
|
|
424
|
+
} as const;
|
|
425
|
+
|
|
426
|
+
/** Try to read a positive-integer value out of a header. Returns `null` for
|
|
427
|
+
* missing / malformed values. */
|
|
428
|
+
function readIntHeader(headers: Headers, name: string): number | null {
|
|
429
|
+
const raw = headers.get(name);
|
|
430
|
+
if (raw === null || raw === "") return null;
|
|
431
|
+
const parsed = parseInt(raw, 10);
|
|
432
|
+
if (!Number.isInteger(parsed) || parsed < 0) return null;
|
|
433
|
+
return parsed;
|
|
434
|
+
}
|
|
237
435
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
436
|
+
/** Read client-supplied headers. Returns `null` when no PID header is
|
|
437
|
+
* present — caller falls back to OS scanning. */
|
|
438
|
+
function readClientHeaders(request: Request): ClientInfo | null {
|
|
439
|
+
const pid = readIntHeader(request.headers, CLIENT_HEADERS.pid);
|
|
440
|
+
if (pid === null || pid === 0) return null;
|
|
441
|
+
const port = readIntHeader(request.headers, CLIENT_HEADERS.port);
|
|
442
|
+
const cwd = request.headers.get(CLIENT_HEADERS.cwd);
|
|
443
|
+
const projectFolder = request.headers.get(CLIENT_HEADERS.projectFolder);
|
|
444
|
+
return {
|
|
445
|
+
pid,
|
|
446
|
+
port: port !== null && port > 0 ? port : null,
|
|
447
|
+
cwd: cwd !== null && cwd !== "" ? cwd : null,
|
|
448
|
+
projectFolder: projectFolder !== null && projectFolder !== "" ? projectFolder : null,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
241
451
|
|
|
242
|
-
|
|
243
|
-
|
|
452
|
+
/**
|
|
453
|
+
* Get client info (PID, CWD, project folder) for the request that triggered
|
|
454
|
+
* this call. The TanStack Start fetch-API `Request` does not expose the
|
|
455
|
+
* underlying TCP socket (no `request.socket`, no `event.node.req`), so we
|
|
456
|
+
* cannot derive the per-request client port from the request itself.
|
|
457
|
+
*
|
|
458
|
+
* Strategy: enumerate the OS-level TCP connections to our proxy's listen
|
|
459
|
+
* port and pick the most recently established client-side connection. This
|
|
460
|
+
* is essentially "who is currently talking to us" — for a session with many
|
|
461
|
+
* rapid-fire requests the same client holds a long-lived connection, so
|
|
462
|
+
* consecutive lookups within the cache TTL converge on the same PID.
|
|
463
|
+
*
|
|
464
|
+
* Deduplicates concurrent lookups with a single in-flight promise. Results
|
|
465
|
+
* are cached by PID for `RECENT_CLIENT_TTL_MS` to avoid spawning a
|
|
466
|
+
* `Get-NetTCPConnection` / `ss` subprocess on every single request.
|
|
467
|
+
*/
|
|
468
|
+
export async function getClientInfo(request: Request): Promise<ClientInfo> {
|
|
469
|
+
// Preferred path: explicit client identity in headers. AI coding tools
|
|
470
|
+
// (or the wrapper scripts that launch them) attach
|
|
471
|
+
// `X-Agent-Inspector-Client-Pid` (and optionally …-Cwd /
|
|
472
|
+
// …-Project-Folder / …-Port) so we never have to guess.
|
|
473
|
+
const fromHeaders = readClientHeaders(request);
|
|
474
|
+
if (fromHeaders !== null) return fromHeaders;
|
|
475
|
+
|
|
476
|
+
// Fallback: scan the OS connection table for the most recently opened
|
|
477
|
+
// client→proxy connection. Best-effort — in environments with many
|
|
478
|
+
// long-lived keep-alive connections (browsers, background processes) the
|
|
479
|
+
// picked connection may not belong to the current request.
|
|
480
|
+
const cached = getRecentClientFromCache();
|
|
481
|
+
if (cached !== null) return cached;
|
|
482
|
+
|
|
483
|
+
const existing = recentInflight;
|
|
244
484
|
if (existing) return existing;
|
|
245
485
|
|
|
246
|
-
const promise =
|
|
486
|
+
const promise = lookupRecentClient()
|
|
247
487
|
.then((info) => {
|
|
248
|
-
|
|
249
|
-
|
|
488
|
+
setRecentClientCache(info);
|
|
489
|
+
recentInflight = null;
|
|
250
490
|
return info;
|
|
251
491
|
})
|
|
252
492
|
.catch((err) => {
|
|
253
|
-
|
|
254
|
-
logger.debug(`[socketTracker]
|
|
255
|
-
|
|
256
|
-
setCache(port, fallback);
|
|
257
|
-
return fallback;
|
|
493
|
+
recentInflight = null;
|
|
494
|
+
logger.debug(`[socketTracker] Recent-client lookup failed:`, String(err));
|
|
495
|
+
return { port: null, pid: null, cwd: null, projectFolder: null };
|
|
258
496
|
});
|
|
259
497
|
|
|
260
|
-
|
|
498
|
+
recentInflight = promise;
|
|
261
499
|
return promise;
|
|
262
500
|
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Lookup the PID owning a specific client-side local port, then enrich it
|
|
504
|
+
* with the process's cwd / project folder. Used by the identity-injecting
|
|
505
|
+
* native HTTP proxy in `identityProxy.ts`, which has access to
|
|
506
|
+
* `req.socket.remotePort` (the client's ephemeral port) and needs to know
|
|
507
|
+
* which process opened the connection.
|
|
508
|
+
*
|
|
509
|
+
* Results are cached by `clientPort` for `PORT_LOOKUP_TTL_MS` — a single
|
|
510
|
+
* keep-alive connection typically reuses the same client port for many
|
|
511
|
+
* requests, so we don't want to spawn a subprocess per request.
|
|
512
|
+
*/
|
|
513
|
+
const PORT_LOOKUP_TTL_MS = 5 * 60 * 1000;
|
|
514
|
+
const portInfoCache = new Map<number, { info: ClientInfo; expiresAt: number }>();
|
|
515
|
+
|
|
516
|
+
export async function lookupClientByPort(clientPort: number): Promise<ClientInfo> {
|
|
517
|
+
if (!Number.isInteger(clientPort) || clientPort <= 0 || clientPort > 65_535) {
|
|
518
|
+
return { port: null, pid: null, cwd: null, projectFolder: null };
|
|
519
|
+
}
|
|
520
|
+
const cached = portInfoCache.get(clientPort);
|
|
521
|
+
if (cached !== undefined && Date.now() < cached.expiresAt) {
|
|
522
|
+
return cached.info;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
const pid = await lookupPidByLocalPort(clientPort);
|
|
526
|
+
if (pid === null) {
|
|
527
|
+
const fallback: ClientInfo = {
|
|
528
|
+
port: clientPort,
|
|
529
|
+
pid: null,
|
|
530
|
+
cwd: null,
|
|
531
|
+
projectFolder: null,
|
|
532
|
+
};
|
|
533
|
+
portInfoCache.set(clientPort, { info: fallback, expiresAt: Date.now() + PORT_LOOKUP_TTL_MS });
|
|
534
|
+
return fallback;
|
|
535
|
+
}
|
|
536
|
+
const { cwd, projectFolder } = await lookupProcessInfo(pid);
|
|
537
|
+
const info: ClientInfo = { port: clientPort, pid, cwd, projectFolder };
|
|
538
|
+
portInfoCache.set(clientPort, { info, expiresAt: Date.now() + PORT_LOOKUP_TTL_MS });
|
|
539
|
+
return info;
|
|
540
|
+
} catch (err) {
|
|
541
|
+
logger.debug(`[socketTracker] lookupClientByPort(${clientPort}) failed:`, String(err));
|
|
542
|
+
return { port: clientPort, pid: null, cwd: null, projectFolder: null };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Returns the PID owning `clientPort` on the local machine, or `null`
|
|
547
|
+
* if the port isn't held by any known process. Cross-platform wrapper
|
|
548
|
+
* around the OS-specific connection table. */
|
|
549
|
+
async function lookupPidByLocalPort(clientPort: number): Promise<number | null> {
|
|
550
|
+
const platform = process.platform;
|
|
551
|
+
try {
|
|
552
|
+
if (platform === "win32") {
|
|
553
|
+
// We deliberately use `netstat -ano | findstr` instead of
|
|
554
|
+
// `Get-NetTCPConnection -LocalPort`. The PowerShell cmdlet
|
|
555
|
+
// enumerates the entire socket table before filtering, which on a
|
|
556
|
+
// busy machine takes 10+ seconds — longer than our per-request
|
|
557
|
+
// timeout. `netstat -ano` streams its output and `findstr` filters
|
|
558
|
+
// line by line, completing in ~0.5s.
|
|
559
|
+
//
|
|
560
|
+
// `netstat -ano` lists every TCP connection twice — once for each
|
|
561
|
+
// endpoint. The format is:
|
|
562
|
+
//
|
|
563
|
+
// Proto LocalAddress RemoteAddress State PID
|
|
564
|
+
// TCP [::1]:25949 [::1]:55193 ESTABLISHED 10936 ← server-side row (proxy owns the listener)
|
|
565
|
+
// TCP [::1]:55193 [::1]:25949 ESTABLISHED 29356 ← client-side row (curl owns the ephemeral port)
|
|
566
|
+
//
|
|
567
|
+
// For `clientPort` (the client's ephemeral port) we want the row
|
|
568
|
+
// whose LOCAL column ends with `:<clientPort>` — that's the row
|
|
569
|
+
// attributed to the client process.
|
|
570
|
+
const { stdout } = await execAsync(
|
|
571
|
+
`netstat -ano | findstr :${clientPort} | findstr ESTABLISHED`,
|
|
572
|
+
{ shell: "cmd.exe", timeout: 2000, windowsHide: true },
|
|
573
|
+
);
|
|
574
|
+
const clientToken = `:${clientPort}`;
|
|
575
|
+
for (const rawLine of stdout.split("\n")) {
|
|
576
|
+
const line = rawLine.trim();
|
|
577
|
+
if (line === "") continue;
|
|
578
|
+
const cols = line.split(/\s+/);
|
|
579
|
+
if (cols.length < 5) continue;
|
|
580
|
+
const localAddr = cols[1] ?? "";
|
|
581
|
+
if (!localAddr.endsWith(clientToken)) continue;
|
|
582
|
+
const pidStr = cols[cols.length - 1] ?? "";
|
|
583
|
+
const parsed = parseInt(pidStr, 10);
|
|
584
|
+
if (Number.isInteger(parsed) && parsed > 0) return parsed;
|
|
585
|
+
}
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
// Unix: try `ss` first, fall back to `lsof`. Both need root or
|
|
589
|
+
// CAP_NET_ADMIN to see PIDs of processes owned by other users; when
|
|
590
|
+
// permission is denied the command exits non-zero and we return null.
|
|
591
|
+
try {
|
|
592
|
+
const { stdout } = await execAsync(
|
|
593
|
+
`ss -tanp state established 'sport = :${clientPort}' 2>/dev/null | tail -n +2 | head -n 1`,
|
|
594
|
+
{ shell: "/bin/sh" },
|
|
595
|
+
);
|
|
596
|
+
const match = stdout.match(/pid=(\d+)/);
|
|
597
|
+
if (match !== null) {
|
|
598
|
+
const parsed = parseInt(match[1] ?? "", 10);
|
|
599
|
+
if (Number.isInteger(parsed) && parsed > 0) return parsed;
|
|
600
|
+
}
|
|
601
|
+
} catch {
|
|
602
|
+
// fall through
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
const { stdout } = await execAsync(
|
|
606
|
+
`lsof -ti tcp:${clientPort} -sTCP:ESTABLISHED 2>/dev/null`,
|
|
607
|
+
{
|
|
608
|
+
shell: "/bin/sh",
|
|
609
|
+
},
|
|
610
|
+
);
|
|
611
|
+
const trimmed = stdout.trim().split("\n")[0] ?? "";
|
|
612
|
+
const parsed = parseInt(trimmed, 10);
|
|
613
|
+
if (Number.isInteger(parsed) && parsed > 0) return parsed;
|
|
614
|
+
} catch {
|
|
615
|
+
// fall through
|
|
616
|
+
}
|
|
617
|
+
return null;
|
|
618
|
+
} catch (err) {
|
|
619
|
+
logger.debug(`[socketTracker] lookupPidByLocalPort(${clientPort}) failed:`, String(err));
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{R as s,j as e}from"./main-D9YPHKL0.js";import{P as i}from"./ProxyViewerContainer-6mRRv0F1.js";function t(){const{sessionId:o}=s.useParams();return e.jsx(i,{initialSessionId:o},o)}export{t as component};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{P as o}from"./ProxyViewerContainer-6mRRv0F1.js";import"./main-D9YPHKL0.js";const r=o;export{r as component};
|