troxy-cli 1.29.3 → 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.
package/bin/troxy.js CHANGED
@@ -115,6 +115,19 @@ switch (command) {
115
115
  await runUninstall();
116
116
  break;
117
117
 
118
+ // ── Live Model Policy Enforcement interception (Layer 3, experimental) ─
119
+ // Not yet part of `troxy init` or the default help banner below - see
120
+ // proxy.js's own top-of-file note and the plan doc's rollout order for
121
+ // why this is deliberately opt-in-only and manually run for now.
122
+ case 'proxy': {
123
+ const { runProxyStatus, runProxyEnable, runProxyDisable } = await import('../src/proxy.js');
124
+ if (sub === 'enable') { await runProxyEnable(flags); break; }
125
+ if (sub === 'disable') { runProxyDisable(); break; }
126
+ if (sub === 'status' || !sub) { await runProxyStatus(); break; }
127
+ console.error(` Unknown: troxy proxy ${sub}. Try: status, enable, disable\n`);
128
+ process.exit(1);
129
+ }
130
+
118
131
  // ── Auth ──────────────────────────────────────────────────────
119
132
  case 'login':
120
133
  await runLogin(flags);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.29.3",
3
+ "version": "1.29.4",
4
4
  "description": "A secure control layer for AI agents: policies across payments, messages, logins, destructive actions, model usage, and secrets, all enforceable from the CLI",
5
5
  "homepage": "https://troxy.io",
6
6
  "bugs": {
@@ -14,7 +14,8 @@
14
14
  "test": "node --test src/tests/*.test.js"
15
15
  },
16
16
  "dependencies": {
17
- "@modelcontextprotocol/sdk": "^1.10.2"
17
+ "@modelcontextprotocol/sdk": "^1.10.2",
18
+ "node-forge": "^1.4.0"
18
19
  },
19
20
  "engines": {
20
21
  "node": ">=18"
package/src/daemon.js CHANGED
@@ -1,16 +1,125 @@
1
1
  /**
2
- * Troxy heartbeat daemon.
2
+ * Troxy background daemon.
3
3
  * Runs as a background service (systemd / launchd). Sends a heartbeat to the
4
4
  * Troxy API every 60 seconds so the dashboard shows this agent as connected.
5
- * Does NOT start an MCP stdio server that only makes sense when an MCP client
6
- * (Claude Desktop, Cursor, etc.) is present.
5
+ * Does NOT start an MCP stdio server - that only makes sense when an MCP
6
+ * client (Claude Desktop, Cursor, etc.) is present.
7
+ *
8
+ * As of Layer 2 of the Live Model Policy Enforcement plan, also hosts the
9
+ * local interceptor (interceptor.js) - the TLS-terminating proxy that lets
10
+ * the Claude Desktop app's Code tab route through Troxy the same way
11
+ * terminal `claude` already does via ANTHROPIC_BASE_URL. This lives here,
12
+ * not as a separate process, specifically so it inherits the supervision
13
+ * this daemon already has (launchd KeepAlive / systemd Restart=always) -
14
+ * see the plan doc's architecture section for why a fully local, always-
15
+ * supervised interceptor is the only design that can actually fail open
16
+ * when Troxy's own service has downtime.
7
17
  */
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { format } from 'node:util';
8
21
  import { loadConfig } from './config.js';
9
22
  import { api } from './api.js';
23
+ import { createInterceptor } from './interceptor.js';
24
+ import { enabledProviders, interceptHostsFor, troxyRouteFor } from './providers.js';
25
+ import { ensureInterceptionCerts } from './tls-ca.js';
10
26
 
11
27
  const INTERVAL_MS = 60_000;
12
28
 
29
+ // Fixed, not dynamically chosen - so settings.json's HTTPS_PROXY value
30
+ // stays valid across daemon restarts instead of needing to be rewritten
31
+ // every time. If this port is ever unavailable, the interceptor simply
32
+ // does not start (see startInterceptor's listen 'error' handler) rather
33
+ // than silently picking a different one and leaving settings.json wrong.
34
+ export const INTERCEPTOR_PORT = 48173;
35
+
36
+ // Re-checked once a day, not on a timer that matters much - ensureInterceptionCerts
37
+ // is idempotent when the existing CA/leaf are still healthy, so this is a
38
+ // cheap no-op almost every time it fires; it only does real work on the
39
+ // leaf's ~30-day renewal window or if something on disk got corrupted.
40
+ const CERT_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
41
+
42
+ function _log(fmt, ...args) {
43
+ process.stderr.write(`[troxy-interceptor] ${format(fmt, ...args)}\n`);
44
+ }
45
+
46
+ /**
47
+ * Starts the local interceptor, if any provider is enabled (Phase 1: just
48
+ * 'anthropic'). Never throws - a failure here (bad cert generation, the
49
+ * port already being held by something else) is logged and the daemon
50
+ * keeps running its heartbeat regardless; interception is additive, never
51
+ * a requirement for the rest of what this daemon does.
52
+ *
53
+ * `troxyDir` defaults to the real ~/.troxy but is injectable, matching
54
+ * ensureInterceptionCerts's own pattern - lets a test exercise this
55
+ * without touching whatever the real machine running the test has there.
56
+ * `port` defaults to INTERCEPTOR_PORT, injectable so a test doesn't
57
+ * collide with a real daemon that might already be running on this
58
+ * machine.
59
+ */
60
+ export function startInterceptor({ troxyDir = path.join(os.homedir(), '.troxy'), port = INTERCEPTOR_PORT } = {}) {
61
+ const providers = enabledProviders();
62
+ const interceptHosts = interceptHostsFor(providers);
63
+ if (interceptHosts.length === 0) return null;
64
+
65
+ let certs = null;
66
+
67
+ function refreshCerts() {
68
+ try {
69
+ const result = ensureInterceptionCerts(troxyDir, {
70
+ hostname: os.hostname(),
71
+ leafDnsNames: interceptHosts,
72
+ });
73
+ certs = { leafCertPem: result.leafCertPem, leafKeyPem: result.leafKeyPem };
74
+ } catch (err) {
75
+ _log('failed to generate interception certificates, tunneling only until this resolves: %s', err.message);
76
+ certs = null;
77
+ }
78
+ }
79
+ refreshCerts();
80
+ const refreshTimer = setInterval(refreshCerts, CERT_REFRESH_INTERVAL_MS);
81
+ refreshTimer.unref(); // must not keep the process alive on its own
82
+
83
+ const interceptor = createInterceptor({
84
+ interceptHosts,
85
+ routeResolver: (host, method, reqPath) => troxyRouteFor(providers, host, method, reqPath),
86
+ // Fresh from disk on every request, not cached at startup - a
87
+ // `troxy rotate-key` takes effect immediately, no daemon restart needed.
88
+ getTroxyKey: () => loadConfig()?.apiKey || null,
89
+ getCerts: () => certs,
90
+ log: _log,
91
+ });
92
+
93
+ interceptor.on('error', (err) => {
94
+ if (err.code === 'EADDRINUSE') {
95
+ _log('port %d is already in use by something else - interception is disabled this run. ' +
96
+ 'Run `troxy proxy status` to check, or `troxy proxy disable` if this persists.', port);
97
+ } else {
98
+ _log('server error (interception disabled this run): %s', err.message);
99
+ }
100
+ });
101
+
102
+ interceptor.listen(port, '127.0.0.1', () => {
103
+ _log('listening on 127.0.0.1:%d for %s', port, interceptHosts.join(', '));
104
+ });
105
+
106
+ return interceptor;
107
+ }
108
+
13
109
  export async function runDaemon() {
110
+ // Installed first, before anything else in this process can throw - a
111
+ // bug anywhere here (the heartbeat, cert generation, a single bad TLS
112
+ // handshake) must never take the whole daemon down. The interceptor's
113
+ // listener dying silently is exactly what broke Claude Code outright
114
+ // during this feature's own manual testing - log and keep running,
115
+ // never exit.
116
+ process.on('uncaughtException', (err) => {
117
+ process.stderr.write(`[troxy-daemon] uncaught exception (continuing): ${err.stack || err.message}\n`);
118
+ });
119
+ process.on('unhandledRejection', (err) => {
120
+ process.stderr.write(`[troxy-daemon] unhandled rejection (continuing): ${err?.stack || err}\n`);
121
+ });
122
+
14
123
  const cfg = loadConfig();
15
124
  if (!cfg?.apiKey) {
16
125
  process.stderr.write('[troxy-daemon] No API key found. Run troxy init first.\n');
@@ -19,6 +128,10 @@ export async function runDaemon() {
19
128
 
20
129
  const { apiKey, agentName } = cfg;
21
130
 
131
+ // Bound before the first heartbeat - a network hiccup reaching the Troxy
132
+ // API on startup must never leave the interceptor's port unbound.
133
+ startInterceptor();
134
+
22
135
  // `force` pushes the config name as the authoritative display name. We only
23
136
  // do this on the FIRST beat after (re)start, so a `troxy restart` overrides
24
137
  // the dashboard, while regular beats never clobber a dashboard rename.
package/src/init.js CHANGED
@@ -5,6 +5,10 @@ import readline from 'readline';
5
5
  import { execSync, execFileSync } from 'child_process';
6
6
  import { saveConfig } from './config.js';
7
7
  import { evaluatePayment, api } from './api.js';
8
+ import { INTERCEPTOR_PORT } from './daemon.js';
9
+ import { ensureInterceptionCerts } from './tls-ca.js';
10
+ import { enabledProviders, interceptHostsFor } from './providers.js';
11
+ import { probePort } from './proxy.js';
8
12
 
9
13
  // Re-point every consumer of the API key (MCP client configs, the background service /
10
14
  // systemd env file / launchd plist, OpenClaw) at `key`. Shared by runInit and rotate-key
@@ -87,6 +91,15 @@ export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null)
87
91
  console.log(` • OpenClaw ✗ (${err.message})`);
88
92
  }
89
93
  }
94
+ // Claude Desktop's Code tab runs the same Claude Code engine under the
95
+ // hood and reads the same global ~/.claude/settings.json - it just has
96
+ // no CLI on PATH for hasClaudeCode()'s probe to find, and (unlike
97
+ // terminal `claude`) no project-scoped ~/.claude.json entry to patch.
98
+ // Before this fix, a desktop-only machine (no terminal `claude` on PATH
99
+ // at all) got ZERO settings.json writes: not even the pre-existing
100
+ // Stop/PreToolUse hook capture, let alone anything interception-related.
101
+ // See the plan doc's Layer 3 section ("the detection gap to fix").
102
+ const hasDesktop = hasClaudeDesktop();
90
103
  if (hasClaude) {
91
104
  try {
92
105
  patchClaudeCodeConfig(claudeCodeConfigPath(), key);
@@ -94,13 +107,28 @@ export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null)
94
107
  } catch (err) {
95
108
  console.log(` • Claude Code ✗ (${err.message})`);
96
109
  }
110
+ }
111
+ if (hasClaude || hasDesktop) {
97
112
  try {
98
113
  patchClaudeCodeHooks(claudeCodeSettingsPath());
99
114
  console.log(` • Claude Code usage capture (all projects) ✓`);
100
115
  } catch (err) {
101
116
  console.log(` • Claude Code usage capture ✗ (${err.message})`);
102
117
  }
103
- await maybeEnableClaudeCodeProxy(proxyOptIn, key);
118
+ }
119
+ if (hasClaude) {
120
+ // Terminal used to get base-URL substitution (maybeEnableClaudeCodeProxy)
121
+ // here - found live 2026-09-07 that it silently bills the org's own
122
+ // pay-as-you-go API key for every terminal call, since substituting
123
+ // ANTHROPIC_AUTH_TOKEN with a Troxy token means no real Anthropic
124
+ // credential ever reaches model-proxy. This now reuses the same
125
+ // interception mechanism already live for Desktop (see
126
+ // patchClaudeCodeInterception below) instead: terminal keeps its own
127
+ // real login, Troxy watches the traffic on its way to Anthropic.
128
+ // Desktop-only machines are unaffected by this call (hasDesktop is
129
+ // not part of the gate here) - they still use the existing manual
130
+ // `troxy proxy enable --experimental` path.
131
+ await maybeEnableTroxyInterception(proxyOptIn, hasDesktop);
104
132
  }
105
133
  console.log('\n Restart your MCP client to activate Troxy.');
106
134
  }
