@karpeleslab/teamclaude 1.0.6 → 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.
- package/LICENSE +21 -0
- package/README.md +176 -16
- package/package.json +7 -2
- package/src/account-manager.js +382 -24
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +123 -0
- package/src/config.js +26 -0
- package/src/h2/frames.js +83 -0
- package/src/h2/hpack.js +314 -0
- package/src/h2/relay.js +417 -0
- package/src/identity.js +65 -0
- package/src/index.js +521 -91
- package/src/json-format-stream.js +65 -0
- package/src/mitm.js +387 -0
- package/src/oauth.js +77 -1
- package/src/prober.js +82 -0
- package/src/request-log.js +194 -0
- package/src/server.js +166 -88
- package/src/sx.js +218 -0
- package/src/tui.js +231 -17
- package/src/upstream-fetch.js +85 -0
- package/src/x509.js +166 -0
package/src/h2/relay.js
ADDED
|
@@ -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
|
+
}
|
package/src/identity.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Account identity helpers.
|
|
2
|
+
//
|
|
3
|
+
// An OAuth account is identified by its Anthropic account UUID (the *person*)
|
|
4
|
+
// plus the organization it is scoped to. The same email/person can belong to
|
|
5
|
+
// multiple organizations — e.g. a corporate Pro org and a personal Max org —
|
|
6
|
+
// each with its own OAuth token and quota. The org must therefore be part of
|
|
7
|
+
// the identity; otherwise multi-org logins overwrite each other, removals match
|
|
8
|
+
// the wrong entry, and token rotation persists onto the wrong account.
|
|
9
|
+
//
|
|
10
|
+
// The org discriminator prefers the org UUID but falls back to the org name
|
|
11
|
+
// (the profile endpoint has always returned a name), so identity still works on
|
|
12
|
+
// entries created before org UUIDs were stored.
|
|
13
|
+
|
|
14
|
+
/** Stable org discriminator for an account record: org UUID, else org name, else null. */
|
|
15
|
+
export function orgKey(acct) {
|
|
16
|
+
return acct?.orgUuid || acct?.orgName || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether two account records refer to the same account+org.
|
|
21
|
+
*
|
|
22
|
+
* - Both have an accountUuid: it must match. If both org keys are known they
|
|
23
|
+
* must also match; but if either side's org is still unknown we treat them as
|
|
24
|
+
* the same. This lets a freshly-profiled login backfill a legacy entry (which
|
|
25
|
+
* has no stored org) instead of creating a duplicate. Once both sides carry an
|
|
26
|
+
* org key, a *different* org is correctly seen as a distinct account.
|
|
27
|
+
* - Otherwise (API-key accounts, or no UUID yet): fall back to matching by name.
|
|
28
|
+
*/
|
|
29
|
+
export function sameIdentity(a, b) {
|
|
30
|
+
if (a?.accountUuid && b?.accountUuid) {
|
|
31
|
+
if (a.accountUuid !== b.accountUuid) return false;
|
|
32
|
+
const ka = orgKey(a);
|
|
33
|
+
const kb = orgKey(b);
|
|
34
|
+
if (ka && kb) return ka === kb;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return a?.name === b?.name;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The email portion of a display name, stripping any " (org)" suffix. */
|
|
41
|
+
export function emailOf(acct) {
|
|
42
|
+
return (acct?.name || '').replace(/ \(.*\)$/, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Find accounts matching a name-or-email query, optionally narrowed by org.
|
|
47
|
+
*
|
|
48
|
+
* An exact display-name match wins outright. Otherwise match by email (so
|
|
49
|
+
* `remove user@x.com` finds `user@x.com (Acme)`). `orgFilter` narrows by org
|
|
50
|
+
* name or org UUID (prefix allowed). Returns the array of matches; the caller
|
|
51
|
+
* decides what to do with 0, 1, or many.
|
|
52
|
+
*/
|
|
53
|
+
export function matchAccounts(accounts, query, orgFilter) {
|
|
54
|
+
let matches = accounts.filter(a => a.name === query);
|
|
55
|
+
if (matches.length === 0) {
|
|
56
|
+
matches = accounts.filter(a => emailOf(a) === query);
|
|
57
|
+
}
|
|
58
|
+
if (orgFilter) {
|
|
59
|
+
matches = matches.filter(a =>
|
|
60
|
+
(a.orgName && a.orgName === orgFilter) ||
|
|
61
|
+
(a.orgUuid && (a.orgUuid === orgFilter || a.orgUuid.startsWith(orgFilter)))
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return matches;
|
|
65
|
+
}
|