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 +13 -0
- package/package.json +3 -2
- 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/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/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
|
+
"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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|