@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.
- package/.output/cli.js +14671 -397
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-BaGKK2R7.js → CompareDrawer-CcwXsZp3.js} +1 -1
- package/.output/public/assets/{ProxyViewerContainer-Cxwizw8H.js → ProxyViewerContainer-DFFxBlvO.js} +17 -17
- package/.output/public/assets/{ReplayDialog-Io8Ufsif.js → ReplayDialog-XXiwmD5o.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-CiZE_fKZ.js → RequestAnatomy-DTCh37so.js} +1 -1
- package/.output/public/assets/{ResponseView-TJwxn-lh.js → ResponseView-CbFYw9xP.js} +1 -1
- package/.output/public/assets/{StreamingChunkSequence-D-9ysFSG.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-JvXpk_ov.js → main-C-lua4Uq.js} +2 -2
- package/.output/server/{_sessionId-DSZ76cjr.mjs → _sessionId-CO0WzOBc.mjs} +2 -2
- package/.output/server/_ssr/{CompareDrawer-BcUJ7lNL.mjs → CompareDrawer-_OnPOi0O.mjs} +2 -2
- package/.output/server/_ssr/{ProxyViewerContainer-DEtSh7OB.mjs → ProxyViewerContainer-Bfy-cjD3.mjs} +23 -25
- package/.output/server/_ssr/{ReplayDialog-DwSynSXP.mjs → ReplayDialog-DBZyQFFR.mjs} +3 -3
- package/.output/server/_ssr/{RequestAnatomy-C6LGadAw.mjs → RequestAnatomy-C7m6HJOG.mjs} +2 -2
- package/.output/server/_ssr/{ResponseView-Dr27DXbx.mjs → ResponseView-CM2nFoqa.mjs} +2 -2
- package/.output/server/_ssr/{StreamingChunkSequence-CDTaUWjj.mjs → StreamingChunkSequence-Dj61jLHa.mjs} +2 -2
- package/.output/server/_ssr/{index-D803rHfz.mjs → index-BI_nCxdk.mjs} +2 -2
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-D7dDibPa.mjs → router-WBmANYRw.mjs} +3 -3
- package/.output/server/{_tanstack-start-manifest_v-P9lKyaOf.mjs → _tanstack-start-manifest_v-lODJPovZ.mjs} +1 -1
- package/.output/server/index.mjs +58 -58
- 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 +122 -0
- package/.output/public/assets/_sessionId-3JCekYV3.js +0 -1
- 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};
|