troxy-cli 1.29.3 → 1.29.5

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,419 @@
1
+ /**
2
+ * The local interceptor - Layer 2 of the Live Model Policy Enforcement
3
+ * plan. Runs inside `troxy daemon` (see daemon.js), listening on loopback
4
+ * only. Handles HTTP CONNECT the way any forward proxy does:
5
+ *
6
+ * - A host NOT in `interceptHosts` is a pure blind byte tunnel. This code
7
+ * never sees its plaintext, never parses it, makes no network call of
8
+ * its own - just net.connect + pipe() in both directions. This is the
9
+ * hot path for everything Claude Code (and anything it spawns) talks to
10
+ * that isn't the one host this feature cares about.
11
+ * - An allowlisted host gets its TLS terminated with a leaf signed by the
12
+ * local, name-constrained CA (tls-ca.js), fed into a real Node
13
+ * http.Server instance via `.emit('connection', tlsSocket)` so Node's
14
+ * own battle-tested HTTP/1.1 parsing handles the request, not a
15
+ * hand-rolled one. Only the ONE configured route (providers.js) gets
16
+ * re-addressed to Troxy; every other path on that same host relays to
17
+ * the real destination untouched, same as a non-allowlisted host would.
18
+ *
19
+ * Fail-open, required by the plan (found the hard way tonight - a dead
20
+ * local proxy broke Claude Code outright until manually reverted):
21
+ * - getCerts() returning null (missing/corrupt/expired) -> tunnel the
22
+ * allowlisted host too, exactly like a non-allowlisted one. Coverage is
23
+ * lost; the user's Claude Code is not.
24
+ * - Troxy upstream unreachable or slow past troxyConnectTimeoutMs -> the
25
+ * matched request falls back to relaying directly to the real host,
26
+ * with the client's own original credentials, instead of erroring.
27
+ * - No Troxy key available -> same fallback, without even attempting Troxy.
28
+ */
29
+ import net from 'node:net';
30
+ import tls from 'node:tls';
31
+ import http from 'node:http';
32
+ import https from 'node:https';
33
+
34
+ // Found live 2026-09-08, first real terminal validation of this whole
35
+ // mechanism: a real `claude` process opens a burst of ~15 concurrent
36
+ // connections through the interceptor at startup (telemetry, mcp-registry
37
+ // pagination, oauth/account/settings, bootstrap, the real /v1/messages
38
+ // call, etc.), all landing on this single daemon process's event loop at
39
+ // once. A cold Node https.request to proxy.troxy.io in isolation completes
40
+ // in ~150-350ms (measured directly), comfortably inside 1.5s - but under
41
+ // that real startup burst, the specific connection carrying the actual
42
+ // message reliably missed the 1.5s budget and fell open, so every message
43
+ // in a fresh terminal session silently bypassed Troxy. Verified live: the
44
+ // SAME session's second and later messages, once the startup burst has
45
+ // settled, connect and route correctly well within 1.5s - this is a
46
+ // startup-burst problem, not a genuinely slow or unreachable Troxy.
47
+ // Widened for headroom under that real burst; still bounded, so a
48
+ // genuinely dead Troxy backend still fails open within a few seconds, not
49
+ // hangs indefinitely.
50
+ const DEFAULT_TROXY_CONNECT_TIMEOUT_MS = 5000;
51
+ // Once the TCP/TLS connection to Troxy is actually established, Troxy
52
+ // itself is reachable - any further wait is the model genuinely thinking,
53
+ // not an outage. Found live: a real Claude Code/Desktop request (full
54
+ // system prompt, tool definitions, history) commonly takes several
55
+ // seconds of time-to-first-token, which blew straight past the old single
56
+ // 1.5s budget on every real message, so the interceptor fell back to a
57
+ // direct call every single time and never actually reached policy
58
+ // enforcement in practice. Falling back at that point buys the user
59
+ // nothing anyway - Anthropic direct would show the same latency - so this
60
+ // is a generous absolute backstop against Troxy hanging post-connect, not
61
+ // a value meant to trigger in the normal case.
62
+ const DEFAULT_TROXY_RESPONSE_TIMEOUT_MS = 55_000;
63
+
64
+ export function parseConnectTarget(url) {
65
+ const idx = url.lastIndexOf(':');
66
+ if (idx === -1) return { host: url, port: 443 };
67
+ const host = url.slice(0, idx);
68
+ const port = parseInt(url.slice(idx + 1), 10);
69
+ return { host, port: Number.isFinite(port) ? port : 443 };
70
+ }
71
+
72
+ // Headers that only made sense on the leg that already terminated here -
73
+ // meaningless (or wrong) to forward on either outbound leg.
74
+ const _HOP_BY_HOP_HEADERS = new Set(['host', 'connection', 'content-length', 'transfer-encoding']);
75
+
76
+ /** 'desktop' | 'terminal' | null. Verified live 2026-09-11 against two real
77
+ * captured user-agents from this same machine: terminal sends
78
+ * "claude-cli/2.1.236 (external, cli)", desktop sends "claude-cli/2.1.260
79
+ * (external, claude-desktop, agent-sdk/0.3.260)" - the literal substring
80
+ * "claude-desktop" is the one stable, explicit signal found; everything
81
+ * else in the string (version numbers) will drift across releases. null
82
+ * (never a guess) when the client sends no recognizable claude-cli
83
+ * user-agent at all - some other tool on this port, or a future format
84
+ * change this hasn't been updated for. */
85
+ export function detectSurface(incomingHeaders) {
86
+ const ua = incomingHeaders['user-agent'] || '';
87
+ if (!/^claude-cli\//.test(ua)) return null;
88
+ return ua.includes('claude-desktop') ? 'desktop' : 'terminal';
89
+ }
90
+
91
+ /** Headers for the leg going to Troxy: the client's own Authorization (an
92
+ * Anthropic API key or OAuth token) is never sent to Troxy as-is - it
93
+ * rides the side channel app.py already knows how to consume
94
+ * (X-Troxy-Upstream-Authorization), while Authorization itself becomes the
95
+ * Troxy key, matching how app.py authenticates every other proxied call. */
96
+ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey) {
97
+ const headers = {};
98
+ for (const [k, v] of Object.entries(incomingHeaders)) {
99
+ if (_HOP_BY_HOP_HEADERS.has(k) || k === 'authorization' || k === 'accept-encoding') continue;
100
+ headers[k] = v;
101
+ }
102
+ if (incomingHeaders.authorization) {
103
+ headers['x-troxy-upstream-authorization'] = incomingHeaders.authorization;
104
+ }
105
+ const surface = detectSurface(incomingHeaders);
106
+ if (surface) headers['x-troxy-surface'] = surface;
107
+ headers.authorization = `Bearer ${troxyKey}`;
108
+ return headers;
109
+ }
110
+
111
+ /** Headers for the leg going straight to the real host (no Troxy route
112
+ * matched, or Troxy was unreachable): the client's own credential passes
113
+ * through completely unmodified - this is not a Troxy-authenticated call
114
+ * at all, just a transparent relay. */
115
+ export function buildRelayHeaders(incomingHeaders) {
116
+ const headers = {};
117
+ for (const [k, v] of Object.entries(incomingHeaders)) {
118
+ if (_HOP_BY_HOP_HEADERS.has(k)) continue;
119
+ headers[k] = v;
120
+ }
121
+ return headers;
122
+ }
123
+
124
+ function _drainBody(req) {
125
+ return new Promise((resolve, reject) => {
126
+ const chunks = [];
127
+ req.on('data', (c) => chunks.push(c));
128
+ req.on('end', () => resolve(Buffer.concat(chunks)));
129
+ req.on('error', reject);
130
+ });
131
+ }
132
+
133
+ function _relayToRealHost(host, port, req, res, body, log, lookup, ca) {
134
+ const upstreamReq = https.request({
135
+ method: req.method,
136
+ hostname: host,
137
+ port,
138
+ path: req.url,
139
+ headers: buildRelayHeaders(req.headers),
140
+ ...(lookup ? { lookup } : {}),
141
+ ...(ca ? { ca } : {}), // test-only override; production always uses Node's real trust store
142
+ }, (upstreamRes) => {
143
+ res.writeHead(upstreamRes.statusCode, upstreamRes.headers);
144
+ upstreamRes.pipe(res);
145
+ });
146
+ upstreamReq.on('error', (err) => {
147
+ log('relay to real host %s failed: %s', host, err.message);
148
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json' });
149
+ res.end(JSON.stringify({ error: 'troxy interceptor: could not reach ' + host }));
150
+ });
151
+ upstreamReq.end(body);
152
+ }
153
+
154
+ // http.request/https.request chosen per the configured upstream's own
155
+ // protocol - a test stub commonly uses plain http on loopback, production
156
+ // always uses https://proxy.troxy.io. Re-dispatch here so both work
157
+ // without the caller needing to know which.
158
+ function _clientForProtocol(url) {
159
+ return url.startsWith('https:') ? https : http;
160
+ }
161
+
162
+ /** Attempts the Troxy-routed leg; resolves true if it fully served the
163
+ * response, false if it failed/timed out and the caller should fail open
164
+ * to _relayToRealHost instead. Never writes to `res` on a false
165
+ * resolution, so the caller can safely hand off afterward.
166
+ *
167
+ * Two separate deadlines, not one: `connectTimeoutMs` bounds how long we
168
+ * wait to establish the TCP/TLS connection to Troxy at all (a real
169
+ * unreachability signal - fail open fast). `responseTimeoutMs` is a much
170
+ * longer backstop that only starts once that connection is up, covering
171
+ * the wait for the model's own response (which can legitimately take
172
+ * several real seconds and has nothing to do with whether Troxy is
173
+ * healthy). */
174
+ function _relayToTroxy(route, req, res, body, troxyKey, connectTimeoutMs, responseTimeoutMs, log) {
175
+ return new Promise((resolve) => {
176
+ const url = new URL(route.upstream);
177
+ const qsIdx = req.url.indexOf('?');
178
+ const search = qsIdx === -1 ? '' : req.url.slice(qsIdx);
179
+ let settled = false;
180
+ let connected = false;
181
+ let connectTimer = null;
182
+ // The socket to Troxy is very likely reused across many requests
183
+ // (Node's default agent keeps connections alive) - `socket.setTimeout`
184
+ // adds a NEW 'timeout' listener each call without removing the last
185
+ // one, so leaving this armed after settling leaks a listener onto a
186
+ // long-lived socket every single request (confirmed live:
187
+ // MaxListenersExceededWarning after ~11 real requests). Track exactly
188
+ // what we armed so it can always be torn down on settle, regardless of
189
+ // which branch settled it.
190
+ let timedSocket = null;
191
+ let responseTimeoutHandler = null;
192
+
193
+ const disarmResponseTimeout = () => {
194
+ if (timedSocket && responseTimeoutHandler) {
195
+ timedSocket.removeListener('timeout', responseTimeoutHandler);
196
+ timedSocket.setTimeout(0); // clear the idle timer so a reused socket starts clean for its next request
197
+ }
198
+ timedSocket = null;
199
+ responseTimeoutHandler = null;
200
+ };
201
+
202
+ const settle = (result) => {
203
+ if (settled) return;
204
+ settled = true;
205
+ clearTimeout(connectTimer);
206
+ disarmResponseTimeout();
207
+ resolve(result);
208
+ };
209
+
210
+ const upstreamReq = _clientForProtocol(route.upstream).request({
211
+ method: req.method,
212
+ hostname: url.hostname,
213
+ port: url.port || (url.protocol === 'http:' ? 80 : 443),
214
+ path: url.pathname + search,
215
+ headers: buildTroxyForwardHeaders(req.headers, troxyKey),
216
+ }, (upstreamRes) => {
217
+ if (settled) return; // already failed open on a timeout; drop this late arrival
218
+ settled = true;
219
+ clearTimeout(connectTimer);
220
+ disarmResponseTimeout();
221
+ // The only positive confirmation a real request was actually policy-
222
+ // evaluated by Troxy rather than relayed/fallen-open - previously
223
+ // this path logged nothing at all on success, so a live test had no
224
+ // way to tell "silently worked" apart from "silently never ran".
225
+ log('routed %s to troxy (%s) -> %d', req.url, route.upstream, upstreamRes.statusCode);
226
+ res.writeHead(upstreamRes.statusCode, upstreamRes.headers);
227
+ upstreamRes.pipe(res);
228
+ upstreamRes.on('end', () => resolve(true));
229
+ upstreamRes.on('error', () => resolve(true)); // response already started; not a fail-open case anymore
230
+ });
231
+
232
+ connectTimer = setTimeout(() => {
233
+ if (connected) return; // connection-phase deadline no longer applies once connected
234
+ upstreamReq.destroy();
235
+ settle(false);
236
+ }, connectTimeoutMs);
237
+ connectTimer.unref?.();
238
+
239
+ upstreamReq.on('socket', (socket) => {
240
+ const onConnected = () => {
241
+ connected = true;
242
+ clearTimeout(connectTimer);
243
+ if (settled) return; // response (or an error) already arrived before the connect event did
244
+ // Post-connect backstop: Troxy is reachable, so only a genuine
245
+ // hang (not normal model latency) should still fail open here.
246
+ responseTimeoutHandler = () => {
247
+ upstreamReq.destroy();
248
+ settle(false);
249
+ };
250
+ timedSocket = socket;
251
+ socket.setTimeout(responseTimeoutMs, responseTimeoutHandler);
252
+ };
253
+ if (!socket.connecting) onConnected(); // already connected (e.g. a reused keep-alive socket)
254
+ else socket.once(url.protocol === 'https:' ? 'secureConnect' : 'connect', onConnected);
255
+ });
256
+
257
+ upstreamReq.on('error', () => settle(false));
258
+ upstreamReq.end(body);
259
+ });
260
+ }
261
+
262
+ async function _handleInterceptedRequest(req, res, { routeResolver, getTroxyKey, troxyConnectTimeoutMs, troxyResponseTimeoutMs, log, lookup, ca }) {
263
+ const targetHost = req.socket._troxyTargetHost;
264
+ const targetPort = req.socket._troxyTargetPort || 443;
265
+ let body;
266
+ try {
267
+ body = await _drainBody(req);
268
+ } catch (err) {
269
+ log('failed to read intercepted request body: %s', err.message);
270
+ res.writeHead(400);
271
+ res.end();
272
+ return;
273
+ }
274
+
275
+ const route = routeResolver(targetHost, req.method, req.url);
276
+ if (route) {
277
+ const troxyKey = getTroxyKey();
278
+ if (troxyKey) {
279
+ const served = await _relayToTroxy(route, req, res, body, troxyKey, troxyConnectTimeoutMs, troxyResponseTimeoutMs, log);
280
+ if (served) return;
281
+ log('troxy upstream unreachable, failing open: relaying %s directly to %s', req.url, targetHost);
282
+ } else {
283
+ log('no troxy key available, failing open: relaying %s directly to %s', req.url, targetHost);
284
+ }
285
+ }
286
+
287
+ _relayToRealHost(targetHost, targetPort, req, res, body, log, lookup, ca);
288
+ }
289
+
290
+ function _blindTunnel(host, port, clientSocket, head, log, lookup) {
291
+ const upstream = net.connect({ host, port, ...(lookup ? { lookup } : {}) });
292
+ upstream.on('connect', () => {
293
+ if (head && head.length) upstream.write(head);
294
+ clientSocket.pipe(upstream);
295
+ upstream.pipe(clientSocket);
296
+ });
297
+ upstream.on('error', (err) => { log('blind tunnel to %s failed: %s', host, err.message); clientSocket.destroy(); });
298
+ clientSocket.on('error', () => upstream.destroy());
299
+ clientSocket.on('close', () => upstream.destroy());
300
+ upstream.on('close', () => clientSocket.destroy());
301
+ }
302
+
303
+ /**
304
+ * Creates the interceptor as an http.Server (not yet listening - call
305
+ * .listen() on it, same as any Node server). Options:
306
+ * - interceptHosts: string[] - hosts to terminate TLS for; everything
307
+ * else is a blind tunnel.
308
+ * - routeResolver: (host, method, path) => {upstream} | undefined -
309
+ * normally providers.js's troxyRouteFor bound to the enabled providers.
310
+ * - getTroxyKey: () => string | null - called fresh per request, not
311
+ * cached, so a key rotation takes effect without a daemon restart.
312
+ * - getCerts: () => {leafCertPem, leafKeyPem} | null - null means
313
+ * unhealthy; the interceptor tunnels the allowlisted host too rather
314
+ * than fail.
315
+ * - troxyConnectTimeoutMs: fail-open budget for reaching Troxy at all
316
+ * (TCP+TLS connect) - real unreachability. troxyResponseTimeoutMs: a
317
+ * much longer backstop that only starts once connected, covering the
318
+ * model's own response time (not an outage signal - see _relayToTroxy).
319
+ * - log: (fmt, ...args) => void
320
+ * - lookup: optional custom DNS resolver, Node's standard (hostname,
321
+ * options, callback) => void shape - passed straight through to the
322
+ * outbound net.connect/https.request calls that reach a real target
323
+ * host. Never needed in production (real DNS is exactly right there);
324
+ * exists so tests can redirect a real hostname like api.anthropic.com
325
+ * to a local stub server without touching production code paths.
326
+ * - ca: optional extra trusted CA(s) for _relayToRealHost's own outbound
327
+ * TLS verification of the real target host. Never needed in production
328
+ * (Node's real trust store already trusts api.anthropic.com's real
329
+ * cert) - exists only so a test's local stub server, presenting a
330
+ * self-signed test cert, can be trusted without weakening what
331
+ * production actually verifies against.
332
+ */
333
+ export function createInterceptor({
334
+ interceptHosts,
335
+ routeResolver,
336
+ getTroxyKey,
337
+ getCerts,
338
+ troxyConnectTimeoutMs = DEFAULT_TROXY_CONNECT_TIMEOUT_MS,
339
+ troxyResponseTimeoutMs = DEFAULT_TROXY_RESPONSE_TIMEOUT_MS,
340
+ log = () => {},
341
+ lookup,
342
+ ca,
343
+ }) {
344
+ const interceptedServer = http.createServer((req, res) => {
345
+ _handleInterceptedRequest(req, res, { routeResolver, getTroxyKey, troxyConnectTimeoutMs, troxyResponseTimeoutMs, log, lookup, ca })
346
+ .catch((err) => {
347
+ log('unexpected error handling intercepted request: %s', err.stack || err.message);
348
+ try { if (!res.headersSent) res.writeHead(502); res.end(); } catch {}
349
+ });
350
+ });
351
+ interceptedServer.on('clientError', (err, socket) => { try { socket.destroy(); } catch {} });
352
+ // Errors on individual TLS-terminated connections must never escape as
353
+ // uncaught 'error' events on the server itself (a single bad handshake
354
+ // must not take the whole interceptor down).
355
+ interceptedServer.on('error', (err) => log('intercepted-server error: %s', err.message));
356
+
357
+ const proxyServer = http.createServer((req, res) => {
358
+ res.writeHead(400, { 'content-type': 'text/plain' });
359
+ res.end('troxy interceptor: only CONNECT is supported\n');
360
+ });
361
+ proxyServer.on('clientError', (err, socket) => { try { socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); } catch {} });
362
+
363
+ proxyServer.on('connect', (req, clientSocket, head) => {
364
+ clientSocket.on('error', () => {}); // a reset/closed client connection is routine, not a crash
365
+ const { host, port } = parseConnectTarget(req.url);
366
+
367
+ try {
368
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
369
+ } catch {
370
+ return; // client already gone
371
+ }
372
+
373
+ if (!interceptHosts.includes(host)) {
374
+ _blindTunnel(host, port, clientSocket, head, log, lookup);
375
+ return;
376
+ }
377
+
378
+ const certs = getCerts();
379
+ if (!certs) {
380
+ log('certificates unhealthy - tunneling %s instead of intercepting', host);
381
+ _blindTunnel(host, port, clientSocket, head, log, lookup);
382
+ return;
383
+ }
384
+
385
+ let tlsSocket;
386
+ try {
387
+ tlsSocket = new tls.TLSSocket(clientSocket, {
388
+ isServer: true,
389
+ cert: certs.leafCertPem,
390
+ key: certs.leafKeyPem,
391
+ // The real api.anthropic.com speaks HTTP/2, and a client that
392
+ // offers ALPN (Chromium's network stack - what the desktop app's
393
+ // Code tab actually uses, not Node's own https client - does this
394
+ // strictly) can abort the handshake outright if the server doesn't
395
+ // declare a protocol it's willing to accept, surfacing here as a
396
+ // bare ECONNRESET during the handshake rather than a clean TLS
397
+ // alert. This server only ever speaks HTTP/1.1 (interceptedServer
398
+ // is a plain http.Server fed via emit('connection', ...)), so
399
+ // declare exactly that - found live, a real client aborted every
400
+ // single connection attempt without this.
401
+ ALPNProtocols: ['http/1.1'],
402
+ });
403
+ } catch (err) {
404
+ log('failed to start TLS termination for %s: %s', host, err.message);
405
+ clientSocket.destroy();
406
+ return;
407
+ }
408
+ tlsSocket.on('error', (err) => {
409
+ log('tls handshake failed for %s: %s', host, err.message);
410
+ try { clientSocket.destroy(); } catch {}
411
+ });
412
+ tlsSocket._troxyTargetHost = host;
413
+ tlsSocket._troxyTargetPort = port;
414
+ if (head && head.length) tlsSocket.unshift(head);
415
+ interceptedServer.emit('connection', tlsSocket);
416
+ });
417
+
418
+ return proxyServer;
419
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The single source of truth for which hosts the local interceptor
3
+ * (interceptor.js) ever terminates TLS for, and where an intercepted
4
+ * request gets re-addressed to. Everything else - every other host the
5
+ * client talks to, and every path on an intercepted host that has no
6
+ * route here - passes through as a blind byte tunnel, never parsed, never
7
+ * visible to this codebase at all.
8
+ *
9
+ * Phase 1 (see the plan doc) is Anthropic only. openai_codex/cursor are
10
+ * declared here, disabled, purely so Phase 2/3 is a data change to this
11
+ * file later, not a rewrite of the interceptor or the CA logic - the
12
+ * provider's real divergence (Codex signs with CODEX_CA_CERTIFICATE, not
13
+ * NODE_EXTRA_CA_CERTS, since it's Rust-based) is already captured as a
14
+ * field, not hardcoded control flow, even though it isn't wired to
15
+ * anything yet. Do not flip `enabled` on either without first doing that
16
+ * phase's own hands-on verification - see the plan's Phase 2/3 section for
17
+ * exactly what's still unverified about each.
18
+ */
19
+
20
+ export const PROVIDERS = [
21
+ {
22
+ id: 'anthropic',
23
+ name: 'Claude (Code + Desktop)',
24
+ enabled: true,
25
+ interceptHosts: ['api.anthropic.com'],
26
+ troxyRoutes: [
27
+ { method: 'POST', path: '/v1/messages', upstream: 'https://proxy.troxy.io/v1/messages' },
28
+ ],
29
+ envKeys: { proxy: 'HTTPS_PROXY', ca: 'NODE_EXTRA_CA_CERTS' },
30
+ },
31
+ {
32
+ id: 'openai_codex',
33
+ name: 'Codex CLI',
34
+ enabled: false, // Phase 2 - not wired up. See plan doc: no official network-config
35
+ // doc exists for Codex, and whether its TLS stack enforces
36
+ // nameConstraints the way OpenSSL does is unverified.
37
+ interceptHosts: ['api.openai.com'],
38
+ troxyRoutes: [],
39
+ envKeys: { proxy: 'HTTPS_PROXY', ca: 'CODEX_CA_CERTIFICATE' },
40
+ },
41
+ {
42
+ id: 'cursor',
43
+ name: 'Cursor',
44
+ enabled: false, // Phase 3 - not wired up, shape genuinely unknown. See plan doc:
45
+ // Cursor routes ALL traffic through its own backend even with
46
+ // BYOK, so this would need to intercept api2.cursor.sh (Cursor
47
+ // itself), not a model provider - a materially different,
48
+ // unverified scope. Needs its own hands-on test before design.
49
+ interceptHosts: [],
50
+ troxyRoutes: [],
51
+ envKeys: {},
52
+ },
53
+ ];
54
+
55
+ export function enabledProviders() {
56
+ return PROVIDERS.filter(p => p.enabled);
57
+ }
58
+
59
+ /** Every host any of the given providers' TLS should be terminated for -
60
+ * the interceptor's live allowlist. Anything not in this list is a blind
61
+ * tunnel, unconditionally. */
62
+ export function interceptHostsFor(providers) {
63
+ return providers.flatMap(p => p.interceptHosts || []);
64
+ }
65
+
66
+ /** The Troxy route to re-address an intercepted request to, or undefined
67
+ * if this host+method+path has no configured route - in which case the
68
+ * interceptor relays it to the real host verbatim instead of proxying it
69
+ * (e.g. api.anthropic.com's own /api/oauth/account/settings, which has
70
+ * nothing to do with policy enforcement and must reach Anthropic
71
+ * unmodified). Case-insensitive on method; ignores any query string on
72
+ * the incoming path since a route is keyed on the path alone. */
73
+ export function troxyRouteFor(providers, host, method, requestPath) {
74
+ const pathOnly = requestPath.split('?')[0];
75
+ for (const provider of providers) {
76
+ if (!(provider.interceptHosts || []).includes(host)) continue;
77
+ for (const route of provider.troxyRoutes || []) {
78
+ if (route.method.toUpperCase() === method.toUpperCase() && route.path === pathOnly) {
79
+ return route;
80
+ }
81
+ }
82
+ }
83
+ return undefined;
84
+ }