agent-shim 7.1.2 → 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/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.1.2",
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>",