agent-shim 7.1.3 → 7.2.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/dist/cli.cjs +451 -324
- package/dist/index.cjs +25 -4
- package/dist/index.mjs +25 -4
- package/dist/types/frontdoor/capture.d.ts +45 -0
- package/dist/types/frontdoor/connect.d.ts +8 -5
- package/dist/types/usage/schema.d.ts +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -2440,9 +2440,9 @@ async function startConnectServer(config, effects, preferredPort) {
|
|
|
2440
2440
|
config.serveRouted(request3, response);
|
|
2441
2441
|
return;
|
|
2442
2442
|
}
|
|
2443
|
-
effects.forwardHttp(config.upstream, request3, response);
|
|
2443
|
+
effects.forwardHttp(config.upstream, request3, response, config.capture?.observePassthrough(request3));
|
|
2444
2444
|
};
|
|
2445
|
-
const httpSession = effects.createHttpSession(handler);
|
|
2445
|
+
const httpSession = effects.createHttpSession(handler, config.capture === void 0 ? void 0 : (request3) => config.capture?.upgrade(request3));
|
|
2446
2446
|
const tlsAcceptor = effects.createTlsAcceptor(config.leafFor(config.interceptHost), (secure) => {
|
|
2447
2447
|
httpSession.serve(secure);
|
|
2448
2448
|
});
|
|
@@ -2595,6 +2595,7 @@ function routeConnect(socket, head, context) {
|
|
|
2595
2595
|
refuse(socket, REFUSAL.serviceUnavailable);
|
|
2596
2596
|
return;
|
|
2597
2597
|
}
|
|
2598
|
+
config.capture?.connect(target, isInterceptedHost(target.host, config.interceptHost));
|
|
2598
2599
|
socket.write(CONNECT_ESTABLISHED);
|
|
2599
2600
|
if (isInterceptedHost(target.host, config.interceptHost)) {
|
|
2600
2601
|
tlsAcceptor.accept(socket);
|
|
@@ -2691,11 +2692,17 @@ function realConnectEffects() {
|
|
|
2691
2692
|
}
|
|
2692
2693
|
};
|
|
2693
2694
|
},
|
|
2694
|
-
createHttpSession: (handler) => {
|
|
2695
|
+
createHttpSession: (handler, onUpgrade) => {
|
|
2695
2696
|
const server = http.createServer(handler);
|
|
2696
2697
|
server.on("clientError", (_error, socket) => {
|
|
2697
2698
|
socket.destroy();
|
|
2698
2699
|
});
|
|
2700
|
+
if (onUpgrade !== void 0) {
|
|
2701
|
+
server.on("upgrade", (request3, socket) => {
|
|
2702
|
+
onUpgrade(request3);
|
|
2703
|
+
socket.destroy();
|
|
2704
|
+
});
|
|
2705
|
+
}
|
|
2699
2706
|
return {
|
|
2700
2707
|
serve: (socket) => {
|
|
2701
2708
|
server.emit("connection", socket);
|
|
@@ -2713,7 +2720,7 @@ function realConnectEffects() {
|
|
|
2713
2720
|
});
|
|
2714
2721
|
socket.once("error", reject);
|
|
2715
2722
|
}),
|
|
2716
|
-
forwardHttp: (target, request3, response) => {
|
|
2723
|
+
forwardHttp: (target, request3, response, observer) => {
|
|
2717
2724
|
const headers = forwardableHeaders(request3.headers);
|
|
2718
2725
|
const options = {
|
|
2719
2726
|
host: target.host,
|
|
@@ -2733,8 +2740,14 @@ function realConnectEffects() {
|
|
|
2733
2740
|
}
|
|
2734
2741
|
}
|
|
2735
2742
|
const onUpstreamResponse = (upstreamResponse) => {
|
|
2743
|
+
observer?.onResponse(upstreamResponse.statusCode ?? HTTP_BAD_GATEWAY, upstreamResponse.headers);
|
|
2736
2744
|
response.writeHead(upstreamResponse.statusCode ?? HTTP_BAD_GATEWAY, forwardableHeaders(upstreamResponse.headers));
|
|
2737
2745
|
response.flushHeaders();
|
|
2746
|
+
if (observer !== void 0) {
|
|
2747
|
+
upstreamResponse.on("data", (chunk) => {
|
|
2748
|
+
observer.onResponseChunk(chunk);
|
|
2749
|
+
});
|
|
2750
|
+
}
|
|
2738
2751
|
upstreamResponse.pipe(response);
|
|
2739
2752
|
};
|
|
2740
2753
|
const upstream = target.tls ? https.request(options, onUpstreamResponse) : http.request(options, onUpstreamResponse);
|
|
@@ -2747,6 +2760,14 @@ function realConnectEffects() {
|
|
|
2747
2760
|
response.writeHead(HTTP_BAD_GATEWAY, { "content-type": "text/plain", "content-length": String(body.length) });
|
|
2748
2761
|
response.end(body);
|
|
2749
2762
|
});
|
|
2763
|
+
if (observer !== void 0) {
|
|
2764
|
+
request3.on("data", (chunk) => {
|
|
2765
|
+
observer.onRequestChunk(chunk);
|
|
2766
|
+
});
|
|
2767
|
+
response.on("close", () => {
|
|
2768
|
+
observer.onEnd();
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2750
2771
|
request3.pipe(upstream);
|
|
2751
2772
|
request3.socket.setNoDelay(true);
|
|
2752
2773
|
upstream.on("socket", (socket) => {
|
package/dist/index.mjs
CHANGED
|
@@ -2366,9 +2366,9 @@ async function startConnectServer(config, effects, preferredPort) {
|
|
|
2366
2366
|
config.serveRouted(request3, response);
|
|
2367
2367
|
return;
|
|
2368
2368
|
}
|
|
2369
|
-
effects.forwardHttp(config.upstream, request3, response);
|
|
2369
|
+
effects.forwardHttp(config.upstream, request3, response, config.capture?.observePassthrough(request3));
|
|
2370
2370
|
};
|
|
2371
|
-
const httpSession = effects.createHttpSession(handler);
|
|
2371
|
+
const httpSession = effects.createHttpSession(handler, config.capture === void 0 ? void 0 : (request3) => config.capture?.upgrade(request3));
|
|
2372
2372
|
const tlsAcceptor = effects.createTlsAcceptor(config.leafFor(config.interceptHost), (secure) => {
|
|
2373
2373
|
httpSession.serve(secure);
|
|
2374
2374
|
});
|
|
@@ -2521,6 +2521,7 @@ function routeConnect(socket, head, context) {
|
|
|
2521
2521
|
refuse(socket, REFUSAL.serviceUnavailable);
|
|
2522
2522
|
return;
|
|
2523
2523
|
}
|
|
2524
|
+
config.capture?.connect(target, isInterceptedHost(target.host, config.interceptHost));
|
|
2524
2525
|
socket.write(CONNECT_ESTABLISHED);
|
|
2525
2526
|
if (isInterceptedHost(target.host, config.interceptHost)) {
|
|
2526
2527
|
tlsAcceptor.accept(socket);
|
|
@@ -2617,11 +2618,17 @@ function realConnectEffects() {
|
|
|
2617
2618
|
}
|
|
2618
2619
|
};
|
|
2619
2620
|
},
|
|
2620
|
-
createHttpSession: (handler) => {
|
|
2621
|
+
createHttpSession: (handler, onUpgrade) => {
|
|
2621
2622
|
const server = http.createServer(handler);
|
|
2622
2623
|
server.on("clientError", (_error, socket) => {
|
|
2623
2624
|
socket.destroy();
|
|
2624
2625
|
});
|
|
2626
|
+
if (onUpgrade !== void 0) {
|
|
2627
|
+
server.on("upgrade", (request3, socket) => {
|
|
2628
|
+
onUpgrade(request3);
|
|
2629
|
+
socket.destroy();
|
|
2630
|
+
});
|
|
2631
|
+
}
|
|
2625
2632
|
return {
|
|
2626
2633
|
serve: (socket) => {
|
|
2627
2634
|
server.emit("connection", socket);
|
|
@@ -2639,7 +2646,7 @@ function realConnectEffects() {
|
|
|
2639
2646
|
});
|
|
2640
2647
|
socket.once("error", reject);
|
|
2641
2648
|
}),
|
|
2642
|
-
forwardHttp: (target, request3, response) => {
|
|
2649
|
+
forwardHttp: (target, request3, response, observer) => {
|
|
2643
2650
|
const headers = forwardableHeaders(request3.headers);
|
|
2644
2651
|
const options = {
|
|
2645
2652
|
host: target.host,
|
|
@@ -2659,8 +2666,14 @@ function realConnectEffects() {
|
|
|
2659
2666
|
}
|
|
2660
2667
|
}
|
|
2661
2668
|
const onUpstreamResponse = (upstreamResponse) => {
|
|
2669
|
+
observer?.onResponse(upstreamResponse.statusCode ?? HTTP_BAD_GATEWAY, upstreamResponse.headers);
|
|
2662
2670
|
response.writeHead(upstreamResponse.statusCode ?? HTTP_BAD_GATEWAY, forwardableHeaders(upstreamResponse.headers));
|
|
2663
2671
|
response.flushHeaders();
|
|
2672
|
+
if (observer !== void 0) {
|
|
2673
|
+
upstreamResponse.on("data", (chunk) => {
|
|
2674
|
+
observer.onResponseChunk(chunk);
|
|
2675
|
+
});
|
|
2676
|
+
}
|
|
2664
2677
|
upstreamResponse.pipe(response);
|
|
2665
2678
|
};
|
|
2666
2679
|
const upstream = target.tls ? https.request(options, onUpstreamResponse) : http.request(options, onUpstreamResponse);
|
|
@@ -2673,6 +2686,14 @@ function realConnectEffects() {
|
|
|
2673
2686
|
response.writeHead(HTTP_BAD_GATEWAY, { "content-type": "text/plain", "content-length": String(body.length) });
|
|
2674
2687
|
response.end(body);
|
|
2675
2688
|
});
|
|
2689
|
+
if (observer !== void 0) {
|
|
2690
|
+
request3.on("data", (chunk) => {
|
|
2691
|
+
observer.onRequestChunk(chunk);
|
|
2692
|
+
});
|
|
2693
|
+
response.on("close", () => {
|
|
2694
|
+
observer.onEnd();
|
|
2695
|
+
});
|
|
2696
|
+
}
|
|
2676
2697
|
request3.pipe(upstream);
|
|
2677
2698
|
request3.socket.setNoDelay(true);
|
|
2678
2699
|
upstream.on("socket", (socket) => {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from "node:http";
|
|
2
|
+
/**
|
|
3
|
+
* How much of any one chunk is logged. A chunk excerpt only has to identify what the traffic is and carry the first frames of a stream, and 8 KiB is already the head budget this surface applies elsewhere (`MAX_CONNECT_HEAD_BYTES`), so one budget serves both notions of "a diagnostic excerpt".
|
|
4
|
+
*/
|
|
5
|
+
export declare const CHUNK_LOG_CAP_BYTES = 8192;
|
|
6
|
+
/**
|
|
7
|
+
* How many body bytes each direction of one request may log in total. Enough to hold a full Remote Control handshake and a long run of its frames, while bounding the file against an unbounded SSE stream that could otherwise grow it without limit. Bytes past the cap are counted, never stored.
|
|
8
|
+
*/
|
|
9
|
+
export declare const STREAM_LOG_CAP_BYTES = 262144;
|
|
10
|
+
/** The request facts a capture sees, shaped so both a real `IncomingMessage` and a test's plain object satisfy it. */
|
|
11
|
+
interface CapturedRequestShape {
|
|
12
|
+
readonly method?: string;
|
|
13
|
+
readonly url?: string;
|
|
14
|
+
readonly headers: IncomingHttpHeaders;
|
|
15
|
+
}
|
|
16
|
+
/** The callbacks one piped request's forwarding drives, all optional callers except, so a fake or a partial observer fits the same seam. */
|
|
17
|
+
export interface PassthroughObserver {
|
|
18
|
+
readonly onRequestChunk: (chunk: Buffer) => void;
|
|
19
|
+
readonly onResponse: (status: number, headers: IncomingHttpHeaders) => void;
|
|
20
|
+
readonly onResponseChunk: (chunk: Buffer) => void;
|
|
21
|
+
readonly onEnd: () => void;
|
|
22
|
+
}
|
|
23
|
+
/** The tap the connect surface drives. `undefined` everywhere means "not capturing"; one object covers the whole door process. */
|
|
24
|
+
export interface ConnectCapture {
|
|
25
|
+
/** Records one authenticated CONNECT target: the host and port only, since a blind tunnel's bytes are unreadable by construction. */
|
|
26
|
+
readonly connect: (target: {
|
|
27
|
+
readonly host: string;
|
|
28
|
+
readonly port: number;
|
|
29
|
+
}, intercepted: boolean) => void;
|
|
30
|
+
/** Records one non-routed request's head and returns the observer its forwarding drives, so the capture correlates every later chunk with the request it belongs to. */
|
|
31
|
+
readonly observePassthrough: (request: CapturedRequestShape) => PassthroughObserver;
|
|
32
|
+
/** Records one HTTP upgrade request arriving on a terminated session. The surface destroys the socket afterwards either way, with or without a capture, matching Node's own no-listener behaviour exactly. */
|
|
33
|
+
readonly upgrade: (request: CapturedRequestShape) => void;
|
|
34
|
+
}
|
|
35
|
+
/** Replaces every header value whose name looks like a credential with a marker, keeping every other header verbatim. Returns a fresh object. */
|
|
36
|
+
export declare function redactHeaders(headers: Readonly<IncomingHttpHeaders>): IncomingHttpHeaders;
|
|
37
|
+
/**
|
|
38
|
+
* Redacts one body excerpt for the capture file. A body that parses as JSON is redacted structurally (key-named credentials go even when their shape matches no pattern); anything else gets the substring patterns, which still catch the bearer and JWT forms an OAuth refresh or a Remote Control frame carries. Invalid UTF-8 decodes with replacement characters, which is fine: the excerpt exists to be read, not to be re-encoded.
|
|
39
|
+
*/
|
|
40
|
+
export declare function redactBody(chunk: Readonly<Buffer>): string;
|
|
41
|
+
/**
|
|
42
|
+
* Builds the file-backed capture: one JSON object per line, appended to `file` (conventionally `logs/frontdoor-capture.jsonl`), never read back by the door itself. The directory is created up front rather than per record, so a capture started against a fresh state root cannot race its own first append.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createFileCapture(file: string, now?: () => Date): ConnectCapture;
|
|
45
|
+
export {};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import * as net from "node:net";
|
|
3
|
+
import type { ConnectCapture, PassthroughObserver } from "./capture";
|
|
3
4
|
/**
|
|
4
5
|
* The front door's CONNECT surface, so an OAuth session can have agent-shim's routing AND Claude Code's Remote Control at once: Remote Control refuses any `ANTHROPIC_BASE_URL` other than the real API, but happily honours `HTTPS_PROXY`, so OAuth launches route at the proxy layer instead.
|
|
5
6
|
*
|
|
6
7
|
* Nothing is tunnelled or intercepted for a client that has not authenticated: every CONNECT request must present a live launch's capability as its proxy credential (`Proxy-Authorization: Basic`, which clients derive from the credential in the `HTTPS_PROXY` URL the launcher sets), or it is answered 407 before any target is dialled, so the port is no open proxy for other local processes. Pending and authenticated connections are bounded by `ConnectLimits`.
|
|
7
8
|
*
|
|
8
|
-
* Every authenticated CONNECT target is blind-tunnelled byte for byte EXCEPT the intercept host (Claude Code's own API), whose TLS this surface terminates with a leaf certificate signed by a locally generated CA. On the terminated session, the paths the front door routes (`/v1/`) are handed to the same ordered pipeline the provider listener serves (the client's Authorization header passes through untouched), and every other path is piped by this surface to the real upstream over TLS, so Remote Control's streaming, OAuth refreshes, and unknown endpoints bypass the pipeline entirely.
|
|
9
|
+
* Every authenticated CONNECT target is blind-tunnelled byte for byte EXCEPT the intercept host (Claude Code's own API), whose TLS this surface terminates with a leaf certificate signed by a locally generated CA. On the terminated session, the paths the front door routes (`/v1/`) are handed to the same ordered pipeline the provider listener serves (the client's Authorization header passes through untouched), and every other path is piped by this surface to the real upstream over TLS, so Remote Control's streaming, OAuth refreshes, and unknown endpoints bypass the pipeline entirely. When a `capture` is configured, those piped exchanges and every CONNECT target are recorded through it (see `./capture.ts`); the forwarding itself is identical either way.
|
|
9
10
|
*/
|
|
10
11
|
/** The one CONNECT host whose TLS gets terminated: Claude Code's own API, the upstream every OAuth session is really talking to. */
|
|
11
12
|
export declare const CONNECT_INTERCEPT_HOST = "api.anthropic.com";
|
|
@@ -132,12 +133,12 @@ export interface ConnectEffects {
|
|
|
132
133
|
readonly listenLoopback: (preferredPort: number | undefined, onSocket: (socket: net.Socket) => void) => Promise<ConnectListenerHandle>;
|
|
133
134
|
/** Builds the TLS terminator for one leaf, handing each successfully handshaked session to `onSecure`. */
|
|
134
135
|
readonly createTlsAcceptor: (leaf: LeafCert, onSecure: (secure: net.Socket) => void) => TlsAcceptor;
|
|
135
|
-
/** Builds the HTTP parser bound to one request handler. */
|
|
136
|
-
readonly createHttpSession: (handler: ConnectRequestHandler) => HttpParserSession;
|
|
136
|
+
/** Builds the HTTP parser bound to one request handler. `onUpgrade`, when given, sees every HTTP upgrade request arriving on a terminated session; the session destroys the socket afterwards either way, with or without one, matching Node's own no-listener behaviour. */
|
|
137
|
+
readonly createHttpSession: (handler: ConnectRequestHandler, onUpgrade?: (request: IncomingMessage) => void) => HttpParserSession;
|
|
137
138
|
/** Opens a raw TCP connection for a blind tunnel. */
|
|
138
139
|
readonly connectTcp: (host: string, port: number) => Promise<net.Socket>;
|
|
139
|
-
/** Streams one request to `target` and the response back, never buffering a body. */
|
|
140
|
-
readonly forwardHttp: (target: ConnectForwardTarget, request: IncomingMessage, response: ServerResponse) => void;
|
|
140
|
+
/** Streams one request to `target` and the response back, never buffering a body. When `observer` is given, the forwarding drives it with additive listeners (request and response chunks arrive to both the pipe and the observer) so capturing never changes the forwarding's own flow control. */
|
|
141
|
+
readonly forwardHttp: (target: ConnectForwardTarget, request: IncomingMessage, response: ServerResponse, observer?: PassthroughObserver) => void;
|
|
141
142
|
}
|
|
142
143
|
/** Everything the connect surface needs to route, resolved before it starts. */
|
|
143
144
|
export interface ConnectServerConfig {
|
|
@@ -155,6 +156,8 @@ export interface ConnectServerConfig {
|
|
|
155
156
|
readonly isLiveCapability: (token: string) => boolean;
|
|
156
157
|
/** The connection deadlines and caps; production passes `CONNECT_LIMITS`. */
|
|
157
158
|
readonly limits: ConnectLimits;
|
|
159
|
+
/** The diagnostic tap, when capturing is enabled; absent means nothing is recorded. */
|
|
160
|
+
readonly capture?: ConnectCapture;
|
|
158
161
|
}
|
|
159
162
|
/** A running connect surface. */
|
|
160
163
|
export interface ConnectServerHandle {
|
|
@@ -45,8 +45,8 @@ export declare const UsageRecordSchema: z.ZodObject<{
|
|
|
45
45
|
latencyMs: z.ZodNumber;
|
|
46
46
|
durationMs: z.ZodNumber;
|
|
47
47
|
outcome: z.ZodEnum<{
|
|
48
|
-
completed: "completed";
|
|
49
48
|
aborted: "aborted";
|
|
49
|
+
completed: "completed";
|
|
50
50
|
}>;
|
|
51
51
|
usage: z.ZodOptional<z.ZodObject<{
|
|
52
52
|
inputTokens: z.ZodOptional<z.ZodNumber>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-shim",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.2.0",
|
|
4
4
|
"description": "A profile manager and launcher for Claude Code that lets one person run multiple logins from one machine while controlling what gets shared between them.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Joseph Mearman <joseph@mearman.co.uk>",
|