@rikcodes/teamclaude 1.1.13-rik.1
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 +122 -0
- package/package.json +43 -0
- package/src/account-manager.js +1459 -0
- package/src/account-uuid-rewrite.js +115 -0
- package/src/alias.js +125 -0
- package/src/claude-env.js +65 -0
- package/src/config.js +146 -0
- package/src/crash-log.js +27 -0
- package/src/egress-guard.js +132 -0
- package/src/identity.js +96 -0
- package/src/index.js +1873 -0
- package/src/json-format-stream.js +63 -0
- package/src/mitm.js +336 -0
- package/src/model.js +276 -0
- package/src/oauth.js +459 -0
- package/src/prober.js +158 -0
- package/src/request-log.js +32 -0
- package/src/resolve-accounts.js +43 -0
- package/src/server.js +1319 -0
- package/src/service.js +241 -0
- package/src/session-tracker.js +133 -0
- package/src/status-renderer.js +316 -0
- package/src/sx.js +218 -0
- package/src/terminal-title.js +31 -0
- package/src/tool-pair-sanitize.js +193 -0
- package/src/tui-remote.js +274 -0
- package/src/tui.js +1634 -0
- package/src/updater.js +177 -0
- package/src/upstream-fetch.js +267 -0
- package/src/upstream-proxy.js +214 -0
- package/src/warmer.js +237 -0
- package/src/x509.js +166 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Streaming JSON pretty-printer (no regex, no buffering of the whole body).
|
|
2
|
+
//
|
|
3
|
+
// Built on the same idea as the account_uuid patcher: walk the bytes once,
|
|
4
|
+
// tracking only enough state (nesting depth, in-string, escape) to know where
|
|
5
|
+
// we are, and re-emit with indentation as we go. This lets the request logger
|
|
6
|
+
// flush a readable body to disk *as it streams* — so when a request blocks
|
|
7
|
+
// mid-flight, the partial (pretty) body is already on disk and you can see how
|
|
8
|
+
// far it got. Bodies can be ~1M tokens, so we never hold more than the current
|
|
9
|
+
// chunk.
|
|
10
|
+
//
|
|
11
|
+
// Whitespace outside strings is dropped and re-inserted; strings (including any
|
|
12
|
+
// whitespace/escapes inside them) are copied verbatim. Operates on latin1 so a
|
|
13
|
+
// multi-byte UTF-8 sequence split across chunks is preserved byte-for-byte.
|
|
14
|
+
export class JsonStreamFormatter {
|
|
15
|
+
constructor(indent = 2) {
|
|
16
|
+
this.pad = ' '.repeat(indent);
|
|
17
|
+
this.depth = 0;
|
|
18
|
+
this.inStr = false;
|
|
19
|
+
this.esc = false;
|
|
20
|
+
this.freshContainer = false; // just opened { or [ — first element needs a newline+indent
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
nl(depth) { return '\n' + this.pad.repeat(depth); }
|
|
24
|
+
|
|
25
|
+
// Feed a chunk; returns the formatted text for that chunk.
|
|
26
|
+
push(buf) {
|
|
27
|
+
const text = Buffer.isBuffer(buf) ? buf.toString('latin1') : String(buf);
|
|
28
|
+
let out = '';
|
|
29
|
+
for (let i = 0; i < text.length; i++) {
|
|
30
|
+
const ch = text[i];
|
|
31
|
+
|
|
32
|
+
if (this.inStr) {
|
|
33
|
+
out += ch;
|
|
34
|
+
if (this.esc) this.esc = false;
|
|
35
|
+
else if (ch === '\\') this.esc = true;
|
|
36
|
+
else if (ch === '"') this.inStr = false;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Outside a string: collapse existing whitespace; we re-insert our own.
|
|
41
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue;
|
|
42
|
+
|
|
43
|
+
if (ch === '}' || ch === ']') {
|
|
44
|
+
this.depth--;
|
|
45
|
+
// Empty container: emit "{}" / "[]" with no inner newline.
|
|
46
|
+
if (this.freshContainer) { this.freshContainer = false; out += ch; }
|
|
47
|
+
else out += this.nl(this.depth) + ch;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Any other token. If it's the first token inside a just-opened
|
|
52
|
+
// container, break the line and indent first.
|
|
53
|
+
if (this.freshContainer) { out += this.nl(this.depth); this.freshContainer = false; }
|
|
54
|
+
|
|
55
|
+
if (ch === '{' || ch === '[') { out += ch; this.depth++; this.freshContainer = true; continue; }
|
|
56
|
+
if (ch === ',') { out += ',' + this.nl(this.depth); continue; }
|
|
57
|
+
if (ch === ':') { out += ': '; continue; }
|
|
58
|
+
if (ch === '"') { this.inStr = true; out += ch; continue; }
|
|
59
|
+
out += ch; // number / true / false / null character
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/mitm.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// MITM forward-proxy support: local cert lifecycle + terminating CONNECT proxy.
|
|
2
|
+
//
|
|
3
|
+
// When a claude instance is launched with HTTPS_PROXY pointed at teamclaude it
|
|
4
|
+
// sends `CONNECT api.anthropic.com:443`. Rather than byte-relaying the tunnel, we
|
|
5
|
+
// TERMINATE it with a real Node HTTP/2 server (allowHTTP1, so an h1 client works
|
|
6
|
+
// too) presenting our locally-minted leaf, then forward each request with a
|
|
7
|
+
// buffering, retrying client — the SAME path the base proxy uses
|
|
8
|
+
// (createProxyRequestListener). That gives per-request account selection, body
|
|
9
|
+
// account_uuid rewriting, and — critically — the ability to resend a request on a
|
|
10
|
+
// different account when one returns a quota 429, instead of surfacing it. A host
|
|
11
|
+
// routing table decides per-CONNECT behavior:
|
|
12
|
+
// api.anthropic.com → terminate + forward, www.example.org → local test server,
|
|
13
|
+
// anything else → blind tunnel.
|
|
14
|
+
|
|
15
|
+
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
16
|
+
import { X509Certificate } from 'node:crypto';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import net from 'node:net';
|
|
19
|
+
import tls from 'node:tls';
|
|
20
|
+
import http2 from 'node:http2';
|
|
21
|
+
import { getConfigPath } from './config.js';
|
|
22
|
+
import { generateCertChain } from './x509.js';
|
|
23
|
+
import { createProxyRequestListener, safeKeyEqual, isLoopbackAddr, relayUpgrade, resolveAccountPin } from './server.js';
|
|
24
|
+
|
|
25
|
+
const CA_CERT = 'teamclaude-ca.pem';
|
|
26
|
+
const LEAF_CERT = 'teamclaude-leaf.pem';
|
|
27
|
+
const LEAF_KEY = 'teamclaude-leaf.key';
|
|
28
|
+
|
|
29
|
+
// A built-in host the MITM proxy always intercepts and answers itself (never
|
|
30
|
+
// forwarded upstream). Lets you verify the proxy + CA end-to-end with no
|
|
31
|
+
// credentials, e.g.:
|
|
32
|
+
// curl --proxy http://localhost:3456 --cacert <ca.pem> https://www.example.org/
|
|
33
|
+
export const TEST_HOST = 'www.example.org';
|
|
34
|
+
|
|
35
|
+
const certDir = () => dirname(getConfigPath());
|
|
36
|
+
const fpath = (n) => join(certDir(), n);
|
|
37
|
+
|
|
38
|
+
/** Path to the CA cert clients should trust via NODE_EXTRA_CA_CERTS. */
|
|
39
|
+
export function caCertPath() {
|
|
40
|
+
return fpath(CA_CERT);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readIf(p) {
|
|
44
|
+
try { return await readFile(p, 'utf8'); } catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function atomicWrite(path, data, mode) {
|
|
48
|
+
const tmp = `${path}.tmp${process.pid}`;
|
|
49
|
+
await writeFile(tmp, data, { mode });
|
|
50
|
+
await rename(tmp, path);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Is the stored leaf signed by the stored CA and valid for every host in `hosts`?
|
|
54
|
+
function leafCovers(caCertPem, leafCertPem, hosts) {
|
|
55
|
+
try {
|
|
56
|
+
const ca = new X509Certificate(caCertPem);
|
|
57
|
+
const leaf = new X509Certificate(leafCertPem);
|
|
58
|
+
if (!leaf.verify(ca.publicKey)) return false;
|
|
59
|
+
const names = (leaf.subjectAltName || '').split(',').map((s) => s.trim());
|
|
60
|
+
return hosts.every((h) => names.includes(`DNS:${h}`));
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Ensure a CA cert + a leaf for `host` exist in the config dir, generating them
|
|
68
|
+
* if missing/mismatched. The CA *private* key is never persisted — we regenerate
|
|
69
|
+
* the whole chain when needed, so the only on-disk secret is the leaf key (0600),
|
|
70
|
+
* which only authenticates as `host` to a process that already trusts our CA.
|
|
71
|
+
* Returns { caPath, caCertPem, leafCertPem, leafKeyPem }.
|
|
72
|
+
*/
|
|
73
|
+
export async function ensureCerts(host) {
|
|
74
|
+
const hosts = host === TEST_HOST ? [TEST_HOST] : [host, TEST_HOST];
|
|
75
|
+
const [caCertPem, leafCertPem, leafKeyPem] = await Promise.all([
|
|
76
|
+
readIf(fpath(CA_CERT)), readIf(fpath(LEAF_CERT)), readIf(fpath(LEAF_KEY)),
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
if (caCertPem && leafCertPem && leafKeyPem && leafCovers(caCertPem, leafCertPem, hosts)) {
|
|
80
|
+
return { caPath: fpath(CA_CERT), caCertPem, leafCertPem, leafKeyPem };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const chain = generateCertChain(hosts); // caKeyPem intentionally discarded
|
|
84
|
+
await mkdir(certDir(), { recursive: true });
|
|
85
|
+
await atomicWrite(fpath(CA_CERT), chain.caCertPem, 0o644);
|
|
86
|
+
await atomicWrite(fpath(LEAF_CERT), chain.leafCertPem, 0o644);
|
|
87
|
+
await atomicWrite(fpath(LEAF_KEY), chain.leafKeyPem, 0o600);
|
|
88
|
+
return {
|
|
89
|
+
caPath: fpath(CA_CERT),
|
|
90
|
+
caCertPem: chain.caCertPem,
|
|
91
|
+
leafCertPem: chain.leafCertPem,
|
|
92
|
+
leafKeyPem: chain.leafKeyPem,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function upstreamHostOf(config) {
|
|
97
|
+
try { return new URL(config?.upstream || 'https://api.anthropic.com').hostname; }
|
|
98
|
+
catch { return 'api.anthropic.com'; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Per-CONNECT behavior: 'rewrite' (intercept + token inject), 'test', or 'tunnel'. */
|
|
102
|
+
export function hostMode(host, config) {
|
|
103
|
+
if (host === TEST_HOST) return 'test';
|
|
104
|
+
if (host === upstreamHostOf(config)) return 'rewrite';
|
|
105
|
+
return 'tunnel';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build a `connect` event handler implementing the terminating MITM described at
|
|
110
|
+
* the top of this file.
|
|
111
|
+
* @param ensureLeaf async () => { key, cert } // current leaf PEMs
|
|
112
|
+
*/
|
|
113
|
+
export function createConnectHandler({ config, accountManager, ensureLeaf, logDir = null, hooks = {}, log = () => {}, sx = null, egress = null }) {
|
|
114
|
+
const upstream = config.upstream || 'https://api.anthropic.com';
|
|
115
|
+
const proxyApiKey = config.proxy?.apiKey;
|
|
116
|
+
const holdMs = (config.holdSeconds || 0) * 1000;
|
|
117
|
+
|
|
118
|
+
// One terminating h2/h1 server per pin, minted lazily on the first intercepted
|
|
119
|
+
// CONNECT that needs it (key '' = unpinned, the common case).
|
|
120
|
+
// TLS uses our leaf; ALPN negotiates h2 or http/1.1 (allowHTTP1) with whatever
|
|
121
|
+
// the client offers. It emits 'request' for BOTH protocols, so `forward` — the
|
|
122
|
+
// shared buffering/retrying proxy listener — handles them identically. Each
|
|
123
|
+
// CONNECT feeds it the raw tunnel socket; the client keeps the tunnel open and
|
|
124
|
+
// multiplexes many requests over it, each independently account-selected.
|
|
125
|
+
//
|
|
126
|
+
// Keying by pin is what carries a TC_ACCT pin from the CONNECT to the requests
|
|
127
|
+
// inside the tunnel. The alternative — tagging the raw socket and reading it
|
|
128
|
+
// back from the request — means digging through a TLSSocket and, under h2, a
|
|
129
|
+
// Proxy over the session socket. A listener bound to the account is the same
|
|
130
|
+
// information with none of that. The map is bounded by the account count.
|
|
131
|
+
const serverPromises = new Map();
|
|
132
|
+
const getServer = (pin = '') => {
|
|
133
|
+
let p = serverPromises.get(pin);
|
|
134
|
+
if (p) return p;
|
|
135
|
+
p = (async () => {
|
|
136
|
+
const { key, cert } = await ensureLeaf();
|
|
137
|
+
const srv = http2.createSecureServer({ key, cert, allowHTTP1: true });
|
|
138
|
+
srv.on('request', createProxyRequestListener({ accountManager, upstream, logDir, hooks, sx, holdMs, config, forcedPin: pin || null, egress }));
|
|
139
|
+
// Remote Control's real-time channel is a WebSocket (Upgrade handshake),
|
|
140
|
+
// which never fires 'request' — only 'upgrade', with a raw socket instead
|
|
141
|
+
// of a response object (h1-only; falls back to blind h2 passthrough is not
|
|
142
|
+
// needed since WS clients negotiate h1 for the handshake).
|
|
143
|
+
srv.on('upgrade', (req, socket, head) => relayUpgrade(req, socket, head, upstream, sx));
|
|
144
|
+
srv.on('sessionError', (e) => log(`[TeamClaude] MITM session error: ${e.message}`));
|
|
145
|
+
srv.on('clientError', (e, sock) => { try { sock.destroy(); } catch { /* already gone */ } });
|
|
146
|
+
return srv;
|
|
147
|
+
})().catch((err) => {
|
|
148
|
+
// Don't let a transient cert/disk failure poison the memo forever: drop it
|
|
149
|
+
// so the next intercepted CONNECT retries instead of re-awaiting a cached
|
|
150
|
+
// rejection (which would leave the MITM path dead until a restart).
|
|
151
|
+
serverPromises.delete(pin);
|
|
152
|
+
throw err;
|
|
153
|
+
});
|
|
154
|
+
serverPromises.set(pin, p);
|
|
155
|
+
return p;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return (req, clientSocket, head) => {
|
|
159
|
+
clientSocket.on('error', () => {});
|
|
160
|
+
|
|
161
|
+
// Auth gate — mirror the HTTP path: loopback is exempt, everything else must
|
|
162
|
+
// present the proxy apiKey via Proxy-Authorization. Without this, a remote
|
|
163
|
+
// client can CONNECT api.anthropic.com and have a rotated ACCOUNT TOKEN
|
|
164
|
+
// injected (token theft), or blind-tunnel to arbitrary hosts (open relay /
|
|
165
|
+
// SSRF) — the HTTP path already blocks the equivalent for remote clients.
|
|
166
|
+
if (!connectAuthorized(req, clientSocket, proxyApiKey)) {
|
|
167
|
+
try {
|
|
168
|
+
clientSocket.write('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="teamclaude"\r\nConnection: close\r\n\r\n');
|
|
169
|
+
} catch { /* client already gone */ }
|
|
170
|
+
clientSocket.destroy();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const [host, portStr] = (req.url || '').split(':');
|
|
175
|
+
const port = parseInt(portStr, 10) || 443;
|
|
176
|
+
const mode = hostMode(host, config);
|
|
177
|
+
|
|
178
|
+
if (mode === 'tunnel') {
|
|
179
|
+
// Until the upstream connects we still owe the client a CONNECT status
|
|
180
|
+
// line. If we tore the socket down on an upstream failure without one,
|
|
181
|
+
// the client reports "Proxy connection ended before receiving CONNECT
|
|
182
|
+
// response" — so before the tunnel is live, surface failures as a real
|
|
183
|
+
// proxy error status instead of a silent drop.
|
|
184
|
+
let established = false, closed = false;
|
|
185
|
+
// Tear down BOTH sockets when either errors or closes, so a one-sided
|
|
186
|
+
// failure can't leave the paired socket lingering (FD leak). The `closed`
|
|
187
|
+
// guard makes it idempotent (error+close both fire) and ensures we write
|
|
188
|
+
// at most one status line.
|
|
189
|
+
const teardown = (statusLine) => {
|
|
190
|
+
if (closed) return;
|
|
191
|
+
closed = true;
|
|
192
|
+
if (!established && statusLine) {
|
|
193
|
+
try { clientSocket.write(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\n\r\n`); } catch { /* client already gone */ }
|
|
194
|
+
}
|
|
195
|
+
up.destroy(); clientSocket.destroy();
|
|
196
|
+
};
|
|
197
|
+
const up = net.connect(port, host, () => {
|
|
198
|
+
established = true;
|
|
199
|
+
reply200Raw(clientSocket);
|
|
200
|
+
if (head && head.length) up.write(head);
|
|
201
|
+
up.pipe(clientSocket); clientSocket.pipe(up);
|
|
202
|
+
});
|
|
203
|
+
up.on('error', (err) => {
|
|
204
|
+
if (!established) log(`[TeamClaude] tunnel ${host}:${port} failed: ${err.message}`);
|
|
205
|
+
teardown('502 Bad Gateway');
|
|
206
|
+
});
|
|
207
|
+
// A FIN before the tunnel is live (no preceding 'error') is still a failed
|
|
208
|
+
// dial from the client's view — surface a 502 rather than a silent drop.
|
|
209
|
+
up.on('close', () => teardown('502 Bad Gateway'));
|
|
210
|
+
clientSocket.on('close', () => teardown()); // client gone: nothing to write
|
|
211
|
+
up.setTimeout(30_000, () => teardown('504 Gateway Timeout')); // bound a stalled connect/idle tunnel
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (mode === 'test') {
|
|
216
|
+
// The built-in test host is answered locally, never forwarded upstream.
|
|
217
|
+
ensureLeaf().then(({ key, cert }) => {
|
|
218
|
+
reply200Raw(clientSocket);
|
|
219
|
+
serveTest(termClaude(clientSocket, head, key, cert, ['http/1.1']));
|
|
220
|
+
}).catch((err) => { log(`[TeamClaude] MITM ${host}: ${err.message}`); reply502Raw(clientSocket); clientSocket.destroy(); });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// rewrite: terminate the tunnel and forward each request with buffering +
|
|
225
|
+
// retry. Reply 200, hand the raw socket (ClientHello and all) to the h2/h1
|
|
226
|
+
// server, which does TLS + protocol negotiation itself. If the terminating
|
|
227
|
+
// server can't be minted (cert/disk/TLS-init failure) we haven't replied yet
|
|
228
|
+
// — send a 502 so the client sees a real proxy error instead of "Proxy
|
|
229
|
+
// connection ended before receiving CONNECT response".
|
|
230
|
+
// Pin resolution is deliberately confined to `rewrite`. Clients send
|
|
231
|
+
// Proxy-Authorization on EVERY CONNECT, including blind-tunneled third-party
|
|
232
|
+
// hosts, where an account pin is meaningless — rejecting there would take
|
|
233
|
+
// down unrelated traffic over a typo meant for Anthropic.
|
|
234
|
+
const { pin, error } = resolveConnectPin(req, accountManager, proxyApiKey);
|
|
235
|
+
if (error) {
|
|
236
|
+
log(`[TeamClaude] CONNECT ${host}: ${error}`);
|
|
237
|
+
try {
|
|
238
|
+
clientSocket.write(`HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="teamclaude"\r\nConnection: close\r\n\r\n`);
|
|
239
|
+
} catch { /* client already gone */ }
|
|
240
|
+
clientSocket.destroy();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
getServer(pin || '').then((srv) => {
|
|
245
|
+
reply200Raw(clientSocket);
|
|
246
|
+
if (head && head.length) clientSocket.unshift(head);
|
|
247
|
+
srv.emit('connection', clientSocket);
|
|
248
|
+
}).catch((err) => { log(`[TeamClaude] MITM ${host}: ${err.message}`); reply502Raw(clientSocket); clientSocket.destroy(); });
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// The Basic username from a CONNECT's `Proxy-Authorization`, or null. This is
|
|
253
|
+
// the only pin channel expressible in an HTTPS_PROXY URL, which is what
|
|
254
|
+
// `teamclaude run` has to work with in MITM mode (there is no request path to
|
|
255
|
+
// carry a `/tc-acct/` prefix — inside the tunnel the path is the real upstream
|
|
256
|
+
// one). Clients send this preemptively on every CONNECT.
|
|
257
|
+
export function connectPinToken(req) {
|
|
258
|
+
const header = (req?.headers?.['proxy-authorization'] || '').trim();
|
|
259
|
+
if (!header.toLowerCase().startsWith('basic ')) return null;
|
|
260
|
+
const dec = Buffer.from(header.slice('basic '.length).trim(), 'base64').toString('utf8'); // "user:pass"
|
|
261
|
+
const colon = dec.indexOf(':');
|
|
262
|
+
return (colon >= 0 ? dec.slice(0, colon) : dec) || null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Resolve the account pin on a CONNECT, or a rejection reason.
|
|
267
|
+
*
|
|
268
|
+
* The username slot is overloaded: the documented remote form is
|
|
269
|
+
* `--proxy http://<key>@host:port`, where it holds the proxy apiKey, not an
|
|
270
|
+
* account. So the key wins over any account of the same name — an operator who
|
|
271
|
+
* names an account after their proxy key gets auth, not a surprise pin.
|
|
272
|
+
*
|
|
273
|
+
* An unrecognized username is an ERROR rather than a silently ignored pin: a
|
|
274
|
+
* typo'd account name that quietly served from the wrong account is exactly the
|
|
275
|
+
* failure mode this feature exists to remove.
|
|
276
|
+
*
|
|
277
|
+
* @returns {{pin: string|null, error: string|null}}
|
|
278
|
+
*/
|
|
279
|
+
export function resolveConnectPin(req, accountManager, proxyApiKey) {
|
|
280
|
+
const token = connectPinToken(req);
|
|
281
|
+
if (!token) return { pin: null, error: null };
|
|
282
|
+
if (proxyApiKey && safeKeyEqual(token, proxyApiKey)) return { pin: null, error: null };
|
|
283
|
+
if (resolveAccountPin(accountManager, token) == null) {
|
|
284
|
+
return { pin: null, error: `Unknown account pin "${token}"` };
|
|
285
|
+
}
|
|
286
|
+
return { pin: token, error: null };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Authorize a CONNECT: no key configured → open (matches the HTTP path); a
|
|
290
|
+
// loopback client is exempt; otherwise the proxy apiKey must be presented via
|
|
291
|
+
// `Proxy-Authorization` (Bearer <key>, or Basic where the key is the username
|
|
292
|
+
// or password — so `--proxy http://<key>@host:port` works). Exported for tests.
|
|
293
|
+
export function connectAuthorized(req, socket, proxyApiKey) {
|
|
294
|
+
if (!proxyApiKey) return true;
|
|
295
|
+
if (isLoopbackAddr(socket?.remoteAddress)) return true;
|
|
296
|
+
const m = /^\s*(basic|bearer)\s+(.+?)\s*$/i.exec(req?.headers?.['proxy-authorization'] || '');
|
|
297
|
+
if (!m) return false;
|
|
298
|
+
let presented = m[2];
|
|
299
|
+
if (m[1].toLowerCase() === 'basic') {
|
|
300
|
+
const dec = Buffer.from(m[2], 'base64').toString('utf8'); // "user:pass"
|
|
301
|
+
const i = dec.indexOf(':');
|
|
302
|
+
const user = i >= 0 ? dec.slice(0, i) : dec;
|
|
303
|
+
const pass = i >= 0 ? dec.slice(i + 1) : '';
|
|
304
|
+
presented = pass || user;
|
|
305
|
+
}
|
|
306
|
+
return safeKeyEqual(presented, proxyApiKey);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function reply200Raw(sock) { sock.write('HTTP/1.1 200 Connection Established\r\n\r\n'); }
|
|
310
|
+
function reply502Raw(sock) { try { sock.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n'); } catch { /* client already gone */ } }
|
|
311
|
+
|
|
312
|
+
function termClaude(clientSocket, head, key, cert, alpn) {
|
|
313
|
+
if (head && head.length) clientSocket.unshift(head);
|
|
314
|
+
const t = new tls.TLSSocket(clientSocket, { isServer: true, key, cert, ALPNProtocols: alpn });
|
|
315
|
+
t.on('error', () => t.destroy());
|
|
316
|
+
return t;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Answer the built-in test host locally over h1 with a canned JSON response.
|
|
320
|
+
function serveTest(tlsSock) {
|
|
321
|
+
let buf = Buffer.alloc(0);
|
|
322
|
+
const onData = (chunk) => {
|
|
323
|
+
buf = Buffer.concat([buf, chunk]);
|
|
324
|
+
const idx = buf.indexOf('\r\n\r\n');
|
|
325
|
+
if (idx < 0) { if (buf.length > 65536) tlsSock.destroy(); return; }
|
|
326
|
+
tlsSock.removeListener('data', onData);
|
|
327
|
+
const reqLine = buf.subarray(0, buf.indexOf('\r\n')).toString('latin1');
|
|
328
|
+
const path = reqLine.split(' ')[1] || '/';
|
|
329
|
+
const body = JSON.stringify({ teamclaude: 'mitm-proxy-ok', host: TEST_HOST, path });
|
|
330
|
+
tlsSock.end(
|
|
331
|
+
`HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(body)}\r\nconnection: close\r\n\r\n${body}`,
|
|
332
|
+
);
|
|
333
|
+
};
|
|
334
|
+
tlsSock.on('data', onData);
|
|
335
|
+
tlsSock.on('error', () => tlsSock.destroy());
|
|
336
|
+
}
|
package/src/model.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Model-id helpers shared by the request path (server + MITM relay) and account
|
|
2
|
+
// selection. Kept dependency-free so the low-level h2/h1 relay can peek a
|
|
3
|
+
// request's model without pulling in the account-manager graph.
|
|
4
|
+
|
|
5
|
+
// A request targets the Fable model family when its `model` id names Fable
|
|
6
|
+
// (e.g. "claude-fable-5"). Account selection uses this to gate the Fable-only
|
|
7
|
+
// weekly bucket: a Fable-exhausted account still serves every other model.
|
|
8
|
+
export function isFableModel(model) {
|
|
9
|
+
return typeof model === 'string' && /fable/i.test(model);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The model "family" a request belongs to. Anthropic meters some families with
|
|
13
|
+
// their own weekly quota bucket (Fable, Sonnet) on top of the shared 5-hour and
|
|
14
|
+
// weekly buckets, so the family decides which bucket governs a given request —
|
|
15
|
+
// letting an account whose Fable bucket is spent keep serving Opus/Sonnet.
|
|
16
|
+
// Returns a stable lowercase tag; unknown ids fall back to 'other'.
|
|
17
|
+
export function modelFamily(model) {
|
|
18
|
+
if (typeof model !== 'string' || !model) return 'other';
|
|
19
|
+
if (/fable/i.test(model)) return 'fable';
|
|
20
|
+
if (/sonnet/i.test(model)) return 'sonnet';
|
|
21
|
+
if (/opus/i.test(model)) return 'opus';
|
|
22
|
+
if (/haiku/i.test(model)) return 'haiku';
|
|
23
|
+
return 'other';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Quota buckets on an account (see AccountManager emptyQuota). The shared 5-hour
|
|
27
|
+
// bucket applies to every request; the weekly bucket depends on the family.
|
|
28
|
+
// A family with no dedicated weekly bucket falls back to the shared 'unified7d'.
|
|
29
|
+
const FAMILY_WEEKLY_BUCKET = {
|
|
30
|
+
fable: 'unified7dFable',
|
|
31
|
+
sonnet: 'unified7dSonnet',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// The weekly quota bucket key that governs a model, e.g. a Fable request is
|
|
35
|
+
// gated by 'unified7dFable' rather than the shared 'unified7d'. Used by account
|
|
36
|
+
// selection so a spent family bucket only bars that family's requests.
|
|
37
|
+
export function weeklyBucketForModel(model) {
|
|
38
|
+
return FAMILY_WEEKLY_BUCKET[modelFamily(model)] || 'unified7d';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Match a shell-style glob against a model id. Only `*` is special (matches any
|
|
42
|
+
// run of characters, including none); every other character is literal. The
|
|
43
|
+
// comparison is case-insensitive. Used by configurable routes so a pattern like
|
|
44
|
+
// `*fable*` or `claude-opus-*` selects the models a route handles.
|
|
45
|
+
export function modelGlobMatches(glob, model) {
|
|
46
|
+
if (typeof glob !== 'string' || typeof model !== 'string') return false;
|
|
47
|
+
const re = '^' + glob.split('*').map(escapeRegExp).join('.*') + '$';
|
|
48
|
+
return new RegExp(re, 'i').test(model);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function escapeRegExp(s) {
|
|
52
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Do two model globs describe any model in common? Used to tell whether a route
|
|
56
|
+
// is fully shadowed by the blocklist. Exact glob intersection is not decidable
|
|
57
|
+
// in general, so this compares literal cores (the pattern with `*` removed) in
|
|
58
|
+
// both directions: `claude-fable-5` overlaps `*fable*`, and a bare `*` (empty
|
|
59
|
+
// core) overlaps everything. Display-only, and deliberately inclusive — the
|
|
60
|
+
// authoritative per-request gate still matches the concrete model id.
|
|
61
|
+
export function modelGlobOverlaps(a, b) {
|
|
62
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
63
|
+
const core = s => s.replace(/\*/g, '').toLowerCase();
|
|
64
|
+
const ca = core(a);
|
|
65
|
+
const cb = core(b);
|
|
66
|
+
return ca.includes(cb) || cb.includes(ca);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The `blockedModels` pattern that takes a model FAMILY out of service, or null.
|
|
70
|
+
//
|
|
71
|
+
// The blocklist is written against concrete model ids (`*fable*`,
|
|
72
|
+
// `claude-fable-5`) but the status view reasons in families (`Fable`), so a
|
|
73
|
+
// direct glob match is not enough: `claude-fable-5` never matches the literal
|
|
74
|
+
// string `Fable`. Both spellings are checked so the two natural ways to block a
|
|
75
|
+
// family light up the same row — the glob (via modelGlobMatches, which also
|
|
76
|
+
// makes a bare `*` block everything) and a concrete id (via substring).
|
|
77
|
+
//
|
|
78
|
+
// Deliberately advisory: this drives display only. The authoritative gate is the
|
|
79
|
+
// per-request check in server.js, which matches the real model id. A pattern
|
|
80
|
+
// that names no family (say `claude-3-*`) simply lights up no row, and the
|
|
81
|
+
// header list still shows it verbatim.
|
|
82
|
+
export function findFamilyBlock(patterns, family) {
|
|
83
|
+
if (!Array.isArray(patterns) || !family) return null;
|
|
84
|
+
const key = String(family).toLowerCase();
|
|
85
|
+
return patterns.find(p => typeof p === 'string'
|
|
86
|
+
&& (modelGlobMatches(p, key) || p.toLowerCase().includes(key))) || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Streaming, byte-exact locator for a TOP-LEVEL string field of a JSON object,
|
|
90
|
+
// fed incrementally. It tracks JSON structure (container stack, key/value,
|
|
91
|
+
// string/escape) so it ONLY matches the field at depth 1 of the root object —
|
|
92
|
+
// a `"model": "..."` sitting inside conversation text (a message, a tool result)
|
|
93
|
+
// is nested deeper and is never mistaken for the real field. No regex, no
|
|
94
|
+
// whole-body buffering, so the relay can peek just the first frames.
|
|
95
|
+
export class TopLevelFieldFinder {
|
|
96
|
+
constructor(field) {
|
|
97
|
+
this.field = field; // target key at the root, e.g. 'model'
|
|
98
|
+
this.isObj = []; // container stack: true=object, false=array
|
|
99
|
+
this.awaitingKey = false; // at an object, the next string is a key
|
|
100
|
+
this.inStr = false;
|
|
101
|
+
this.esc = false;
|
|
102
|
+
this.readingKey = false;
|
|
103
|
+
this.readingValue = false; // accumulating the target field's value
|
|
104
|
+
this.curKey = null; // last key seen in the current object
|
|
105
|
+
this.buf = []; // key/value byte accumulation
|
|
106
|
+
this.value = null; // the found value, or null
|
|
107
|
+
this.done = false; // found it, or the root object closed without it
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Feed a chunk (Buffer). Returns the found value so far (string) or null. */
|
|
111
|
+
push(chunk) {
|
|
112
|
+
if (this.done) return this.value;
|
|
113
|
+
for (let i = 0; i < chunk.length && !this.done; i++) this.#byte(chunk[i]);
|
|
114
|
+
return this.value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#atRoot() { return this.isObj.length === 1 && this.isObj[0] === true; }
|
|
118
|
+
|
|
119
|
+
#byte(b) {
|
|
120
|
+
if (this.inStr) {
|
|
121
|
+
if (this.esc) { this.esc = false; if (this.readingKey || this.readingValue) this.buf.push(b); return; }
|
|
122
|
+
if (b === 0x5c) { this.esc = true; if (this.readingKey || this.readingValue) this.buf.push(b); return; } // backslash
|
|
123
|
+
if (b === 0x22) { // closing quote
|
|
124
|
+
this.inStr = false;
|
|
125
|
+
if (this.readingKey) {
|
|
126
|
+
this.curKey = Buffer.from(this.buf).toString('utf8'); this.buf = []; this.readingKey = false;
|
|
127
|
+
} else if (this.readingValue) {
|
|
128
|
+
this.value = Buffer.from(this.buf).toString('utf8'); this.buf = [];
|
|
129
|
+
this.readingValue = false; this.done = true; // the one top-level field we want
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (this.readingKey || this.readingValue) this.buf.push(b);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
switch (b) {
|
|
138
|
+
case 0x7b: this.isObj.push(true); this.awaitingKey = true; this.curKey = null; break; // {
|
|
139
|
+
case 0x5b: this.isObj.push(false); this.awaitingKey = false; break; // [
|
|
140
|
+
case 0x7d: case 0x5d: // } ]
|
|
141
|
+
this.isObj.pop(); this.curKey = null;
|
|
142
|
+
if (this.isObj.length === 0) this.done = true; // root closed → field absent
|
|
143
|
+
break;
|
|
144
|
+
case 0x3a: this.awaitingKey = false; break; // :
|
|
145
|
+
case 0x2c: this.awaitingKey = this.isObj[this.isObj.length - 1] === true; break; // ,
|
|
146
|
+
case 0x22: // string begins
|
|
147
|
+
if (this.awaitingKey && this.isObj[this.isObj.length - 1]) {
|
|
148
|
+
this.readingKey = true; this.buf = [];
|
|
149
|
+
} else if (this.#atRoot() && this.curKey === this.field) {
|
|
150
|
+
this.readingValue = true; this.buf = [];
|
|
151
|
+
}
|
|
152
|
+
this.inStr = true; this.esc = false;
|
|
153
|
+
break;
|
|
154
|
+
default: break; // scalars / whitespace
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Extract the requested model id from a JSON request body (Buffer or string).
|
|
160
|
+
// Uses the streaming top-level finder so it is exact (never matches a `model`
|
|
161
|
+
// key nested in conversation content) and cheap on large bodies (it stops as
|
|
162
|
+
// soon as the top-level field resolves). Returns null if absent.
|
|
163
|
+
export function parseRequestModel(body) {
|
|
164
|
+
if (!body) return null;
|
|
165
|
+
try {
|
|
166
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
|
|
167
|
+
return new TopLevelFieldFinder('model').push(buf);
|
|
168
|
+
} catch { return null; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Byte-exact locator for the SECOND model an advisor request carries: Claude
|
|
172
|
+
// Code's advisor tool (`anthropic-beta: advisor-tool-…`) keeps the executor in
|
|
173
|
+
// the top-level `model` field and nests the advisor's model inside the tools
|
|
174
|
+
// array — `tools: [{ type: "advisor_20260301", name: "advisor", model: "…" }]`.
|
|
175
|
+
// The advisor sub-inference runs on the same account and spends that model's
|
|
176
|
+
// quota bucket, so account selection must see it (issue #98).
|
|
177
|
+
//
|
|
178
|
+
// Same byte-machine discipline as TopLevelFieldFinder: it walks the container
|
|
179
|
+
// stack and only reads `type`/`model` strings that are DIRECT fields of an
|
|
180
|
+
// object element of the ROOT object's `tools` array — a "model" inside a tool's
|
|
181
|
+
// input_schema or inside conversation text is deeper (or under another root
|
|
182
|
+
// key) and never matches. Elements are judged when they close, so field order
|
|
183
|
+
// within the tool object doesn't matter.
|
|
184
|
+
export class AdvisorModelFinder {
|
|
185
|
+
constructor() {
|
|
186
|
+
this.stack = []; // frames: {isObj, key, awaitingKey}
|
|
187
|
+
this.inStr = false;
|
|
188
|
+
this.esc = false;
|
|
189
|
+
this.reading = null; // 'key' | 'type' | 'model' while in a string
|
|
190
|
+
this.buf = [];
|
|
191
|
+
this.toolType = null; // fields of the tools[] element being read
|
|
192
|
+
this.toolModel = null;
|
|
193
|
+
this.value = null; // the advisor model, once found
|
|
194
|
+
this.done = false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Feed a chunk (Buffer). Returns the found value so far (string) or null. */
|
|
198
|
+
push(chunk) {
|
|
199
|
+
if (this.done) return this.value;
|
|
200
|
+
for (let i = 0; i < chunk.length && !this.done; i++) this.#byte(chunk[i]);
|
|
201
|
+
return this.value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The stack is exactly [root object (last key "tools"), array, element object].
|
|
205
|
+
#inToolElement() {
|
|
206
|
+
const s = this.stack;
|
|
207
|
+
return s.length === 3 && s[0].isObj && s[0].key === 'tools' && !s[1].isObj && s[2].isObj;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#byte(b) {
|
|
211
|
+
if (this.inStr) {
|
|
212
|
+
if (this.esc) { this.esc = false; if (this.reading) this.buf.push(b); return; }
|
|
213
|
+
if (b === 0x5c) { this.esc = true; if (this.reading) this.buf.push(b); return; } // backslash
|
|
214
|
+
if (b === 0x22) { // closing quote
|
|
215
|
+
this.inStr = false;
|
|
216
|
+
if (this.reading) {
|
|
217
|
+
const text = Buffer.from(this.buf).toString('utf8');
|
|
218
|
+
if (this.reading === 'key') this.stack[this.stack.length - 1].key = text;
|
|
219
|
+
else if (this.reading === 'type') this.toolType = text;
|
|
220
|
+
else this.toolModel = text;
|
|
221
|
+
this.reading = null;
|
|
222
|
+
this.buf = [];
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (this.reading) this.buf.push(b);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
switch (b) {
|
|
231
|
+
case 0x7b: // {
|
|
232
|
+
this.stack.push({ isObj: true, key: null, awaitingKey: true });
|
|
233
|
+
if (this.#inToolElement()) { this.toolType = null; this.toolModel = null; }
|
|
234
|
+
break;
|
|
235
|
+
case 0x5b: this.stack.push({ isObj: false, key: null, awaitingKey: false }); break; // [
|
|
236
|
+
case 0x7d: // }
|
|
237
|
+
if (this.#inToolElement()
|
|
238
|
+
&& typeof this.toolType === 'string' && /^advisor/i.test(this.toolType)
|
|
239
|
+
&& this.toolModel) {
|
|
240
|
+
this.value = this.toolModel;
|
|
241
|
+
this.done = true;
|
|
242
|
+
}
|
|
243
|
+
// fall through: pop like ]
|
|
244
|
+
case 0x5d: // ]
|
|
245
|
+
this.stack.pop();
|
|
246
|
+
if (this.stack.length === 0) this.done = true; // root closed → absent
|
|
247
|
+
break;
|
|
248
|
+
case 0x3a: { const t = this.stack[this.stack.length - 1]; if (t?.isObj) t.awaitingKey = false; break; } // :
|
|
249
|
+
case 0x2c: { const t = this.stack[this.stack.length - 1]; if (t?.isObj) t.awaitingKey = true; break; } // ,
|
|
250
|
+
case 0x22: { // string begins
|
|
251
|
+
const t = this.stack[this.stack.length - 1];
|
|
252
|
+
if (t?.isObj && t.awaitingKey) this.reading = 'key';
|
|
253
|
+
else if (this.#inToolElement() && (t.key === 'type' || t.key === 'model')) this.reading = t.key;
|
|
254
|
+
else this.reading = null; // uninteresting string: skip bytes
|
|
255
|
+
this.buf = [];
|
|
256
|
+
this.inStr = true;
|
|
257
|
+
this.esc = false;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
default: break; // scalars / whitespace
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Extract the advisor model from a JSON request body, or null when the request
|
|
266
|
+
// carries no advisor tool. Gated on a cheap byte search for "advisor" so the
|
|
267
|
+
// full structural scan only runs on bodies that could possibly contain one —
|
|
268
|
+
// for everything else this is a single Buffer.includes.
|
|
269
|
+
export function parseAdvisorModel(body) {
|
|
270
|
+
if (!body) return null;
|
|
271
|
+
try {
|
|
272
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(String(body), 'utf8');
|
|
273
|
+
if (!buf.includes('advisor')) return null;
|
|
274
|
+
return new AdvisorModelFinder().push(buf);
|
|
275
|
+
} catch { return null; }
|
|
276
|
+
}
|