troxy-cli 1.29.14 → 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 +4 -7
- package/package.json +1 -1
- package/src/api.js +12 -0
- package/src/daemon.js +22 -2
- package/src/providers.js +5 -31
- package/src/simulate.js +17 -15
- package/src/tests/daemon.test.js +29 -0
- package/src/tests/providers.test.js +2 -21
- package/src/tool_detect.js +1 -3
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 }
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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({
|
|
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.
|
|
10
|
-
* declared here, disabled,
|
|
11
|
-
*
|
|
12
|
-
* provider's real divergence (Codex
|
|
13
|
-
*
|
|
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/simulate.js
CHANGED
|
@@ -219,25 +219,27 @@ export function _scanBody(flags) {
|
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
+
async function _runSimulator(kind, flags) {
|
|
234
|
+
const [evaluate, buildBody] = SIMULATORS[kind];
|
|
233
235
|
const apiKey = _requireApiKey();
|
|
234
|
-
_report(await
|
|
236
|
+
_report(await evaluate(buildBody(flags), apiKey));
|
|
235
237
|
}
|
|
236
238
|
|
|
237
|
-
export
|
|
238
|
-
|
|
239
|
-
|
|
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();
|
package/src/tests/daemon.test.js
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
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
|
});
|
package/src/tool_detect.js
CHANGED
|
@@ -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
|
|
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;
|