@tonyclaw/agent-inspector 2.1.10 → 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.
Files changed (30) hide show
  1. package/.output/cli.js +14671 -397
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-BaGKK2R7.js → CompareDrawer-CcwXsZp3.js} +1 -1
  4. package/.output/public/assets/{ProxyViewerContainer-Cxwizw8H.js → ProxyViewerContainer-DFFxBlvO.js} +17 -17
  5. package/.output/public/assets/{ReplayDialog-Io8Ufsif.js → ReplayDialog-XXiwmD5o.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-CiZE_fKZ.js → RequestAnatomy-DTCh37so.js} +1 -1
  7. package/.output/public/assets/{ResponseView-TJwxn-lh.js → ResponseView-CbFYw9xP.js} +1 -1
  8. package/.output/public/assets/{StreamingChunkSequence-D-9ysFSG.js → StreamingChunkSequence-CTLMzUxV.js} +1 -1
  9. package/.output/public/assets/_sessionId-DAwcZWfu.js +1 -0
  10. package/.output/public/assets/index-B6sDoCll.js +1 -0
  11. package/.output/public/assets/{main-JvXpk_ov.js → main-C-lua4Uq.js} +2 -2
  12. package/.output/server/{_sessionId-DSZ76cjr.mjs → _sessionId-CO0WzOBc.mjs} +2 -2
  13. package/.output/server/_ssr/{CompareDrawer-BcUJ7lNL.mjs → CompareDrawer-_OnPOi0O.mjs} +2 -2
  14. package/.output/server/_ssr/{ProxyViewerContainer-DEtSh7OB.mjs → ProxyViewerContainer-Bfy-cjD3.mjs} +23 -25
  15. package/.output/server/_ssr/{ReplayDialog-DwSynSXP.mjs → ReplayDialog-DBZyQFFR.mjs} +3 -3
  16. package/.output/server/_ssr/{RequestAnatomy-C6LGadAw.mjs → RequestAnatomy-C7m6HJOG.mjs} +2 -2
  17. package/.output/server/_ssr/{ResponseView-Dr27DXbx.mjs → ResponseView-CM2nFoqa.mjs} +2 -2
  18. package/.output/server/_ssr/{StreamingChunkSequence-CDTaUWjj.mjs → StreamingChunkSequence-Dj61jLHa.mjs} +2 -2
  19. package/.output/server/_ssr/{index-D803rHfz.mjs → index-BI_nCxdk.mjs} +2 -2
  20. package/.output/server/_ssr/index.mjs +2 -2
  21. package/.output/server/_ssr/{router-D7dDibPa.mjs → router-WBmANYRw.mjs} +3 -3
  22. package/.output/server/{_tanstack-start-manifest_v-P9lKyaOf.mjs → _tanstack-start-manifest_v-lODJPovZ.mjs} +1 -1
  23. package/.output/server/index.mjs +58 -58
  24. package/package.json +1 -1
  25. package/src/cli.ts +89 -0
  26. package/src/components/proxy-viewer/LogEntryHeader.tsx +25 -26
  27. package/src/proxy/identityProxy.ts +193 -0
  28. package/src/proxy/socketTracker.ts +122 -0
  29. package/.output/public/assets/_sessionId-3JCekYV3.js +0 -1
  30. package/.output/public/assets/index-B3EOwfg7.js +0 -1