@@ -320,6 +348,28 @@ export async function runInit({ key, name, proxy } = {}) {
320
348
  console.log('\n For more information, visit https://docs.troxy.io\n');
321
349
  }
322
350
 
351
+ // Found live 2026-09-08: `which troxy` failing silently fell back to a
352
+ // guessed path, `/usr/local/bin/troxy`, that may not exist on this machine
353
+ // at all. Nothing downstream ever checked - the plist/unit file got written
354
+ // and loaded pointing at a binary that isn't there, and installService still
355
+ // returned normally, so init printed "Background service installed ✓" for a
356
+ // service that could never actually start. The specific incident that
357
+ // surfaced this was a dev-only setup (this repo living under a TCC-
358
+ // protected folder, see troxy-hq/CLAUDE.md) - but `which troxy` reliably
359
+ // fails for real end users too, every time someone runs `npx troxy-cli
360
+ // init` (README's own documented install path): npx never puts the binary
361
+ // on PATH, so this is the common case for that install method, not an edge
362
+ // case. The error message below leads with the fix that actually applies to
363
+ // them.
364
+ export function verifyTroxyBinExists(troxy) {
365
+ if (!fs.existsSync(troxy)) {
366
+ throw new Error(
367
+ `resolved troxy binary not found at ${troxy} - run "npm install -g troxy-cli" ` +
368
+ '(or "npm link" from a local checkout) so "which troxy" resolves correctly, then re-run init',
369
+ );
370
+ }
371
+ }
372
+
323
373
  function installService(apiKey, agentName) {
324
374
  const platform = process.platform;
325
375
  let troxy;
@@ -328,8 +378,12 @@ function installService(apiKey, agentName) {
328
378
  } catch {
329
379
  troxy = '/usr/local/bin/troxy';
330
380
  }
381
+ verifyTroxyBinExists(troxy);
331
382
 
332
383
  if (platform === 'linux') {
384
+ // No StandardOutPath/StandardErrorPath equivalent needed here (unlike the
385
+ // darwin branch below) - systemd captures a service's stdout/stderr into
386
+ // the journal automatically; `journalctl -u troxy-mcp` is the log.
333
387
  // The API key lives in a separate root-owned, 600-permission file loaded via
334
388
  // EnvironmentFile= — unit files under /etc/systemd/system are world-readable
335
389
  // (mode 644), so putting the key directly in `Environment=` there would leak
@@ -362,6 +416,19 @@ WantedBy=multi-user.target
362
416
  execSync('sudo systemctl restart troxy-mcp');
363
417
 
364
418
  } else if (platform === 'darwin') {
419
+ // Found live 2026-09-08: with no StandardOutPath/StandardErrorPath, a
420
+ // daemon that fails to start under launchd (e.g. the TCC-protected-folder
421
+ // EPERM bug documented in troxy-hq/CLAUDE.md) fails completely silently -
422
+ // no log anywhere says why. These two keys are launchd's only mechanism
423
+ // for capturing a job's stdout/stderr (there is no journald equivalent on
424
+ // macOS); the log directory is created below, before the plist is loaded,
425
+ // since launchd creates the log *file* on first output but not a missing
426
+ // parent directory.
427
+ const troxyDir = path.join(os.homedir(), '.troxy');
428
+ const stdoutLogPath = path.join(troxyDir, 'daemon-out.log');
429
+ const stderrLogPath = path.join(troxyDir, 'daemon-err.log');
430
+ fs.mkdirSync(troxyDir, { recursive: true });
431
+
365
432
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
366
433
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
367
434
  <plist version="1.0">
@@ -382,6 +449,10 @@ WantedBy=multi-user.target
382
449
  <key>PATH</key>
383
450
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
384
451
  </dict>
452
+ <key>StandardOutPath</key>
453
+ <string>${stdoutLogPath}</string>
454
+ <key>StandardErrorPath</key>
455
+ <string>${stderrLogPath}</string>
385
456
  <key>RunAtLoad</key>
386
457
  <true/>
387
458
  <key>KeepAlive</key>
@@ -466,10 +537,27 @@ function patchContinueConfig(configPath, apiKey) {
466
537
  // asking the agent to also report chat-only turns would double-count against
467
538
  // the hook's real capture of the same turns. Tool-use self-report is left
468
539
  // alone; the hook does not replace that, only chat-only reporting.
469
- function claudeCodeConfigPath() {
540
+ export function claudeCodeConfigPath() {
470
541
  return path.join(os.homedir(), '.claude.json');
471
542
  }
472
543
 
544
+ // checklist item: uninstall symmetry. Removes the troxy mcpServers entry
545
+ // from EVERY project in ~/.claude.json, not just process.cwd() - a machine
546
+ // may have run `troxy init` from several project directories over time,
547
+ // each getting its own entry (patchClaudeCodeConfig is keyed by cwd), and
548
+ // uninstall has no reliable way to know which cwd(s) were used, so it
549
+ // simply scans every project this file knows about.
550
+ export function unpatchClaudeCodeConfig(configPath) {
551
+ let config = {};
552
+ try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return; }
553
+ if (config.projects) {
554
+ for (const proj of Object.values(config.projects)) {
555
+ if (proj?.mcpServers?.troxy) delete proj.mcpServers.troxy;
556
+ }
557
+ }
558
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
559
+ }
560
+
473
561
  // configPath and cwd are explicit params, same shape as patchMcpConfig /
474
562
  // patchZedConfig above, so this can be pointed at a temp file and a fake
475
563
  // project directory in tests rather than always touching the real
@@ -496,7 +584,7 @@ export function patchClaudeCodeConfig(configPath, apiKey, cwd = process.cwd()) {
496
584
  // and replacing it in place, rather than blind-pushing - a second `troxy
497
585
  // init` must not duplicate the entry, and any of the user's OWN unrelated
498
586
  // Stop hooks in the same file must survive untouched.
499
- function claudeCodeSettingsPath() {
587
+ export function claudeCodeSettingsPath() {
500
588
  return path.join(os.homedir(), '.claude', 'settings.json');
501
589
  }
502
590
 
@@ -523,6 +611,17 @@ export function troxyPreToolUseCommand() {
523
611
  return `${_resolveTroxyBin()} pretooluse-hook`;
524
612
  }
525
613
 
614
+ // Shared by patchClaudeCodeHooks and unpatchClaudeCodeHooks, so "what counts
615
+ // as a Troxy-owned hook entry" has exactly one definition.
616
+ function _isTroxyStopEntry(matcherEntry) {
617
+ return Array.isArray(matcherEntry?.hooks) &&
618
+ matcherEntry.hooks.some(h => typeof h?.command === 'string' && h.command.includes('hook-report'));
619
+ }
620
+ function _isTroxyPreToolUseEntry(matcherEntry) {
621
+ return Array.isArray(matcherEntry?.hooks) &&
622
+ matcherEntry.hooks.some(h => typeof h?.command === 'string' && h.command.includes('pretooluse-hook'));
623
+ }
624
+
526
625
  export function patchClaudeCodeHooks(
527
626
  configPath,
528
627
  stopCommand = troxyHookCommand(),
@@ -533,11 +632,8 @@ export function patchClaudeCodeHooks(
533
632
  if (!config.hooks) config.hooks = {};
534
633
 
535
634
  if (!Array.isArray(config.hooks.Stop)) config.hooks.Stop = [];
536
- const isTroxyStopEntry = (matcherEntry) =>
537
- Array.isArray(matcherEntry?.hooks) &&
538
- matcherEntry.hooks.some(h => typeof h?.command === 'string' && h.command.includes('hook-report'));
539
635
  const stopEntry = { hooks: [{ type: 'command', command: stopCommand }] };
540
- const stopIdx = config.hooks.Stop.findIndex(isTroxyStopEntry);
636
+ const stopIdx = config.hooks.Stop.findIndex(_isTroxyStopEntry);
541
637
  if (stopIdx >= 0) config.hooks.Stop[stopIdx] = stopEntry;
542
638
  else config.hooks.Stop.push(stopEntry);
543
639
 
@@ -549,11 +645,8 @@ export function patchClaudeCodeHooks(
549
645
  // Claude Code cancels the hook and lets the tool proceed once this
550
646
  // elapses (its documented behavior for a timed-out PreToolUse hook).
551
647
  if (!Array.isArray(config.hooks.PreToolUse)) config.hooks.PreToolUse = [];
552
- const isTroxyPreToolUseEntry = (matcherEntry) =>
553
- Array.isArray(matcherEntry?.hooks) &&
554
- matcherEntry.hooks.some(h => typeof h?.command === 'string' && h.command.includes('pretooluse-hook'));
555
648
  const preToolUseEntry = { matcher: '*', hooks: [{ type: 'command', command: preToolUseCommand, timeout: 5 }] };
556
- const preIdx = config.hooks.PreToolUse.findIndex(isTroxyPreToolUseEntry);
649
+ const preIdx = config.hooks.PreToolUse.findIndex(_isTroxyPreToolUseEntry);
557
650
  if (preIdx >= 0) config.hooks.PreToolUse[preIdx] = preToolUseEntry;
558
651
  else config.hooks.PreToolUse.push(preToolUseEntry);
559
652
 
@@ -561,6 +654,21 @@ export function patchClaudeCodeHooks(
561
654
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
562
655
  }
563
656
 
657
+ // Uninstall symmetry (fail-open layer 6 in the plan doc): removes exactly
658
+ // the Troxy-owned Stop/PreToolUse entries, leaving any of the user's own
659
+ // unrelated hooks in the same file untouched.
660
+ export function unpatchClaudeCodeHooks(configPath) {
661
+ let config = {};
662
+ try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return; }
663
+ if (Array.isArray(config.hooks?.Stop)) {
664
+ config.hooks.Stop = config.hooks.Stop.filter(e => !_isTroxyStopEntry(e));
665
+ }
666
+ if (Array.isArray(config.hooks?.PreToolUse)) {
667
+ config.hooks.PreToolUse = config.hooks.PreToolUse.filter(e => !_isTroxyPreToolUseEntry(e));
668
+ }
669
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
670
+ }
671
+
564
672
  // checklist #17 ("one-command proxy setup") - routes Claude Code's model
565
673
  // calls through Troxy's model-proxy instead of straight to Anthropic, so
566
674
  // evaluate_model's advisory model-swap suggestions become real, enforced
@@ -606,30 +714,91 @@ export function patchClaudeCodeProxy(configPath, troxyKey, baseUrl = troxyModelP
606
714
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
607
715
  }
608
716
 
609
- // Opt-in only (Gilad, 2026-08-29): the Remote Control tradeoff is real and
610
- // user-visible, so this must be a deliberate yes, never a default-on
611
- // behavior change slipped into an ordinary init run.
612
- // proxyOptIn === true -> --proxy was passed explicitly, enable without asking
613
- // proxyOptIn === null -> not passed; ask interactively if there's a TTY,
614
- // otherwise skip (a scripted/CI init must never hang
615
- // on a prompt, and silence must never mean yes)
616
- async function maybeEnableClaudeCodeProxy(proxyOptIn, key) {
717
+ // Uninstall symmetry (fail-open layer 6): removes exactly the three keys
718
+ // patchClaudeCodeProxy writes, leaving any other env keys (the user's own,
719
+ // or the interception keys below) untouched.
720
+ export function unpatchClaudeCodeProxy(configPath) {
721
+ let config = {};
722
+ try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return; }
723
+ if (config.env) {
724
+ delete config.env.ANTHROPIC_BASE_URL;
725
+ delete config.env.ANTHROPIC_AUTH_TOKEN;
726
+ delete config.env.ENABLE_TOOL_SEARCH;
727
+ }
728
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
729
+ }
730
+
731
+ // Auto-migration for the terminal-interception-passthrough fix
732
+ // (2026-09-08): a machine that already ran `troxy init --proxy` has
733
+ // ANTHROPIC_AUTH_TOKEN set to a Troxy token instead of a real Anthropic
734
+ // credential, which is why model-proxy had to fall back to the org's own
735
+ // pay-as-you-go API key to pay for terminal calls at all - confirmed live
736
+ // 2026-09-07, a single terminal message visibly moved the org's Console
737
+ // API balance instead of the user's Claude subscription usage. Called
738
+ // unconditionally by maybeEnableTroxyInterception (below), regardless of
739
+ // whether the user then opts into interception - leaving these keys in
740
+ // place is strictly worse than falling back to no Troxy visibility for
741
+ // terminal at all.
742
+ export function migrateBaseUrlProxyToInterception(configPath) {
743
+ let config = {};
744
+ try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return false; }
745
+ const env = config.env || {};
746
+ const isTroxyOwned =
747
+ env.ANTHROPIC_BASE_URL === troxyModelProxyBaseUrl() ||
748
+ String(env.ANTHROPIC_AUTH_TOKEN || '').startsWith('txy-');
749
+ if (!isTroxyOwned) return false;
750
+ unpatchClaudeCodeProxy(configPath);
751
+ return true;
752
+ }
753
+
754
+ // Terminal's counterpart to the desktop app's already-shipped
755
+ // interception path. See the call site above for why terminal moved off
756
+ // base-URL substitution. Same proxyOptIn semantics as the retired
757
+ // maybeEnableClaudeCodeProxy: true = enable without asking, null + TTY =
758
+ // ask interactively, otherwise skip (a scripted/CI init must never hang
759
+ // on a prompt).
760
+ async function maybeEnableTroxyInterception(proxyOptIn, hasDesktop, configPath = claudeCodeSettingsPath()) {
761
+ // Unconditional, regardless of what's decided below: a machine still on
762
+ // the old base-URL substitution keys is actively mischarging the org's
763
+ // API balance right now, and leaving that in place is strictly worse
764
+ // than falling back to no Troxy visibility for terminal at all.
765
+ const migrated = migrateBaseUrlProxyToInterception(configPath);
766
+ if (migrated) {
767
+ console.log(' • Removed the old Troxy proxy setup for terminal (was billing your org\'s API key instead of your subscription)');
768
+ }
769
+
770
+ if (interceptionIsConfigured(configPath)) return; // already set up correctly, nothing to ask
771
+
617
772
  let enable = proxyOptIn === true;
618
773
  if (proxyOptIn === null && process.stdin.isTTY) {
619
774
  console.log("\n Route Claude Code's model calls through Troxy for automatic cost");
620
- console.log(' optimization? Troxy can swap in a cheaper model when it finds an');
621
- console.log(' opportunity, not just suggest one.');
622
- console.log(" Trade-off: while this is on, Claude Code's Remote Control feature is");
623
- console.log(' disabled (Anthropic disables it whenever the API base URL points');
624
- console.log(' anywhere other than api.anthropic.com). Model policies (e.g. a BLOCK');
625
- console.log(' rule on a specific model) ARE enforced on this path.');
775
+ console.log(' optimization? Your normal Claude login (subscription or API key) still');
776
+ console.log(' pays - Troxy never substitutes its own credential. Troxy does see your');
777
+ console.log(' real credential in transit, forwarded upstream to actually make the');
778
+ console.log(' call to Anthropic on your behalf.');
779
+ console.log(' How: Troxy generates a certificate authority ON THIS MACHINE');
780
+ console.log(' (~/.troxy/tls/). The private key never leaves this computer and is');
781
+ console.log(' never sent to Troxy. It is locked to api.anthropic.com only and is');
782
+ console.log(' NOT installed in your system keychain, only referenced by Claude Code.');
783
+ if (hasDesktop) {
784
+ console.log(' This also covers the Claude desktop app\'s Code tab (same setting).');
785
+ }
786
+ console.log(' Remove any time with: troxy proxy disable');
626
787
  const answer = await prompt(' Enable? (y/N): ');
627
788
  enable = /^y(es)?$/i.test(answer);
628
789
  }
629
790
  if (!enable) return;
791
+
630
792
  try {
631
- patchClaudeCodeProxy(claudeCodeSettingsPath(), key);
793
+ const troxyDir = path.join(os.homedir(), '.troxy');
794
+ ensureInterceptionCerts(troxyDir, { hostname: os.hostname(), leafDnsNames: interceptHostsFor(enabledProviders()) });
795
+ patchClaudeCodeInterception(configPath);
632
796
  console.log(` • Claude Code model proxy (cost optimization) ✓`);
797
+ const bound = await probePort(INTERCEPTOR_PORT);
798
+ if (!bound) {
799
+ console.log(` ⚠ Nothing is listening on 127.0.0.1:${INTERCEPTOR_PORT} yet - restart the`);
800
+ console.log(' background service (`troxy restart`) so Claude Code can connect.');
801
+ }
633
802
  } catch (err) {
634
803
  console.log(` • Claude Code model proxy ✗ (${err.message})`);
635
804
  }
@@ -648,3 +817,86 @@ export function hasClaudeCode() {
648
817
  return false;
649
818
  }
650
819
  }
820
+
821
+ // Claude Desktop's Code tab has no "claude --version" to probe (it isn't a
822
+ // CLI on PATH) and no marker *file* whose mere existence is reliable
823
+ // (~/.claude.json / ~/.claude/settings.json are created lazily by troxy
824
+ // itself, so their existence proves nothing about whether Desktop is
825
+ // installed - the exact hazard tool_detect.js already documents and avoids
826
+ // for Cursor by checking the app's own per-user data directory instead of a
827
+ // system-wide /Applications path). Same technique here, and for the same
828
+ // reason tool_detect.js never checks a fixed /Applications/<App>.app path
829
+ // either: only per-user, HOME-relative locations, so this stays correct
830
+ // under a real $HOME override in tests instead of depending on whatever
831
+ // happens to be installed system-wide on the machine running them.
832
+ export function hasClaudeDesktop() {
833
+ const home = os.homedir();
834
+ const paths = [
835
+ path.join(home, 'Applications/Claude.app'),
836
+ path.join(home, 'Library/Application Support/Claude'), // macOS
837
+ path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData/Local'), 'AnthropicClaude'), // Windows
838
+ path.join(process.env.APPDATA || path.join(home, 'AppData/Roaming'), 'Claude'),
839
+ path.join(home, '.config/Claude'), // Linux
840
+ ];
841
+ return paths.some(p => { try { return fs.existsSync(p); } catch { return false; } });
842
+ }
843
+
844
+ // The port the local interceptor listens on (daemon.js's startInterceptor) -
845
+ // re-exported here as a URL so callers never hardcode 127.0.0.1 or the
846
+ // scheme themselves.
847
+ export function troxyInterceptorProxyUrl(port = INTERCEPTOR_PORT) {
848
+ return `http://127.0.0.1:${port}`;
849
+ }
850
+
851
+ export function troxyLocalCaCertPath(troxyDir = path.join(os.homedir(), '.troxy')) {
852
+ return path.join(troxyDir, 'tls', 'troxy-local-ca.crt');
853
+ }
854
+
855
+ // Layer 3 of the Live Model Policy Enforcement plan: routes the desktop
856
+ // app's Code tab (which hard-overrides ANTHROPIC_BASE_URL, so the base-URL
857
+ // substitution above never reaches it) through the local interceptor
858
+ // instead. Terminal `claude` honors these two the same way Desktop does.
859
+ // Do NOT also leave the base-URL substitution keys set for the same
860
+ // surface - if `ANTHROPIC_BASE_URL` points straight at proxy.troxy.io,
861
+ // Claude Code never touches api.anthropic.com at all, so this
862
+ // interceptor's allowlist never sees that traffic; base-URL substitution
863
+ // silently wins. See `migrateBaseUrlProxyToInterception` for how
864
+ // terminal's own provisioning avoids this.
865
+ export function patchClaudeCodeInterception(
866
+ configPath,
867
+ { proxyUrl = troxyInterceptorProxyUrl(), caCertPath = troxyLocalCaCertPath() } = {},
868
+ ) {
869
+ let config = {};
870
+ try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
871
+ if (!config.env) config.env = {};
872
+ config.env.HTTPS_PROXY = proxyUrl;
873
+ config.env.NODE_EXTRA_CA_CERTS = caCertPath;
874
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
875
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
876
+ }
877
+
878
+ // `troxy proxy disable`'s one-line recovery, and part of uninstall symmetry
879
+ // (fail-open layer 6) - removes exactly these two keys, leaving the
880
+ // base-URL proxy keys (if also set) and any of the user's own env untouched.
881
+ export function unpatchClaudeCodeInterception(configPath) {
882
+ let config = {};
883
+ try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return; }
884
+ if (config.env) {
885
+ delete config.env.HTTPS_PROXY;
886
+ delete config.env.NODE_EXTRA_CA_CERTS;
887
+ }
888
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
889
+ }
890
+
891
+ // Used by `troxy proxy status`/`enable` to avoid re-prompting or rewriting
892
+ // settings that are already correct (the same class of bug the pre-existing
893
+ // maybeEnableClaudeCodeProxy has - re-prompts on every rotate-key/update
894
+ // because it never checks this).
895
+ export function interceptionIsConfigured(
896
+ configPath,
897
+ { proxyUrl = troxyInterceptorProxyUrl(), caCertPath = troxyLocalCaCertPath() } = {},
898
+ ) {
899
+ let config = {};
900
+ try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return false; }
901
+ return config.env?.HTTPS_PROXY === proxyUrl && config.env?.NODE_EXTRA_CA_CERTS === caCertPath;
902
+ }