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.
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
+ }