@karpeleslab/teamclaude 1.0.7 → 1.0.8

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.
@@ -0,0 +1,417 @@
1
+ // Transparent HTTP/2 relay for the MITM proxy.
2
+ //
3
+ // Bridges two already-decrypted h2 byte streams (claude ⇄ upstream). The
4
+ // request direction (claude→upstream) is parsed frame-by-frame: HEADERS/
5
+ // CONTINUATION blocks are HPACK-decoded, handed to `rewriteRequest` (which
6
+ // rewrites only the auth field), re-encoded, and re-framed; every other frame
7
+ // is forwarded verbatim. The response direction (upstream→claude) is passed
8
+ // through byte-for-byte and only *observed* (read-only HPACK decode) so we can
9
+ // surface `:status` + rate-limit headers for quota tracking.
10
+
11
+ import { readFrames, buildFrame, buildHeaderBlock, stripHeadersPayload, FRAME, FLAG, PREFACE } from './frames.js';
12
+ import { HpackDecoder, HpackEncoder } from './hpack.js';
13
+
14
+ const SETTINGS_HEADER_TABLE_SIZE = 0x1;
15
+
16
+ // Wire src→dst with backpressure; `onClose` fires once when either side ends.
17
+ function link(src, dst, onData, onClose) {
18
+ let closed = false;
19
+ const close = () => { if (closed) return; closed = true; onClose(); };
20
+ src.on('data', (chunk) => {
21
+ try { onData(chunk); } catch (err) { close(); src.destroy(err); }
22
+ });
23
+ src.on('end', close);
24
+ src.on('close', close);
25
+ src.on('error', close);
26
+ return { pauseSrc: () => src.pause(), resumeSrc: () => src.resume() };
27
+ }
28
+
29
+ function writeBackpressured(dst, buf, ctl) {
30
+ if (buf.length === 0) return;
31
+ if (!dst.write(buf)) {
32
+ ctl.pauseSrc();
33
+ dst.once('drain', () => ctl.resumeSrc());
34
+ }
35
+ }
36
+
37
+ /**
38
+ * @param claude decrypted duplex toward the client
39
+ * @param upstream decrypted duplex toward Anthropic
40
+ * @param opts.rewriteRequest (fields[]) => fields[] // mutate/return the header list
41
+ * @param opts.onResponseHeaders (fields[]) => void // observe response headers
42
+ * @param opts.log
43
+ */
44
+ export function h2Relay(claude, upstream, opts = {}) {
45
+ const rewriteRequest = opts.rewriteRequest || ((f) => f);
46
+ const onResponseHeaders = opts.onResponseHeaders || (() => {});
47
+ const makeBodyPatcher = opts.makeBodyPatcher || null; // () => { push(buf)->buf } per stream
48
+ const bodyPatchers = makeBodyPatcher ? new Map() : null; // streamId -> patcher
49
+ const tap = opts.tap || null; // optional request-logging tap (per streamId)
50
+ const log = opts.log || (() => {});
51
+
52
+ // Streams that have started (request headers seen) but not yet completed, so a
53
+ // mid-flight connection teardown can close their tap records instead of
54
+ // leaking them (e.g. a stuck "in-flight" entry in the TUI activity feed).
55
+ const openStreams = new Set();
56
+ const closeStream = (id) => { if (bodyPatchers) bodyPatchers.delete(id); tap?.end(id); openStreams.delete(id); };
57
+
58
+ const reqDec = new HpackDecoder(); // decodes claude's request blocks
59
+ const reqEnc = new HpackEncoder(); // re-encodes to upstream
60
+ reqEnc.dynamicIndexing = false; // independent of upstream's table size
61
+ const respDec = new HpackDecoder(); // read-only, decodes upstream responses
62
+
63
+ const destroyBoth = () => {
64
+ for (const id of openStreams) tap?.end(id);
65
+ openStreams.clear();
66
+ claude.destroy(); upstream.destroy();
67
+ };
68
+
69
+ // ── request direction: claude → upstream (rewrite HEADERS) ──
70
+ let rbuf = Buffer.alloc(0);
71
+ let prefaceSeen = false;
72
+ let asm = null; // { streamId, frags:[], priority, endStream } while assembling a block
73
+ let reqCtl;
74
+
75
+ const onReqData = (chunk) => {
76
+ rbuf = Buffer.concat([rbuf, chunk]);
77
+ if (!prefaceSeen) {
78
+ if (rbuf.length < PREFACE.length) return;
79
+ writeBackpressured(upstream, rbuf.subarray(0, PREFACE.length), reqCtl); // forward preface verbatim
80
+ rbuf = rbuf.subarray(PREFACE.length);
81
+ prefaceSeen = true;
82
+ }
83
+ const { frames, rest } = readFrames(rbuf);
84
+ rbuf = rest;
85
+ for (const fr of frames) handleReqFrame(fr);
86
+ };
87
+
88
+ function handleReqFrame(fr) {
89
+ // Mid-block: only CONTINUATION on the same stream may follow (RFC 7540 §6.10).
90
+ if (asm) {
91
+ if (fr.type === FRAME.CONTINUATION && fr.streamId === asm.streamId) {
92
+ asm.frags.push(Buffer.from(fr.payload));
93
+ if (fr.flags & FLAG.END_HEADERS) finishReqBlock();
94
+ return;
95
+ }
96
+ // Shouldn't happen; bail safely.
97
+ throw new Error('interleaved frame during header block');
98
+ }
99
+ if (fr.type === FRAME.HEADERS) {
100
+ const { block, priority } = stripHeadersPayload(fr.payload, fr.flags);
101
+ asm = { streamId: fr.streamId, frags: [block], priority, endStream: !!(fr.flags & FLAG.END_STREAM) };
102
+ if (fr.flags & FLAG.END_HEADERS) finishReqBlock();
103
+ return;
104
+ }
105
+ if (fr.type === FRAME.DATA && (bodyPatchers || tap)) {
106
+ // Same-length in-place body patch (account_uuid) via a per-stream streaming
107
+ // JSON state machine; re-emit the DATA frame unchanged in length/flags so
108
+ // framing & flow control are preserved.
109
+ let payload = Buffer.from(fr.payload);
110
+ if (bodyPatchers) {
111
+ let p = bodyPatchers.get(fr.streamId);
112
+ if (!p) { p = makeBodyPatcher(); bodyPatchers.set(fr.streamId, p); }
113
+ payload = p.push(payload);
114
+ }
115
+ if (tap) tap.reqData(fr.streamId, payload);
116
+ writeBackpressured(upstream, buildFrame({ type: FRAME.DATA, flags: fr.flags, streamId: fr.streamId, payload }), reqCtl);
117
+ if (fr.flags & FLAG.END_STREAM && bodyPatchers) bodyPatchers.delete(fr.streamId);
118
+ return;
119
+ }
120
+ if (fr.type === FRAME.RST_STREAM) { closeStream(fr.streamId); }
121
+ if (fr.type === FRAME.SETTINGS && fr.streamId === 0 && !(fr.flags & 0x1)) {
122
+ applyTableSizeSetting(fr.payload, respDec); // claude's setting governs response encoding
123
+ }
124
+ writeBackpressured(upstream, fr.raw, reqCtl); // everything else: verbatim
125
+ }
126
+
127
+ function finishReqBlock() {
128
+ const { streamId, frags, priority, endStream } = asm;
129
+ asm = null;
130
+ const fields = reqDec.decode(Buffer.concat(frags)); // keep decoder dynamic table in sync
131
+ const rewritten = rewriteRequest(fields);
132
+ if (tap) tap.req(streamId, rewritten);
133
+ openStreams.add(streamId);
134
+ const newBlock = reqEnc.encode(rewritten);
135
+ writeBackpressured(upstream, buildHeaderBlock(streamId, newBlock, { endStream, priority }), reqCtl);
136
+ }
137
+
138
+ reqCtl = link(claude, upstream, onReqData, destroyBoth);
139
+
140
+ // ── response direction: upstream → claude (passthrough + observe) ──
141
+ let sbuf = Buffer.alloc(0);
142
+ let rasm = null;
143
+ let respCtl;
144
+
145
+ const onRespData = (chunk) => {
146
+ writeBackpressured(claude, chunk, respCtl); // verbatim passthrough first
147
+ sbuf = Buffer.concat([sbuf, chunk]);
148
+ const { frames, rest } = readFrames(sbuf);
149
+ sbuf = rest;
150
+ for (const fr of frames) observeRespFrame(fr);
151
+ };
152
+
153
+ function observeRespFrame(fr) {
154
+ if (rasm) {
155
+ if (fr.type === FRAME.CONTINUATION && fr.streamId === rasm.streamId) {
156
+ rasm.frags.push(Buffer.from(fr.payload));
157
+ if (fr.flags & FLAG.END_HEADERS) finishRespBlock(rasm.streamId);
158
+ }
159
+ return;
160
+ }
161
+ if (fr.type === FRAME.HEADERS) {
162
+ const { block } = stripHeadersPayload(fr.payload, fr.flags);
163
+ rasm = { streamId: fr.streamId, frags: [block] };
164
+ if (fr.flags & FLAG.END_HEADERS) finishRespBlock(fr.streamId);
165
+ if (fr.flags & FLAG.END_STREAM) closeStream(fr.streamId);
166
+ return;
167
+ }
168
+ if (fr.type === FRAME.DATA) {
169
+ if (tap) tap.resData(fr.streamId, Buffer.from(fr.payload));
170
+ if (fr.flags & FLAG.END_STREAM) closeStream(fr.streamId);
171
+ }
172
+ }
173
+
174
+ function finishRespBlock(streamId) {
175
+ const { frags } = rasm;
176
+ rasm = null;
177
+ try {
178
+ const fields = respDec.decode(Buffer.concat(frags));
179
+ onResponseHeaders(fields);
180
+ if (tap) tap.res(streamId, fields);
181
+ } catch (err) {
182
+ log(`[TeamClaude] h2 response header decode failed: ${err.message}`);
183
+ }
184
+ }
185
+
186
+ respCtl = link(upstream, claude, onRespData, destroyBoth);
187
+ }
188
+
189
+ const MAX_HEAD = 65536; // runaway-head guard for a single request/response head
190
+
191
+ // Parse an HTTP/1.1 message head: its start line + the body framing it declares.
192
+ // `chunked` wins over content-length per RFC 7230 §3.3.3.
193
+ function parseH1Head(headText) {
194
+ const lines = headText.split('\r\n');
195
+ let contentLength = null;
196
+ let chunked = false;
197
+ for (let i = 1; i < lines.length; i++) {
198
+ const line = lines[i];
199
+ if (line === '') break;
200
+ const c = line.indexOf(':');
201
+ if (c < 0) continue;
202
+ const name = line.slice(0, c).trim().toLowerCase();
203
+ const value = line.slice(c + 1).trim().toLowerCase();
204
+ if (name === 'transfer-encoding') { if (/(^|,)\s*chunked\s*$/.test(value)) chunked = true; }
205
+ else if (name === 'content-length') { const n = parseInt(value, 10); if (!Number.isNaN(n)) contentLength = n; }
206
+ }
207
+ return { startLine: lines[0] || '', contentLength, chunked };
208
+ }
209
+
210
+ // A streaming body-length tracker. process(buf) returns how many leading bytes of
211
+ // `buf` belong to the current message body and whether the body is complete; it
212
+ // keeps internal state across calls so a body split over many chunks is tracked
213
+ // exactly. `kind`: 'none' | 'length' | 'chunked' | 'until-close'.
214
+ function makeBodyTracker(kind, length = 0) {
215
+ if (kind === 'none') return () => ({ consumed: 0, done: true });
216
+ if (kind === 'until-close') return (buf) => ({ consumed: buf.length, done: false });
217
+ if (kind === 'length') {
218
+ let need = length;
219
+ return (buf) => { const take = Math.min(need, buf.length); need -= take; return { consumed: take, done: need === 0 }; };
220
+ }
221
+ // chunked: count framing bytes (size lines, data, trailing CRLFs, trailers)
222
+ let phase = 'size'; // size | data | dataCRLF | trailers
223
+ let need = 0; // bytes left in current chunk's data
224
+ let line = ''; // accumulates a CRLF-terminated control line across chunks
225
+ return (buf) => {
226
+ let i = 0;
227
+ while (i < buf.length) {
228
+ if (phase === 'size') {
229
+ const nl = buf.indexOf(0x0a, i);
230
+ if (nl < 0) { line += buf.toString('latin1', i); return { consumed: buf.length, done: false }; }
231
+ line += buf.toString('latin1', i, nl + 1); i = nl + 1;
232
+ const size = parseInt(line.trim().split(';')[0], 16); line = '';
233
+ if (Number.isNaN(size)) return { consumed: i, done: true }; // malformed: stop here
234
+ if (size === 0) phase = 'trailers'; else { need = size; phase = 'data'; }
235
+ } else if (phase === 'data') {
236
+ const take = Math.min(need, buf.length - i); i += take; need -= take;
237
+ if (need === 0) phase = 'dataCRLF';
238
+ } else if (phase === 'dataCRLF') {
239
+ const nl = buf.indexOf(0x0a, i);
240
+ if (nl < 0) return { consumed: buf.length, done: false };
241
+ i = nl + 1; phase = 'size';
242
+ } else { // trailers: read until a blank line ends the message
243
+ const nl = buf.indexOf(0x0a, i);
244
+ if (nl < 0) { line += buf.toString('latin1', i); return { consumed: buf.length, done: false }; }
245
+ const seg = line + buf.toString('latin1', i, nl + 1); line = ''; i = nl + 1;
246
+ if (seg === '\r\n' || seg === '\n') return { consumed: i, done: true };
247
+ }
248
+ }
249
+ return { consumed: i, done: false };
250
+ };
251
+ }
252
+
253
+ const methodOf = (startLine) => startLine.split(' ')[0].toUpperCase();
254
+ const statusOf = (startLine) => parseInt(startLine.split(' ')[1], 10) || 0;
255
+
256
+ /**
257
+ * Faithful HTTP/1.1 relay. Frames every request/response on a keep-alive
258
+ * connection (parsing content-length / chunked bodies), so each request's auth
259
+ * line is rewritten via `rewriteHead`, each request body is patched, and each
260
+ * exchange is logged to its own tap record. Responses are written to claude
261
+ * verbatim first — parsing is observation-only, so a framing miss can never
262
+ * corrupt the relayed stream — and matched to requests in FIFO order (HTTP/1.1
263
+ * guarantees in-order responses).
264
+ *
265
+ * @param opts.rewriteHead (headText) => headText // rewrite each request head
266
+ * @param opts.onResponseHeaders (fields[]) => void // observe each response's headers
267
+ */
268
+ export function h1Relay(claude, upstream, opts = {}) {
269
+ const rewriteHead = opts.rewriteHead || ((h) => h);
270
+ const makeBodyPatcher = opts.makeBodyPatcher || null;
271
+ const onResponseHeaders = opts.onResponseHeaders || (() => {});
272
+ const tap = opts.tap || null;
273
+ const destroyBoth = () => { claude.destroy(); upstream.destroy(); };
274
+ claude.on('error', destroyBoth);
275
+ upstream.on('error', destroyBoth);
276
+
277
+ let nextId = 0;
278
+ const pending = []; // request ids awaiting a response head, in send order
279
+
280
+ // Close any tap records still open when the connection tears down.
281
+ const endOpen = () => { if (!tap) return; if (resId !== null) tap.end(resId); for (const p of pending) tap.end(p.id); pending.length = 0; };
282
+
283
+ // ── request direction: claude → upstream (rewrite head, patch + forward body) ──
284
+ let reqBuf = Buffer.alloc(0);
285
+ let reqPhase = 'head';
286
+ let reqTrack = null, reqPatcher = null, reqId = null;
287
+
288
+ const pumpReq = () => {
289
+ while (reqBuf.length) {
290
+ if (reqPhase === 'head') {
291
+ const idx = reqBuf.indexOf('\r\n\r\n');
292
+ if (idx < 0) { if (reqBuf.length > MAX_HEAD) destroyBoth(); return; }
293
+ const head = rewriteHead(reqBuf.subarray(0, idx + 4).toString('latin1'));
294
+ reqBuf = reqBuf.subarray(idx + 4);
295
+ const info = parseH1Head(head);
296
+ reqId = ++nextId;
297
+ pending.push({ id: reqId, method: methodOf(info.startLine) });
298
+ if (tap) tap.reqHead(reqId, head);
299
+ upstream.write(Buffer.from(head, 'latin1'));
300
+ const kind = info.chunked ? 'chunked' : (info.contentLength > 0 ? 'length' : 'none');
301
+ reqPatcher = makeBodyPatcher ? makeBodyPatcher() : null;
302
+ reqTrack = makeBodyTracker(kind, info.contentLength || 0);
303
+ reqPhase = 'body';
304
+ } else {
305
+ const { consumed, done } = reqTrack(reqBuf);
306
+ if (consumed > 0) {
307
+ let slice = Buffer.from(reqBuf.subarray(0, consumed));
308
+ reqBuf = reqBuf.subarray(consumed);
309
+ if (reqPatcher) slice = reqPatcher.push(slice); // same-length account_uuid patch
310
+ if (tap) tap.reqData(reqId, slice);
311
+ upstream.write(slice);
312
+ }
313
+ if (done) { reqPhase = 'head'; reqTrack = null; reqPatcher = null; }
314
+ else if (consumed === 0) return; // need more body bytes
315
+ }
316
+ }
317
+ };
318
+ claude.on('data', (c) => { reqBuf = Buffer.concat([reqBuf, c]); pumpReq(); });
319
+ claude.on('end', () => upstream.end());
320
+ claude.on('close', () => upstream.destroy());
321
+
322
+ // ── response direction: upstream → claude (verbatim passthrough + observe) ──
323
+ let resBuf = Buffer.alloc(0);
324
+ let resPhase = 'head';
325
+ let resTrack = null, resId = null;
326
+
327
+ const pumpRes = () => {
328
+ while (resBuf.length) {
329
+ if (resPhase === 'head') {
330
+ const idx = resBuf.indexOf('\r\n\r\n');
331
+ if (idx < 0) { if (resBuf.length > MAX_HEAD) resBuf = resBuf.subarray(resBuf.length - MAX_HEAD); return; }
332
+ const head = resBuf.subarray(0, idx + 4).toString('latin1');
333
+ resBuf = resBuf.subarray(idx + 4);
334
+ const info = parseH1Head(head);
335
+ const status = statusOf(info.startLine);
336
+ if (status >= 100 && status < 200) continue; // interim (e.g. 100-continue): no body, no request consumed
337
+ onResponseHeaders(headFields(head));
338
+ const req = pending.shift();
339
+ resId = req ? req.id : ++nextId;
340
+ if (tap) tap.resHead(resId, head);
341
+ const bodyless = req?.method === 'HEAD' || status === 204 || status === 304;
342
+ const kind = bodyless ? 'none'
343
+ : info.chunked ? 'chunked'
344
+ : info.contentLength !== null ? 'length'
345
+ : 'until-close';
346
+ resTrack = makeBodyTracker(kind, info.contentLength || 0);
347
+ resPhase = 'body';
348
+ } else {
349
+ const { consumed, done } = resTrack(resBuf);
350
+ if (consumed > 0) { if (tap) tap.resData(resId, Buffer.from(resBuf.subarray(0, consumed))); resBuf = resBuf.subarray(consumed); }
351
+ if (done) { if (tap) tap.end(resId); resId = null; resPhase = 'head'; resTrack = null; }
352
+ else if (consumed === 0) return;
353
+ }
354
+ }
355
+ };
356
+ upstream.on('data', (c) => {
357
+ claude.write(c); // faithful passthrough first
358
+ resBuf = Buffer.concat([resBuf, c]);
359
+ try { pumpRes(); } catch { resBuf = Buffer.alloc(0); } // never let a parse bug break the relay
360
+ });
361
+ upstream.on('end', () => { endOpen(); claude.end(); });
362
+ upstream.on('close', () => { endOpen(); claude.destroy(); });
363
+ }
364
+
365
+ // Parse an HTTP/1.1 head into an h2-style [{name,value}] list (lowercased names),
366
+ // so a response can feed the same quota observer the h2 path uses.
367
+ function headFields(headText) {
368
+ const out = [];
369
+ const lines = headText.split('\r\n');
370
+ out.push({ name: ':status', value: String(statusOf(lines[0] || '')) });
371
+ for (let i = 1; i < lines.length; i++) {
372
+ const line = lines[i];
373
+ if (line === '') break;
374
+ const c = line.indexOf(':');
375
+ if (c < 0) continue;
376
+ out.push({ name: line.slice(0, c).trim().toLowerCase(), value: line.slice(c + 1).trim() });
377
+ }
378
+ return out;
379
+ }
380
+
381
+ /** Rewrite an HTTP/1.1 request head: replace the Authorization line with
382
+ * `authValue` (or set x-api-key), and drop the other client-supplied key. */
383
+ export function rewriteH1Auth(headText, { authorization = null, apiKey = null }) {
384
+ const lines = headText.split('\r\n');
385
+ const out = [lines[0]]; // request line
386
+ let setAuth = false;
387
+ for (let i = 1; i < lines.length; i++) {
388
+ const line = lines[i];
389
+ if (line === '') { out.push(line); continue; }
390
+ const lower = line.toLowerCase();
391
+ if (lower.startsWith('x-api-key:')) continue;
392
+ if (lower.startsWith('authorization:')) {
393
+ if (authorization) { out.push(`authorization: ${authorization}`); setAuth = true; }
394
+ continue;
395
+ }
396
+ out.push(line);
397
+ }
398
+ // insert our credential just before the terminating blank line if not already set
399
+ if (!setAuth && (authorization || apiKey)) {
400
+ const blank = out.lastIndexOf('');
401
+ const hdr = authorization ? `authorization: ${authorization}` : `x-api-key: ${apiKey}`;
402
+ out.splice(blank, 0, hdr);
403
+ }
404
+ return out.join('\r\n');
405
+ }
406
+
407
+ // Parse a SETTINGS payload for HEADER_TABLE_SIZE and apply it to a decoder's
408
+ // size limit (so it stays in sync with the announcing peer's encoder).
409
+ function applyTableSizeSetting(payload, decoder) {
410
+ for (let i = 0; i + 6 <= payload.length; i += 6) {
411
+ if (payload.readUInt16BE(i) === SETTINGS_HEADER_TABLE_SIZE) {
412
+ const size = payload.readUInt32BE(i + 2);
413
+ decoder.sizeLimit = size;
414
+ decoder.table.setMaxSize(Math.min(size, decoder.table.maxSize));
415
+ }
416
+ }
417
+ }