@@ -0,0 +1,193 @@
1
+ import http from "node:http";
2
+ import { Buffer } from "node:buffer";
3
+ import { lookupClientByPort } from "./socketTracker";
4
+
5
+ const IDENTITY_HEADERS = {
6
+ pid: "x-agent-inspector-client-pid",
7
+ port: "x-agent-inspector-client-port",
8
+ cwd: "x-agent-inspector-client-cwd",
9
+ projectFolder: "x-agent-inspector-client-project-folder",
10
+ } as const;
11
+
12
+ const REQUEST_HOP_BY_HOP = new Set([
13
+ "host",
14
+ "connection",
15
+ "keep-alive",
16
+ "proxy-authenticate",
17
+ "proxy-authorization",
18
+ "te",
19
+ "trailers",
20
+ "transfer-encoding",
21
+ "upgrade",
22
+ "content-length",
23
+ ]);
24
+
25
+ const RESPONSE_HOP_BY_HOP = new Set([
26
+ "connection",
27
+ "keep-alive",
28
+ "proxy-authenticate",
29
+ "proxy-authorization",
30
+ "te",
31
+ "trailers",
32
+ "transfer-encoding",
33
+ "upgrade",
34
+ ]);
35
+
36
+ export type IdentityProxyOptions = {
37
+ /** Port to listen on (default 25949). Clients point at this. */
38
+ listenPort: number;
39
+ /** Host the upstream TanStack Start server listens on. */
40
+ upstreamHost: string;
41
+ /** Port the upstream TanStack Start server listens on (default 25947). */
42
+ upstreamPort: number;
43
+ };
44
+
45
+ /**
46
+ * Start a tiny native Node HTTP server that:
47
+ * 1. Reads the client connection's source port (`req.socket.remotePort`).
48
+ * 2. Resolves that port to a PID via the OS connection table
49
+ * (`Get-NetTCPConnection -LocalPort <port>` on Windows,
50
+ * `ss -tanp state established` on Linux/macOS).
51
+ * 3. Resolves the PID's cwd / project folder.
52
+ * 4. Injects everything as `X-Agent-Inspector-Client-*` headers.
53
+ * 5. Reverse-proxies the request to the upstream inspector (TanStack
54
+ * Start, which already reads those headers via `getClientInfo`).
55
+ *
56
+ * Why this exists: TanStack Start strips the underlying TCP socket from
57
+ * the fetch-API `Request` before it reaches the proxy handler, so the
58
+ * handler cannot read `socket.remotePort`. Sitting in front of TanStack
59
+ * Start as a native Node server restores the ability to look up the
60
+ * client PID at the socket level — no wrapper script, no client-side
61
+ * changes, no header configuration by the AI tool author.
62
+ *
63
+ * The proxy degrades gracefully when the OS lookup fails (missing
64
+ * permissions, port not found): the request is still forwarded, just
65
+ * without identity headers, so it falls back to the existing
66
+ * best-effort scan inside TanStack Start.
67
+ */
68
+ export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.Server> {
69
+ return new Promise((resolve, reject) => {
70
+ const server = http.createServer();
71
+ // Eagerly resolve client identity on `connection` — at that point the
72
+ // socket is fresh and the OS connection table still lists it as
73
+ // ESTABLISHED. By the time `request` fires (let alone after we finish
74
+ // reading the body) HTTP/1.0 clients have already closed their side
75
+ // and the row is in TIME_WAIT with PID 0.
76
+ server.on("connection", (socket: import("node:net").Socket) => {
77
+ const remotePort = socket.remotePort;
78
+ if (remotePort !== undefined && remotePort > 0) {
79
+ void lookupClientByPort(remotePort).catch(() => null);
80
+ }
81
+ });
82
+ server.on("request", (req, res) => {
83
+ void handleRequest(req, res, options).catch((err: unknown) => {
84
+ const message = err instanceof Error ? err.message : String(err);
85
+ if (!res.headersSent) {
86
+ res.writeHead(502, { "content-type": "text/plain" });
87
+ }
88
+ res.end(`Identity proxy error: ${message}`);
89
+ });
90
+ });
91
+ server.once("error", reject);
92
+ server.listen(options.listenPort, () => {
93
+ server.removeListener("error", reject);
94
+ resolve(server);
95
+ });
96
+ });
97
+ }
98
+
99
+ async function handleRequest(
100
+ req: http.IncomingMessage,
101
+ res: http.ServerResponse,
102
+ options: IdentityProxyOptions,
103
+ ): Promise<void> {
104
+ // The connection-time lookup already populated the per-port cache; this
105
+ // call now hits the cache and returns the result synchronously.
106
+ const remotePort = req.socket.remotePort;
107
+ let identity: Awaited<ReturnType<typeof lookupClientByPort>> | null = null;
108
+ if (remotePort !== undefined && remotePort > 0) {
109
+ try {
110
+ identity = await lookupClientByPort(remotePort);
111
+ } catch {
112
+ identity = null;
113
+ }
114
+ }
115
+
116
+ // Buffer the request body — TanStack Start needs the raw bytes intact
117
+ // for SSE/streaming endpoints, so we can't decode-and-re-encode.
118
+ const chunks: Buffer[] = [];
119
+ for await (const chunk of req) {
120
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : toBuffer(chunk));
121
+ }
122
+ const body = Buffer.concat(chunks);
123
+
124
+ const upstreamHeaders: Record<string, string | string[]> = {};
125
+ for (const [name, value] of Object.entries(req.headers)) {
126
+ if (REQUEST_HOP_BY_HOP.has(name.toLowerCase())) continue;
127
+ if (value === undefined) continue;
128
+ upstreamHeaders[name] = value;
129
+ }
130
+ if (identity !== null && identity.pid !== null) {
131
+ upstreamHeaders[IDENTITY_HEADERS.pid] = String(identity.pid);
132
+ if (identity.port !== null) {
133
+ upstreamHeaders[IDENTITY_HEADERS.port] = String(identity.port);
134
+ }
135
+ if (identity.cwd !== null) {
136
+ upstreamHeaders[IDENTITY_HEADERS.cwd] = identity.cwd;
137
+ }
138
+ if (identity.projectFolder !== null) {
139
+ upstreamHeaders[IDENTITY_HEADERS.projectFolder] = identity.projectFolder;
140
+ }
141
+ }
142
+
143
+ process.stderr.write(
144
+ `[identity-proxy] req ${req.method} ${req.url} remotePort=${remotePort} pid=${identity?.pid ?? "?"}\n`,
145
+ );
146
+ process.stderr.write(
147
+ `[identity-proxy] forwarding headers: pid=${upstreamHeaders[IDENTITY_HEADERS.pid] ?? "?"} cwd=${upstreamHeaders[IDENTITY_HEADERS.cwd] ?? "?"}\n`,
148
+ );
149
+
150
+ const proxyReq = http.request(
151
+ {
152
+ hostname: options.upstreamHost,
153
+ port: options.upstreamPort,
154
+ path: req.url ?? "/",
155
+ method: req.method,
156
+ headers: upstreamHeaders,
157
+ },
158
+ (proxyRes) => {
159
+ const outHeaders: Record<string, string | string[]> = {};
160
+ for (const [name, value] of Object.entries(proxyRes.headers)) {
161
+ if (RESPONSE_HOP_BY_HOP.has(name.toLowerCase())) continue;
162
+ if (value === undefined) continue;
163
+ outHeaders[name] = value;
164
+ }
165
+ res.writeHead(proxyRes.statusCode ?? 502, outHeaders);
166
+ proxyRes.pipe(res);
167
+ },
168
+ );
169
+ proxyReq.on("error", (err) => {
170
+ if (!res.headersSent) {
171
+ res.writeHead(502, { "content-type": "text/plain" });
172
+ }
173
+ res.end(`Upstream error: ${err.message}`);
174
+ });
175
+ if (body.length > 0) proxyReq.write(body);
176
+ proxyReq.end();
177
+ }
178
+
179
+ // `Readable` chunks in Node are typed as `any` (they can be Buffer | string
180
+ // depending on the encoding setting). We narrow them via runtime checks.
181
+ function toBuffer(chunk: unknown): Buffer {
182
+ if (Buffer.isBuffer(chunk)) return chunk;
183
+ if (typeof chunk === "string") return Buffer.from(chunk);
184
+ // `for await` over a Node Readable always yields Buffer | string; this
185
+ // branch is only reachable if a custom stream returns something else
186
+ // (Uint8Array / ArrayBuffer / etc.). The Buffer constructor accepts an
187
+ // ArrayBuffer directly; we attempt that first, then fall back to a
188
+ // best-effort empty buffer so we never throw on an unexpected chunk.
189
+ if (chunk instanceof ArrayBuffer) return Buffer.from(chunk);
190
+ if (chunk instanceof Uint8Array)
191
+ return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
192
+ return Buffer.alloc(0);
193
+ }
@@ -498,3 +498,125 @@ export async function getClientInfo(request: Request): Promise<ClientInfo> {
498
498
  recentInflight = promise;
499
499
  return promise;
500
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-JvXpk_ov.js";import{P as i}from"./ProxyViewerContainer-Cxwizw8H.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-Cxwizw8H.js";import"./main-JvXpk_ov.js";const r=o;export{r as component};