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/README.md +6 -0
- package/bin/troxy.js +13 -0
- package/package.json +3 -2
- package/src/auth.js +81 -33
- package/src/daemon.js +116 -3
- package/src/init.js +278 -26
- package/src/interceptor.js +402 -0
- package/src/providers.js +84 -0
- package/src/proxy.js +184 -0
- package/src/tests/auth.test.js +12 -1
- package/src/tests/claude-code-hook.test.js +96 -1
- package/src/tests/claude-code-proxy.test.js +120 -1
- package/src/tests/daemon.test.js +85 -0
- package/src/tests/init-detection.test.js +95 -0
- package/src/tests/install-service.test.js +69 -0
- package/src/tests/interception-config.test.js +203 -0
- package/src/tests/interceptor.test.js +559 -0
- package/src/tests/providers.test.js +78 -0
- package/src/tests/proxy-wiring.test.js +118 -0
- package/src/tests/tls-ca.test.js +222 -0
- package/src/tls-ca.js +208 -0
- package/src/uninstall.js +32 -4
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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?
|
|
621
|
-
console.log('
|
|
622
|
-
console.log(
|
|
623
|
-
console.log('
|
|
624
|
-
console.log('
|
|
625
|
-
console.log('
|
|
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
|
-
|
|
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
|
+
}
|