troxy-cli 1.29.2 → 1.29.4

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,402 @@
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
+ /** Headers for the leg going to Troxy: the client's own Authorization (an
77
+ * Anthropic API key or OAuth token) is never sent to Troxy as-is - it
78
+ * rides the side channel app.py already knows how to consume
79
+ * (X-Troxy-Upstream-Authorization), while Authorization itself becomes the
80
+ * Troxy key, matching how app.py authenticates every other proxied call. */
81
+ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey) {
82
+ const headers = {};
83
+ for (const [k, v] of Object.entries(incomingHeaders)) {
84
+ if (_HOP_BY_HOP_HEADERS.has(k) || k === 'authorization' || k === 'accept-encoding') continue;
85
+ headers[k] = v;
86
+ }
87
+ if (incomingHeaders.authorization) {
88
+ headers['x-troxy-upstream-authorization'] = incomingHeaders.authorization;
89
+ }
90
+ headers.authorization = `Bearer ${troxyKey}`;
91
+ return headers;
92
+ }
93
+
94
+ /** Headers for the leg going straight to the real host (no Troxy route
95
+ * matched, or Troxy was unreachable): the client's own credential passes
96
+ * through completely unmodified - this is not a Troxy-authenticated call
97
+ * at all, just a transparent relay. */
98
+ export function buildRelayHeaders(incomingHeaders) {
99
+ const headers = {};
100
+ for (const [k, v] of Object.entries(incomingHeaders)) {
101
+ if (_HOP_BY_HOP_HEADERS.has(k)) continue;
102
+ headers[k] = v;
103
+ }
104
+ return headers;
105
+ }
106
+
107
+ function _drainBody(req) {
108
+ return new Promise((resolve, reject) => {
109
+ const chunks = [];
110
+ req.on('data', (c) => chunks.push(c));
111
+ req.on('end', () => resolve(Buffer.concat(chunks)));
112
+ req.on('error', reject);
113
+ });
114
+ }
115
+
116
+ function _relayToRealHost(host, port, req, res, body, log, lookup, ca) {
117
+ const upstreamReq = https.request({
118
+ method: req.method,
119
+ hostname: host,
120
+ port,
121
+ path: req.url,
122
+ headers: buildRelayHeaders(req.headers),
123
+ ...(lookup ? { lookup } : {}),
124
+ ...(ca ? { ca } : {}), // test-only override; production always uses Node's real trust store
125
+ }, (upstreamRes) => {
126
+ res.writeHead(upstreamRes.statusCode, upstreamRes.headers);
127
+ upstreamRes.pipe(res);
128
+ });
129
+ upstreamReq.on('error', (err) => {
130
+ log('relay to real host %s failed: %s', host, err.message);
131
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'application/json' });
132
+ res.end(JSON.stringify({ error: 'troxy interceptor: could not reach ' + host }));
133
+ });
134
+ upstreamReq.end(body);
135
+ }
136
+
137
+ // http.request/https.request chosen per the configured upstream's own
138
+ // protocol - a test stub commonly uses plain http on loopback, production
139
+ // always uses https://proxy.troxy.io. Re-dispatch here so both work
140
+ // without the caller needing to know which.
141
+ function _clientForProtocol(url) {
142
+ return url.startsWith('https:') ? https : http;
143
+ }
144
+
145
+ /** Attempts the Troxy-routed leg; resolves true if it fully served the
146
+ * response, false if it failed/timed out and the caller should fail open
147
+ * to _relayToRealHost instead. Never writes to `res` on a false
148
+ * resolution, so the caller can safely hand off afterward.
149
+ *
150
+ * Two separate deadlines, not one: `connectTimeoutMs` bounds how long we
151
+ * wait to establish the TCP/TLS connection to Troxy at all (a real
152
+ * unreachability signal - fail open fast). `responseTimeoutMs` is a much
153
+ * longer backstop that only starts once that connection is up, covering
154
+ * the wait for the model's own response (which can legitimately take
155
+ * several real seconds and has nothing to do with whether Troxy is
156
+ * healthy). */
157
+ function _relayToTroxy(route, req, res, body, troxyKey, connectTimeoutMs, responseTimeoutMs, log) {
158
+ return new Promise((resolve) => {
159
+ const url = new URL(route.upstream);
160
+ const qsIdx = req.url.indexOf('?');
161
+ const search = qsIdx === -1 ? '' : req.url.slice(qsIdx);
162
+ let settled = false;
163
+ let connected = false;
164
+ let connectTimer = null;
165
+ // The socket to Troxy is very likely reused across many requests
166
+ // (Node's default agent keeps connections alive) - `socket.setTimeout`
167
+ // adds a NEW 'timeout' listener each call without removing the last
168
+ // one, so leaving this armed after settling leaks a listener onto a
169
+ // long-lived socket every single request (confirmed live:
170
+ // MaxListenersExceededWarning after ~11 real requests). Track exactly
171
+ // what we armed so it can always be torn down on settle, regardless of
172
+ // which branch settled it.
173
+ let timedSocket = null;
174
+ let responseTimeoutHandler = null;
175
+
176
+ const disarmResponseTimeout = () => {
177
+ if (timedSocket && responseTimeoutHandler) {
178
+ timedSocket.removeListener('timeout', responseTimeoutHandler);
179
+ timedSocket.setTimeout(0); // clear the idle timer so a reused socket starts clean for its next request
180
+ }
181
+ timedSocket = null;
182
+ responseTimeoutHandler = null;
183
+ };
184
+
185
+ const settle = (result) => {
186
+ if (settled) return;
187
+ settled = true;
188
+ clearTimeout(connectTimer);
189
+ disarmResponseTimeout();
190
+ resolve(result);
191
+ };
192
+
193
+ const upstreamReq = _clientForProtocol(route.upstream).request({
194
+ method: req.method,
195
+ hostname: url.hostname,
196
+ port: url.port || (url.protocol === 'http:' ? 80 : 443),
197
+ path: url.pathname + search,
198
+ headers: buildTroxyForwardHeaders(req.headers, troxyKey),
199
+ }, (upstreamRes) => {
200
+ if (settled) return; // already failed open on a timeout; drop this late arrival
201
+ settled = true;
202
+ clearTimeout(connectTimer);
203
+ disarmResponseTimeout();
204
+ // The only positive confirmation a real request was actually policy-
205
+ // evaluated by Troxy rather than relayed/fallen-open - previously
206
+ // this path logged nothing at all on success, so a live test had no
207
+ // way to tell "silently worked" apart from "silently never ran".
208
+ log('routed %s to troxy (%s) -> %d', req.url, route.upstream, upstreamRes.statusCode);
209
+ res.writeHead(upstreamRes.statusCode, upstreamRes.headers);
210
+ upstreamRes.pipe(res);
211
+ upstreamRes.on('end', () => resolve(true));
212
+ upstreamRes.on('error', () => resolve(true)); // response already started; not a fail-open case anymore
213
+ });
214
+
215
+ connectTimer = setTimeout(() => {
216
+ if (connected) return; // connection-phase deadline no longer applies once connected
217
+ upstreamReq.destroy();
218
+ settle(false);
219
+ }, connectTimeoutMs);
220
+ connectTimer.unref?.();
221
+
222
+ upstreamReq.on('socket', (socket) => {
223
+ const onConnected = () => {
224
+ connected = true;
225
+ clearTimeout(connectTimer);
226
+ if (settled) return; // response (or an error) already arrived before the connect event did
227
+ // Post-connect backstop: Troxy is reachable, so only a genuine
228
+ // hang (not normal model latency) should still fail open here.
229
+ responseTimeoutHandler = () => {
230
+ upstreamReq.destroy();
231
+ settle(false);
232
+ };
233
+ timedSocket = socket;
234
+ socket.setTimeout(responseTimeoutMs, responseTimeoutHandler);
235
+ };
236
+ if (!socket.connecting) onConnected(); // already connected (e.g. a reused keep-alive socket)
237
+ else socket.once(url.protocol === 'https:' ? 'secureConnect' : 'connect', onConnected);
238
+ });
239
+
240
+ upstreamReq.on('error', () => settle(false));
241
+ upstreamReq.end(body);
242
+ });
243
+ }
244
+
245
+ async function _handleInterceptedRequest(req, res, { routeResolver, getTroxyKey, troxyConnectTimeoutMs, troxyResponseTimeoutMs, log, lookup, ca }) {
246
+ const targetHost = req.socket._troxyTargetHost;
247
+ const targetPort = req.socket._troxyTargetPort || 443;
248
+ let body;
249
+ try {
250
+ body = await _drainBody(req);
251
+ } catch (err) {
252
+ log('failed to read intercepted request body: %s', err.message);
253
+ res.writeHead(400);
254
+ res.end();
255
+ return;
256
+ }
257
+
258
+ const route = routeResolver(targetHost, req.method, req.url);
259
+ if (route) {
260
+ const troxyKey = getTroxyKey();
261
+ if (troxyKey) {
262
+ const served = await _relayToTroxy(route, req, res, body, troxyKey, troxyConnectTimeoutMs, troxyResponseTimeoutMs, log);
263
+ if (served) return;
264
+ log('troxy upstream unreachable, failing open: relaying %s directly to %s', req.url, targetHost);
265
+ } else {
266
+ log('no troxy key available, failing open: relaying %s directly to %s', req.url, targetHost);
267
+ }
268
+ }
269
+
270
+ _relayToRealHost(targetHost, targetPort, req, res, body, log, lookup, ca);
271
+ }
272
+
273
+ function _blindTunnel(host, port, clientSocket, head, log, lookup) {
274
+ const upstream = net.connect({ host, port, ...(lookup ? { lookup } : {}) });
275
+ upstream.on('connect', () => {
276
+ if (head && head.length) upstream.write(head);
277
+ clientSocket.pipe(upstream);
278
+ upstream.pipe(clientSocket);
279
+ });
280
+ upstream.on('error', (err) => { log('blind tunnel to %s failed: %s', host, err.message); clientSocket.destroy(); });
281
+ clientSocket.on('error', () => upstream.destroy());
282
+ clientSocket.on('close', () => upstream.destroy());
283
+ upstream.on('close', () => clientSocket.destroy());
284
+ }
285
+
286
+ /**
287
+ * Creates the interceptor as an http.Server (not yet listening - call
288
+ * .listen() on it, same as any Node server). Options:
289
+ * - interceptHosts: string[] - hosts to terminate TLS for; everything
290
+ * else is a blind tunnel.
291
+ * - routeResolver: (host, method, path) => {upstream} | undefined -
292
+ * normally providers.js's troxyRouteFor bound to the enabled providers.
293
+ * - getTroxyKey: () => string | null - called fresh per request, not
294
+ * cached, so a key rotation takes effect without a daemon restart.
295
+ * - getCerts: () => {leafCertPem, leafKeyPem} | null - null means
296
+ * unhealthy; the interceptor tunnels the allowlisted host too rather
297
+ * than fail.
298
+ * - troxyConnectTimeoutMs: fail-open budget for reaching Troxy at all
299
+ * (TCP+TLS connect) - real unreachability. troxyResponseTimeoutMs: a
300
+ * much longer backstop that only starts once connected, covering the
301
+ * model's own response time (not an outage signal - see _relayToTroxy).
302
+ * - log: (fmt, ...args) => void
303
+ * - lookup: optional custom DNS resolver, Node's standard (hostname,
304
+ * options, callback) => void shape - passed straight through to the
305
+ * outbound net.connect/https.request calls that reach a real target
306
+ * host. Never needed in production (real DNS is exactly right there);
307
+ * exists so tests can redirect a real hostname like api.anthropic.com
308
+ * to a local stub server without touching production code paths.
309
+ * - ca: optional extra trusted CA(s) for _relayToRealHost's own outbound
310
+ * TLS verification of the real target host. Never needed in production
311
+ * (Node's real trust store already trusts api.anthropic.com's real
312
+ * cert) - exists only so a test's local stub server, presenting a
313
+ * self-signed test cert, can be trusted without weakening what
314
+ * production actually verifies against.
315
+ */
316
+ export function createInterceptor({
317
+ interceptHosts,
318
+ routeResolver,
319
+ getTroxyKey,
320
+ getCerts,
321
+ troxyConnectTimeoutMs = DEFAULT_TROXY_CONNECT_TIMEOUT_MS,
322
+ troxyResponseTimeoutMs = DEFAULT_TROXY_RESPONSE_TIMEOUT_MS,
323
+ log = () => {},
324
+ lookup,
325
+ ca,
326
+ }) {
327
+ const interceptedServer = http.createServer((req, res) => {
328
+ _handleInterceptedRequest(req, res, { routeResolver, getTroxyKey, troxyConnectTimeoutMs, troxyResponseTimeoutMs, log, lookup, ca })
329
+ .catch((err) => {
330
+ log('unexpected error handling intercepted request: %s', err.stack || err.message);
331
+ try { if (!res.headersSent) res.writeHead(502); res.end(); } catch {}
332
+ });
333
+ });
334
+ interceptedServer.on('clientError', (err, socket) => { try { socket.destroy(); } catch {} });
335
+ // Errors on individual TLS-terminated connections must never escape as
336
+ // uncaught 'error' events on the server itself (a single bad handshake
337
+ // must not take the whole interceptor down).
338
+ interceptedServer.on('error', (err) => log('intercepted-server error: %s', err.message));
339
+
340
+ const proxyServer = http.createServer((req, res) => {
341
+ res.writeHead(400, { 'content-type': 'text/plain' });
342
+ res.end('troxy interceptor: only CONNECT is supported\n');
343
+ });
344
+ proxyServer.on('clientError', (err, socket) => { try { socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); } catch {} });
345
+
346
+ proxyServer.on('connect', (req, clientSocket, head) => {
347
+ clientSocket.on('error', () => {}); // a reset/closed client connection is routine, not a crash
348
+ const { host, port } = parseConnectTarget(req.url);
349
+
350
+ try {
351
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
352
+ } catch {
353
+ return; // client already gone
354
+ }
355
+
356
+ if (!interceptHosts.includes(host)) {
357
+ _blindTunnel(host, port, clientSocket, head, log, lookup);
358
+ return;
359
+ }
360
+
361
+ const certs = getCerts();
362
+ if (!certs) {
363
+ log('certificates unhealthy - tunneling %s instead of intercepting', host);
364
+ _blindTunnel(host, port, clientSocket, head, log, lookup);
365
+ return;
366
+ }
367
+
368
+ let tlsSocket;
369
+ try {
370
+ tlsSocket = new tls.TLSSocket(clientSocket, {
371
+ isServer: true,
372
+ cert: certs.leafCertPem,
373
+ key: certs.leafKeyPem,
374
+ // The real api.anthropic.com speaks HTTP/2, and a client that
375
+ // offers ALPN (Chromium's network stack - what the desktop app's
376
+ // Code tab actually uses, not Node's own https client - does this
377
+ // strictly) can abort the handshake outright if the server doesn't
378
+ // declare a protocol it's willing to accept, surfacing here as a
379
+ // bare ECONNRESET during the handshake rather than a clean TLS
380
+ // alert. This server only ever speaks HTTP/1.1 (interceptedServer
381
+ // is a plain http.Server fed via emit('connection', ...)), so
382
+ // declare exactly that - found live, a real client aborted every
383
+ // single connection attempt without this.
384
+ ALPNProtocols: ['http/1.1'],
385
+ });
386
+ } catch (err) {
387
+ log('failed to start TLS termination for %s: %s', host, err.message);
388
+ clientSocket.destroy();
389
+ return;
390
+ }
391
+ tlsSocket.on('error', (err) => {
392
+ log('tls handshake failed for %s: %s', host, err.message);
393
+ try { clientSocket.destroy(); } catch {}
394
+ });
395
+ tlsSocket._troxyTargetHost = host;
396
+ tlsSocket._troxyTargetPort = port;
397
+ if (head && head.length) tlsSocket.unshift(head);
398
+ interceptedServer.emit('connection', tlsSocket);
399
+ });
400
+
401
+ return proxyServer;
402
+ }
@@ -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
+ }
package/src/proxy.js ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `troxy proxy status/enable/disable` - the explicit, hands-on control
3
+ * surface for Layer 3 of the Live Model Policy Enforcement plan (local
4
+ * interceptor set up in interceptor.js/tls-ca.js/daemon.js). Deliberately
5
+ * NOT wired into `troxy init`'s own interactive flow yet: per the plan's
6
+ * rollout order, this ships first as an explicit, undocumented opt-in
7
+ * (`--experimental` on enable) so it can be validated by hand against a
8
+ * real desktop app before it becomes something an ordinary `troxy init`
9
+ * run could ever turn on for every user.
10
+ */
11
+ import net from 'node:net';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import fs from 'node:fs';
15
+ import {
16
+ hasClaudeCode, hasClaudeDesktop, claudeCodeSettingsPath,
17
+ patchClaudeCodeInterception, unpatchClaudeCodeInterception, interceptionIsConfigured,
18
+ troxyInterceptorProxyUrl, troxyLocalCaCertPath,
19
+ } from './init.js';
20
+ import { ensureInterceptionCerts, certIsValid, certExpiresWithin } from './tls-ca.js';
21
+ import { enabledProviders, interceptHostsFor } from './providers.js';
22
+ import { INTERCEPTOR_PORT } from './daemon.js';
23
+
24
+ const TROXY_DIR = path.join(os.homedir(), '.troxy');
25
+
26
+ // Quick, bounded TCP probe - "is anything listening on 127.0.0.1:<port>",
27
+ // not a real request. Exists to tell the fail-open layer-6 mismatch
28
+ // (settings.json points HTTPS_PROXY at the interceptor, but nothing is
29
+ // actually there - exactly the outage this whole plan's fail-open design
30
+ // was written around) apart from a healthy setup, so status/enable can say
31
+ // so plainly instead of a user discovering it as a mysterious hang.
32
+ export function probePort(port, timeoutMs = 800) {
33
+ return new Promise(resolve => {
34
+ const socket = net.connect({ host: '127.0.0.1', port, timeout: timeoutMs });
35
+ socket.once('connect', () => { socket.destroy(); resolve(true); });
36
+ socket.once('timeout', () => { socket.destroy(); resolve(false); });
37
+ socket.once('error', () => resolve(false));
38
+ });
39
+ }
40
+
41
+ function _readCert(certPath) {
42
+ try { return fs.readFileSync(certPath, 'utf8'); } catch { return null; }
43
+ }
44
+
45
+ export async function runProxyStatus() {
46
+ console.log('\n Troxy: Live Model Policy Enforcement (interception)\n');
47
+
48
+ const hasClaude = hasClaudeCode();
49
+ const hasDesktop = hasClaudeDesktop();
50
+ console.log(` Terminal Claude Code: ${hasClaude ? 'detected' : 'not detected'}`);
51
+ console.log(` Claude Desktop app: ${hasDesktop ? 'detected' : 'not detected'}`);
52
+
53
+ const settingsPath = claudeCodeSettingsPath();
54
+ const proxyUrl = troxyInterceptorProxyUrl();
55
+ const caCertPath = troxyLocalCaCertPath();
56
+ const configured = interceptionIsConfigured(settingsPath, { proxyUrl, caCertPath });
57
+ console.log(`\n Configured in settings.json: ${configured ? 'yes' : 'no'}`);
58
+ if (!configured) {
59
+ console.log(' Run `troxy proxy enable --experimental` to turn this on.\n');
60
+ return;
61
+ }
62
+
63
+ const bound = await probePort(INTERCEPTOR_PORT);
64
+ if (!bound) {
65
+ console.log(` ⚠ settings.json points at 127.0.0.1:${INTERCEPTOR_PORT}, but nothing is`);
66
+ console.log(' listening there right now - Claude Code and the desktop app will');
67
+ console.log(' fail to connect while this is the case.');
68
+ console.log(' Fix: `troxy restart` (restarts the background daemon), or');
69
+ console.log(' `troxy proxy disable` to remove the setting until this is resolved.\n');
70
+ return;
71
+ }
72
+ console.log(` Interceptor listening: yes (127.0.0.1:${INTERCEPTOR_PORT})`);
73
+
74
+ const caPem = _readCert(caCertPath);
75
+ if (!caPem) {
76
+ console.log(` CA certificate: ✗ not found at ${caCertPath}\n`);
77
+ return;
78
+ }
79
+ console.log(` CA certificate: ${certIsValid(caPem) ? 'valid' : '✗ invalid/expired'}`);
80
+
81
+ const leafPath = path.join(TROXY_DIR, 'tls', 'leaf-api.anthropic.com.crt');
82
+ const leafPem = _readCert(leafPath);
83
+ if (leafPem) {
84
+ const leafOk = certIsValid(leafPem);
85
+ const renewingSoon = leafOk && certExpiresWithin(leafPem, 30);
86
+ console.log(` Leaf certificate: ${leafOk ? 'valid' : '✗ invalid/expired'}${renewingSoon ? ' (renews within 30 days)' : ''}`);
87
+ }
88
+ console.log(` CA cert path: ${caCertPath}`);
89
+ console.log(` Providers intercepted: ${interceptHostsFor(enabledProviders()).join(', ') || '(none)'}\n`);
90
+ }
91
+
92
+ export async function runProxyEnable(flags = {}) {
93
+ if (flags.help || flags.h) {
94
+ console.log(`
95
+ troxy proxy enable --experimental
96
+
97
+ Routes the Claude Desktop app's Code tab (and terminal Claude Code)
98
+ through Troxy's local interceptor, so model policies (e.g. a BLOCK rule
99
+ on a specific model) are enforced in real time even inside the desktop
100
+ app - not just terminal \`claude\`.
101
+
102
+ How: writes HTTPS_PROXY + NODE_EXTRA_CA_CERTS into
103
+ ~/.claude/settings.json, pointing at a certificate authority generated
104
+ ON THIS MACHINE (~/.troxy/tls/). The private key never leaves this
105
+ machine and is never sent to Troxy. It is locked to api.anthropic.com
106
+ only, via a nameConstraints extension - it cannot be used to intercept
107
+ any other site - and it is never installed in your system keychain, only
108
+ referenced by Claude Code itself.
109
+
110
+ \`troxy init\` already offers this for terminal Claude Code automatically.
111
+ This command is for a desktop-only machine (no terminal \`claude\`), or to
112
+ reconfigure by hand. --experimental is required as an explicit
113
+ acknowledgment.
114
+
115
+ Remove any time with: troxy proxy disable
116
+ `);
117
+ process.exit(0);
118
+ }
119
+ if (!flags.experimental) {
120
+ console.error('\n This is experimental. Run: troxy proxy enable --experimental');
121
+ console.error(' Run `troxy proxy enable --help` first to see what this does.\n');
122
+ process.exit(1);
123
+ }
124
+
125
+ console.log("\n Route Claude's model calls through Troxy so policies are enforced in");
126
+ console.log(" real time, including the Claude desktop app's Code tab, which ignores");
127
+ console.log(' the normal ANTHROPIC_BASE_URL setting.\n');
128
+ console.log(' To do that on the desktop app, Troxy generates a certificate authority');
129
+ console.log(' ON THIS MACHINE. The private key never leaves this computer and is');
130
+ console.log(' never sent to Troxy. It is locked to api.anthropic.com only - it cannot');
131
+ console.log(' be used for any other site - and it is NOT installed in your system');
132
+ console.log(' keychain, only referenced by Claude Code. Everything except');
133
+ console.log(' api.anthropic.com passes through untouched and unreadable.\n');
134
+
135
+ process.stdout.write(' Generating certificate authority... ');
136
+ try {
137
+ ensureInterceptionCerts(TROXY_DIR, {
138
+ hostname: os.hostname(),
139
+ leafDnsNames: interceptHostsFor(enabledProviders()),
140
+ });
141
+ console.log('✓');
142
+ } catch (err) {
143
+ console.log('✗');
144
+ console.error(`\n Could not generate certificates: ${err.message}\n`);
145
+ process.exit(1);
146
+ }
147
+
148
+ const settingsPath = claudeCodeSettingsPath();
149
+ process.stdout.write(' Writing ~/.claude/settings.json... ');
150
+ try {
151
+ patchClaudeCodeInterception(settingsPath);
152
+ console.log('✓');
153
+ } catch (err) {
154
+ console.log('✗');
155
+ console.error(`\n Could not write settings.json: ${err.message}\n`);
156
+ process.exit(1);
157
+ }
158
+
159
+ const bound = await probePort(INTERCEPTOR_PORT);
160
+ if (!bound) {
161
+ console.log(`\n ⚠ Nothing is listening on 127.0.0.1:${INTERCEPTOR_PORT} yet - the running`);
162
+ console.log(' background daemon does not have this feature (it predates this');
163
+ console.log(' build). Restart it so the interceptor actually binds:');
164
+ console.log(' troxy restart');
165
+ console.log(' Until then, Claude Code will fail to connect while this is enabled.\n');
166
+ } else {
167
+ console.log(`\n Interceptor is live on 127.0.0.1:${INTERCEPTOR_PORT}. Restart Claude Code`);
168
+ console.log(' (and the desktop app, if open) to pick up the new settings.\n');
169
+ }
170
+ console.log(' Remove any time with: troxy proxy disable\n');
171
+ }
172
+
173
+ export function runProxyDisable() {
174
+ const settingsPath = claudeCodeSettingsPath();
175
+ process.stdout.write('\n Removing HTTPS_PROXY / NODE_EXTRA_CA_CERTS from settings.json... ');
176
+ try {
177
+ unpatchClaudeCodeInterception(settingsPath);
178
+ console.log('✓\n');
179
+ } catch (err) {
180
+ console.log('✗');
181
+ console.error(`\n ${err.message}\n`);
182
+ process.exit(1);
183
+ }
184
+ }