troxy-cli 1.29.13 → 1.29.15

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
@@ -12,7 +12,7 @@ import { runSettings } from '../src/settings.js';
12
12
  import { runApprovals } from '../src/approvals.js';
13
13
  import { runAccount } from '../src/account.js';
14
14
  import { runSecrets } from '../src/secrets.js';
15
- import { api } from '../src/api.js';
15
+ import { api, checkLatestVersion } from '../src/api.js';
16
16
  import { table } from '../src/print.js';
17
17
  import { printSimHelp, runSend, runSignin, runAction, runModelCall, runScan } from '../src/simulate.js';
18
18
 
@@ -70,8 +70,7 @@ switch (command) {
70
70
  const { version } = createRequire(import.meta.url)('../package.json');
71
71
  process.stdout.write(version);
72
72
  try {
73
- const res = await fetch('https://registry.npmjs.org/troxy-cli/latest', { signal: AbortSignal.timeout(3000) });
74
- const { version: latest } = await res.json();
73
+ const latest = await checkLatestVersion();
75
74
  if (version !== latest) {
76
75
  process.stdout.write(` ⚠ ${latest} available, run: troxy update`);
77
76
  }
@@ -495,8 +494,7 @@ switch (command) {
495
494
  const { createRequire } = await import('module');
496
495
  const require = createRequire(import.meta.url);
497
496
  const { version: current } = require('../package.json');
498
- const res = await fetch('https://registry.npmjs.org/troxy-cli/latest', { signal: AbortSignal.timeout(3000) });
499
- const { version: latest } = await res.json();
497
+ const latest = await checkLatestVersion();
500
498
  if (current !== latest) {
501
499
  console.log(` ⚠ New version available: ${latest} (you have ${current})`);
502
500
  console.log(` Run: troxy update\n`);
@@ -560,8 +558,7 @@ switch (command) {
560
558
  process.stdout.write('\n Checking for updates... ');
561
559
  let latest;
562
560
  try {
563
- const res = await fetch('https://registry.npmjs.org/troxy-cli/latest', { signal: AbortSignal.timeout(5000) });
564
- ({ version: latest } = await res.json());
561
+ latest = await checkLatestVersion(5000);
565
562
  } catch {
566
563
  console.log('✗');
567
564
  console.error('\n Could not reach npm registry. Check your connection.\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.29.13",
3
+ "version": "1.29.15",
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": {
package/src/api.js CHANGED
@@ -18,6 +18,18 @@ export const CLI_VERSION = (() => {
18
18
  }
19
19
  })();
20
20
 
21
+ // Fetches the latest published version from the npm registry. Throws on
22
+ // any failure (network error, timeout, bad response) — callers that just
23
+ // want to print an upgrade nudge should swallow the error; `troxy update`
24
+ // needs to tell the difference and reports it to the user.
25
+ export async function checkLatestVersion(timeoutMs = 3000) {
26
+ const res = await fetch('https://registry.npmjs.org/troxy-cli/latest', {
27
+ signal: AbortSignal.timeout(timeoutMs),
28
+ });
29
+ const { version } = await res.json();
30
+ return version;
31
+ }
32
+
21
33
  async function request(method, path, { apiKey, jwt, body } = {}) {
22
34
  const headers = { 'Content-Type': 'application/json' };
23
35
  if (apiKey) headers['X-Troxy-Key'] = apiKey;
package/src/daemon.js CHANGED
@@ -23,6 +23,7 @@ import { api } from './api.js';
23
23
  import { createInterceptor } from './interceptor.js';
24
24
  import { enabledProviders, interceptHostsFor, troxyRouteFor } from './providers.js';
25
25
  import { ensureInterceptionCerts } from './tls-ca.js';
26
+ import { migrateBaseUrlProxyToInterception, claudeCodeSettingsPath } from './init.js';
26
27
 
27
28
  const INTERVAL_MS = 60_000;
28
29
 
@@ -55,9 +56,28 @@ function _log(fmt, ...args) {
55
56
  * without touching whatever the real machine running the test has there.
56
57
  * `port` defaults to INTERCEPTOR_PORT, injectable so a test doesn't
57
58
  * collide with a real daemon that might already be running on this
58
- * machine.
59
+ * machine. `configPath` defaults to the real ~/.claude/settings.json,
60
+ * injectable for the same reason.
61
+ *
62
+ * Also self-heals a machine still stuck on the old ANTHROPIC_BASE_URL
63
+ * substitution (retired 2026-09-08, see migrateBaseUrlProxyToInterception's
64
+ * own docstring in init.js): that migration previously only ran inside
65
+ * `troxy init`, so a machine that set it up before the retirement and never
66
+ * re-ran init keeps routing terminal traffic straight to model-proxy via
67
+ * Claude Code's own HTTP client - a path this codebase never touches at
68
+ * all, so it can never carry the x-troxy-surface header buildTroxyForwardHeaders
69
+ * (interceptor.js) sets. Confirmed live: 30 days of real traffic with zero
70
+ * surface='terminal' rows, all landing as unattributed NULL instead. Running
71
+ * the same migration here too means every daemon (re)start heals it, not
72
+ * just an interactive `init` the user may never re-run.
59
73
  */
60
- export function startInterceptor({ troxyDir = path.join(os.homedir(), '.troxy'), port = INTERCEPTOR_PORT } = {}) {
74
+ export function startInterceptor({
75
+ troxyDir = path.join(os.homedir(), '.troxy'),
76
+ port = INTERCEPTOR_PORT,
77
+ configPath = claudeCodeSettingsPath(),
78
+ } = {}) {
79
+ migrateBaseUrlProxyToInterception(configPath);
80
+
61
81
  const providers = enabledProviders();
62
82
  const interceptHosts = interceptHostsFor(providers);
63
83
  if (interceptHosts.length === 0) return null;
package/src/providers.js CHANGED
@@ -6,15 +6,11 @@
6
6
  * route here - passes through as a blind byte tunnel, never parsed, never
7
7
  * visible to this codebase at all.
8
8
  *
9
- * Phase 1 (see the plan doc) is Anthropic only. openai_codex/cursor are
10
- * declared here, disabled, purely so Phase 2/3 is a data change to this
11
- * file later, not a rewrite of the interceptor or the CA logic - the
12
- * provider's real divergence (Codex signs with CODEX_CA_CERTIFICATE, not
13
- * NODE_EXTRA_CA_CERTS, since it's Rust-based) is already captured as a
14
- * field, not hardcoded control flow, even though it isn't wired to
15
- * anything yet. Do not flip `enabled` on either without first doing that
16
- * phase's own hands-on verification - see the plan's Phase 2/3 section for
17
- * exactly what's still unverified about each.
9
+ * Phase 1 (see the plan doc) is Anthropic only. Codex/Cursor support was
10
+ * speculatively declared here, disabled, ahead of Phase 2/3 - deleted per
11
+ * YAGNI (ponytail audit): re-add when that phase actually ships, with its
12
+ * own hands-on verification of each provider's real divergence (e.g. Codex
13
+ * signs with CODEX_CA_CERTIFICATE, not NODE_EXTRA_CA_CERTS).
18
14
  */
19
15
 
20
16
  export const PROVIDERS = [
@@ -28,28 +24,6 @@ export const PROVIDERS = [
28
24
  ],
29
25
  envKeys: { proxy: 'HTTPS_PROXY', ca: 'NODE_EXTRA_CA_CERTS' },
30
26
  },
31
- {
32
- id: 'openai_codex',
33
- name: 'Codex CLI',
34
- enabled: false, // Phase 2 - not wired up. See plan doc: no official network-config
35
- // doc exists for Codex, and whether its TLS stack enforces
36
- // nameConstraints the way OpenSSL does is unverified.
37
- interceptHosts: ['api.openai.com'],
38
- troxyRoutes: [],
39
- envKeys: { proxy: 'HTTPS_PROXY', ca: 'CODEX_CA_CERTIFICATE' },
40
- },
41
- {
42
- id: 'cursor',
43
- name: 'Cursor',
44
- enabled: false, // Phase 3 - not wired up, shape genuinely unknown. See plan doc:
45
- // Cursor routes ALL traffic through its own backend even with
46
- // BYOK, so this would need to intercept api2.cursor.sh (Cursor
47
- // itself), not a model provider - a materially different,
48
- // unverified scope. Needs its own hands-on test before design.
49
- interceptHosts: [],
50
- troxyRoutes: [],
51
- envKeys: {},
52
- },
53
27
  ];
54
28
 
55
29
  export function enabledProviders() {
package/src/proxy.js CHANGED
@@ -172,6 +172,13 @@ export async function runProxyEnable(flags = {}) {
172
172
 
173
173
  export function runProxyDisable() {
174
174
  const settingsPath = claudeCodeSettingsPath();
175
+
176
+ // Same restart hazard as uninstall.js's own fix: a Claude Code process
177
+ // already running has these two env vars loaded in memory, independent of
178
+ // what settings.json says from this point on. Check BEFORE unpatching so
179
+ // this reflects the state the running process actually launched with.
180
+ const wasIntercepting = interceptionIsConfigured(settingsPath);
181
+
175
182
  process.stdout.write('\n Removing HTTPS_PROXY / NODE_EXTRA_CA_CERTS from settings.json... ');
176
183
  try {
177
184
  unpatchClaudeCodeInterception(settingsPath);
@@ -181,4 +188,14 @@ export function runProxyDisable() {
181
188
  console.error(`\n ${err.message}\n`);
182
189
  process.exit(1);
183
190
  }
191
+
192
+ if (wasIntercepting) {
193
+ console.log(' ⚠ Restart Claude Code / Claude Desktop now for this to take effect -');
194
+ console.log(' the running process still has the old proxy settings loaded in');
195
+ console.log(' memory and will keep routing through Troxy until you do.\n');
196
+ }
197
+
198
+ console.log(' The CLI, background daemon, certificate, and your API key are all still');
199
+ console.log(' in place - this only stops routing calls through Troxy. Turn it back on');
200
+ console.log(' any time with: troxy proxy enable --experimental\n');
184
201
  }
package/src/simulate.js CHANGED
@@ -219,25 +219,27 @@ export function _scanBody(flags) {
219
219
  };
220
220
  }
221
221
 
222
- export async function runSend(flags) {
223
- const apiKey = _requireApiKey();
224
- _report(await api.evaluateEmail(_sendBody(flags), apiKey));
225
- }
226
-
227
- export async function runSignin(flags) {
228
- const apiKey = _requireApiKey();
229
- _report(await api.evaluateLogin(_signinBody(flags), apiKey));
230
- }
222
+ // send/signin/action/model-call all just build a body, POST it, and print
223
+ // the standard report - a lookup table drives them instead of four copies
224
+ // of the same two lines. runScan below is NOT in this table: it has real
225
+ // extra behavior (printing detections) so it stays its own function.
226
+ const SIMULATORS = {
227
+ send: [api.evaluateEmail, _sendBody],
228
+ signin: [api.evaluateLogin, _signinBody],
229
+ action: [api.evaluateAction, _actionBody],
230
+ 'model-call': [api.evaluateModel, _modelCallBody],
231
+ };
231
232
 
232
- export async function runAction(flags) {
233
+ async function _runSimulator(kind, flags) {
234
+ const [evaluate, buildBody] = SIMULATORS[kind];
233
235
  const apiKey = _requireApiKey();
234
- _report(await api.evaluateAction(_actionBody(flags), apiKey));
236
+ _report(await evaluate(buildBody(flags), apiKey));
235
237
  }
236
238
 
237
- export async function runModelCall(flags) {
238
- const apiKey = _requireApiKey();
239
- _report(await api.evaluateModel(_modelCallBody(flags), apiKey));
240
- }
239
+ export const runSend = (flags) => _runSimulator('send', flags);
240
+ export const runSignin = (flags) => _runSimulator('signin', flags);
241
+ export const runAction = (flags) => _runSimulator('action', flags);
242
+ export const runModelCall = (flags) => _runSimulator('model-call', flags);
241
243
 
242
244
  export async function runScan(flags) {
243
245
  const apiKey = _requireApiKey();
@@ -82,4 +82,33 @@ describe('startInterceptor', () => {
82
82
  const err = await new Promise((resolve) => { server.on('error', resolve); });
83
83
  assert.equal(err.code, 'EADDRINUSE');
84
84
  });
85
+
86
+ it('self-heals a machine still on the old ANTHROPIC_BASE_URL substitution on every start, ' +
87
+ 'not just an interactive `troxy init` the user may never re-run', async () => {
88
+ const configPath = path.join(dir, 'settings.json');
89
+ fs.writeFileSync(configPath, JSON.stringify({
90
+ env: { ANTHROPIC_BASE_URL: 'https://proxy.troxy.io', ANTHROPIC_AUTH_TOKEN: 'txy-abc', ENABLE_TOOL_SEARCH: 'true' },
91
+ }));
92
+
93
+ const server = startInterceptor({ troxyDir: dir, port: 0, configPath });
94
+ servers.push(server);
95
+ await new Promise((resolve, reject) => { server.on('listening', resolve); server.on('error', reject); });
96
+
97
+ const settings = JSON.parse(fs.readFileSync(configPath, 'utf8'));
98
+ assert.equal(settings.env.ANTHROPIC_BASE_URL, undefined);
99
+ assert.equal(settings.env.ANTHROPIC_AUTH_TOKEN, undefined);
100
+ assert.equal(settings.env.ENABLE_TOOL_SEARCH, undefined);
101
+ });
102
+
103
+ it('leaves a settings.json with no Troxy-owned base-URL keys untouched', async () => {
104
+ const configPath = path.join(dir, 'settings.json');
105
+ fs.writeFileSync(configPath, JSON.stringify({ env: { SOME_OTHER_VAR: '1' } }));
106
+
107
+ const server = startInterceptor({ troxyDir: dir, port: 0, configPath });
108
+ servers.push(server);
109
+ await new Promise((resolve, reject) => { server.on('listening', resolve); server.on('error', reject); });
110
+
111
+ const settings = JSON.parse(fs.readFileSync(configPath, 'utf8'));
112
+ assert.equal(settings.env.SOME_OTHER_VAR, '1');
113
+ });
85
114
  });
@@ -1,11 +1,7 @@
1
1
  // The single source of truth for which hosts the local interceptor ever
2
2
  // terminates TLS for, and where an intercepted request gets re-addressed
3
- // to. Only 'anthropic' is enabled in Phase 1 - openai_codex/cursor are
4
- // declared but disabled, so Phase 2/3 becomes a data change later, not a
5
- // rewrite (see the plan doc). The critical property this file's tests
6
- // guard: a disabled provider must NEVER contribute to the live intercept
7
- // allowlist - that's the guardrail against a Phase-2 stub silently
8
- // widening what gets MITM'd before it's actually ready.
3
+ // to. Only 'anthropic' is declared/enabled in Phase 1 (see the plan doc;
4
+ // Codex/Cursor entries were deleted per YAGNI until that phase ships).
9
5
 
10
6
  import { describe, it } from 'node:test';
11
7
  import assert from 'node:assert/strict';
@@ -18,13 +14,6 @@ describe('PROVIDERS', () => {
18
14
  assert.equal(anthropic.enabled, true);
19
15
  assert.deepEqual(anthropic.interceptHosts, ['api.anthropic.com']);
20
16
  });
21
-
22
- it('codex and cursor are declared but NOT enabled - Phase 1 is Anthropic only', () => {
23
- const codex = PROVIDERS.find(p => p.id === 'openai_codex');
24
- const cursor = PROVIDERS.find(p => p.id === 'cursor');
25
- assert.equal(codex.enabled, false);
26
- assert.equal(cursor.enabled, false);
27
- });
28
17
  });
29
18
 
30
19
  describe('enabledProviders', () => {
@@ -32,8 +21,6 @@ describe('enabledProviders', () => {
32
21
  const providers = enabledProviders();
33
22
  assert.ok(providers.every(p => p.enabled === true));
34
23
  assert.ok(providers.some(p => p.id === 'anthropic'));
35
- assert.ok(!providers.some(p => p.id === 'openai_codex'));
36
- assert.ok(!providers.some(p => p.id === 'cursor'));
37
24
  });
38
25
  });
39
26
 
@@ -42,12 +29,6 @@ describe('interceptHostsFor', () => {
42
29
  assert.deepEqual(interceptHostsFor(enabledProviders()), ['api.anthropic.com']);
43
30
  });
44
31
 
45
- it('a disabled provider passed in explicitly contributes nothing - ' +
46
- 'enabling it later must be a deliberate PROVIDERS edit, not automatic', () => {
47
- const cursor = PROVIDERS.find(p => p.id === 'cursor');
48
- assert.deepEqual(interceptHostsFor([cursor]), []);
49
- });
50
-
51
32
  it('empty input yields an empty allowlist, not an error', () => {
52
33
  assert.deepEqual(interceptHostsFor([]), []);
53
34
  });
@@ -116,3 +116,25 @@ describe('uninstall.js: settings.json symmetry (fail-open layer 6)', () => {
116
116
  assert.match(surrounding, /try\s*\{[\s\S]*unpatchClaudeCodeConfig[\s\S]*\}\s*catch/);
117
117
  });
118
118
  });
119
+
120
+ describe('proxy.js: runProxyDisable restart warning (same hazard as uninstall.js)', () => {
121
+ const proxySrc = readFileSync(join(__dirname, '..', 'proxy.js'), 'utf8');
122
+
123
+ it('checks interceptionIsConfigured BEFORE unpatching, not after', () => {
124
+ const checkIdx = proxySrc.indexOf('interceptionIsConfigured(settingsPath)');
125
+ const unpatchIdx = proxySrc.indexOf('unpatchClaudeCodeInterception(settingsPath)');
126
+ assert.ok(checkIdx !== -1 && unpatchIdx !== -1);
127
+ assert.ok(checkIdx < unpatchIdx, 'must read whether interception was configured before unpatching removes the evidence');
128
+ });
129
+
130
+ it('warns to restart Claude Code only when it was actually intercepting', () => {
131
+ const warnIdx = proxySrc.indexOf('now for this to take effect');
132
+ assert.ok(warnIdx !== -1, 'restart warning text not found');
133
+ const guardIdx = proxySrc.lastIndexOf('if (wasIntercepting)', warnIdx);
134
+ assert.ok(guardIdx !== -1 && guardIdx < warnIdx, 'restart warning must be gated behind an if (wasIntercepting) check');
135
+ });
136
+
137
+ it('reassures the user that disable leaves the CLI, daemon, cert and API key untouched', () => {
138
+ assert.match(proxySrc, /CLI, background daemon, certificate, and your API key are all still/);
139
+ });
140
+ });
@@ -92,9 +92,7 @@ export async function reportToolPlans(apiKey, detected) {
92
92
 
93
93
  // The full flow, shared by `troxy init` (a quiet inventory step) and
94
94
  // `troxy tools` (an explicit re-scan). No prompting - detection is a fact,
95
- // not a question - so `interactive` no longer changes behavior; kept as a
96
- // parameter so callers don't need to change, in case a future non-detection
97
- // step needs the distinction.
95
+ // not a question.
98
96
  export async function runToolDetection(apiKey) {
99
97
  const detected = detectAiTools();
100
98
  if (detected.every(d => !d.installed)) return;