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.
@@ -0,0 +1,203 @@
1
+ // patchClaudeCodeInterception / unpatchClaudeCodeInterception /
2
+ // interceptionIsConfigured - Layer 3 of the Live Model Policy Enforcement
3
+ // plan. Writes HTTPS_PROXY + NODE_EXTRA_CA_CERTS into Claude Code's global
4
+ // settings.json so BOTH terminal `claude` and the Desktop app's Code tab
5
+ // (which hard-overrides ANTHROPIC_BASE_URL, unlike these two) route through
6
+ // the local interceptor. Modeled directly on claude-code-proxy.test.js's
7
+ // coverage of patchClaudeCodeProxy - same idempotency/preservation/corrupt-
8
+ // JSON guarantees are required here too, since both patch the same file.
9
+
10
+ import { describe, it, beforeEach, afterEach } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ import {
17
+ patchClaudeCodeInterception, unpatchClaudeCodeInterception, interceptionIsConfigured,
18
+ troxyInterceptorProxyUrl, troxyLocalCaCertPath,
19
+ } from '../init.js';
20
+
21
+ let dir;
22
+ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-interception-cfg-')); });
23
+ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
24
+
25
+ describe('troxyInterceptorProxyUrl / troxyLocalCaCertPath', () => {
26
+ it('builds a loopback URL from the given port', () => {
27
+ assert.equal(troxyInterceptorProxyUrl(12345), 'http://127.0.0.1:12345');
28
+ });
29
+
30
+ it('defaults to the daemon\'s real INTERCEPTOR_PORT when no port is given', () => {
31
+ assert.match(troxyInterceptorProxyUrl(), /^http:\/\/127\.0\.0\.1:\d+$/);
32
+ });
33
+
34
+ it('builds the CA cert path under <troxyDir>/tls/', () => {
35
+ assert.equal(
36
+ troxyLocalCaCertPath('/tmp/fake-troxy-dir'),
37
+ path.join('/tmp/fake-troxy-dir', 'tls', 'troxy-local-ca.crt'),
38
+ );
39
+ });
40
+ });
41
+
42
+ describe('patchClaudeCodeInterception', () => {
43
+ const configPath = () => path.join(dir, 'settings.json');
44
+ const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
45
+ const OPTS = { proxyUrl: 'http://127.0.0.1:48173', caCertPath: '/home/x/.troxy/tls/troxy-local-ca.crt' };
46
+
47
+ it('creates the env block with HTTPS_PROXY + NODE_EXTRA_CA_CERTS when the file does not exist', () => {
48
+ patchClaudeCodeInterception(configPath(), OPTS);
49
+ const cfg = read();
50
+ assert.equal(cfg.env.HTTPS_PROXY, OPTS.proxyUrl);
51
+ assert.equal(cfg.env.NODE_EXTRA_CA_CERTS, OPTS.caCertPath);
52
+ });
53
+
54
+ it('uses real default values (troxyInterceptorProxyUrl/troxyLocalCaCertPath) when no options are given', () => {
55
+ patchClaudeCodeInterception(configPath());
56
+ const cfg = read();
57
+ assert.equal(cfg.env.HTTPS_PROXY, troxyInterceptorProxyUrl());
58
+ assert.equal(cfg.env.NODE_EXTRA_CA_CERTS, troxyLocalCaCertPath());
59
+ });
60
+
61
+ it('preserves unrelated existing env keys instead of replacing the whole block', () => {
62
+ fs.writeFileSync(configPath(), JSON.stringify({ env: { SOME_OTHER_VAR: 'keep-me' } }));
63
+ patchClaudeCodeInterception(configPath(), OPTS);
64
+ const cfg = read();
65
+ assert.equal(cfg.env.SOME_OTHER_VAR, 'keep-me');
66
+ assert.equal(cfg.env.HTTPS_PROXY, OPTS.proxyUrl);
67
+ });
68
+
69
+ it('preserves the base-URL proxy env keys (ANTHROPIC_BASE_URL etc.) set by patchClaudeCodeProxy - both can coexist', () => {
70
+ fs.writeFileSync(configPath(), JSON.stringify({
71
+ env: { ANTHROPIC_BASE_URL: 'https://proxy.troxy.io', ANTHROPIC_AUTH_TOKEN: 'txy-abc', ENABLE_TOOL_SEARCH: 'true' },
72
+ }));
73
+ patchClaudeCodeInterception(configPath(), OPTS);
74
+ const cfg = read();
75
+ assert.equal(cfg.env.ANTHROPIC_BASE_URL, 'https://proxy.troxy.io');
76
+ assert.equal(cfg.env.ANTHROPIC_AUTH_TOKEN, 'txy-abc');
77
+ assert.equal(cfg.env.HTTPS_PROXY, OPTS.proxyUrl);
78
+ });
79
+
80
+ it('preserves unrelated top-level keys (e.g. hooks already written by patchClaudeCodeHooks)', () => {
81
+ fs.writeFileSync(configPath(), JSON.stringify({ hooks: { Stop: [{ hooks: [{ command: 'x' }] }] } }));
82
+ patchClaudeCodeInterception(configPath(), OPTS);
83
+ const cfg = read();
84
+ assert.equal(cfg.hooks.Stop.length, 1, 'existing hooks block was dropped');
85
+ assert.equal(cfg.env.HTTPS_PROXY, OPTS.proxyUrl);
86
+ });
87
+
88
+ it('re-running is idempotent - overwrites rather than duplicating or erroring', () => {
89
+ patchClaudeCodeInterception(configPath(), OPTS);
90
+ patchClaudeCodeInterception(configPath(), OPTS);
91
+ const cfg = read();
92
+ assert.equal(cfg.env.HTTPS_PROXY, OPTS.proxyUrl);
93
+ assert.equal(cfg.env.NODE_EXTRA_CA_CERTS, OPTS.caCertPath);
94
+ });
95
+
96
+ it('does not throw when the existing file is corrupted JSON', () => {
97
+ fs.writeFileSync(configPath(), '{ not json');
98
+ assert.doesNotThrow(() => patchClaudeCodeInterception(configPath(), OPTS));
99
+ });
100
+
101
+ it('creates the parent directory when the settings file does not exist yet', () => {
102
+ const nested = path.join(dir, 'nested', 'dir', 'settings.json');
103
+ assert.doesNotThrow(() => patchClaudeCodeInterception(nested, OPTS));
104
+ assert.ok(fs.existsSync(nested));
105
+ });
106
+ });
107
+
108
+ describe('unpatchClaudeCodeInterception', () => {
109
+ const configPath = () => path.join(dir, 'settings.json');
110
+ const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
111
+ const OPTS = { proxyUrl: 'http://127.0.0.1:48173', caCertPath: '/home/x/.troxy/tls/troxy-local-ca.crt' };
112
+
113
+ it('removes exactly HTTPS_PROXY and NODE_EXTRA_CA_CERTS - the documented `troxy proxy disable` recovery', () => {
114
+ patchClaudeCodeInterception(configPath(), OPTS);
115
+ unpatchClaudeCodeInterception(configPath());
116
+ const cfg = read();
117
+ assert.equal(cfg.env.HTTPS_PROXY, undefined);
118
+ assert.equal(cfg.env.NODE_EXTRA_CA_CERTS, undefined);
119
+ });
120
+
121
+ it('leaves the base-URL proxy env keys untouched - the two mechanisms are independent', () => {
122
+ fs.writeFileSync(configPath(), JSON.stringify({
123
+ env: { ANTHROPIC_BASE_URL: 'https://proxy.troxy.io', ANTHROPIC_AUTH_TOKEN: 'txy-abc' },
124
+ }));
125
+ patchClaudeCodeInterception(configPath(), OPTS);
126
+ unpatchClaudeCodeInterception(configPath());
127
+ const cfg = read();
128
+ assert.equal(cfg.env.ANTHROPIC_BASE_URL, 'https://proxy.troxy.io');
129
+ assert.equal(cfg.env.ANTHROPIC_AUTH_TOKEN, 'txy-abc');
130
+ });
131
+
132
+ it('leaves unrelated env keys and top-level keys (e.g. hooks) untouched', () => {
133
+ fs.writeFileSync(configPath(), JSON.stringify({
134
+ env: { SOME_OTHER_VAR: 'keep-me' },
135
+ hooks: { Stop: [{ hooks: [{ command: 'x' }] }] },
136
+ }));
137
+ patchClaudeCodeInterception(configPath(), OPTS);
138
+ unpatchClaudeCodeInterception(configPath());
139
+ const cfg = read();
140
+ assert.equal(cfg.env.SOME_OTHER_VAR, 'keep-me');
141
+ assert.equal(cfg.hooks.Stop.length, 1);
142
+ });
143
+
144
+ it('does not throw and does not create a file when called against a non-existent settings file', () => {
145
+ assert.doesNotThrow(() => unpatchClaudeCodeInterception(configPath()));
146
+ assert.equal(fs.existsSync(configPath()), false);
147
+ });
148
+
149
+ it('does not throw when the existing file is corrupted JSON', () => {
150
+ fs.writeFileSync(configPath(), '{ not json');
151
+ assert.doesNotThrow(() => unpatchClaudeCodeInterception(configPath()));
152
+ });
153
+
154
+ it('is idempotent - calling twice is safe', () => {
155
+ patchClaudeCodeInterception(configPath(), OPTS);
156
+ unpatchClaudeCodeInterception(configPath());
157
+ assert.doesNotThrow(() => unpatchClaudeCodeInterception(configPath()));
158
+ });
159
+ });
160
+
161
+ describe('interceptionIsConfigured', () => {
162
+ const configPath = () => path.join(dir, 'settings.json');
163
+ const OPTS = { proxyUrl: 'http://127.0.0.1:48173', caCertPath: '/home/x/.troxy/tls/troxy-local-ca.crt' };
164
+
165
+ it('is false against a non-existent settings file', () => {
166
+ assert.equal(interceptionIsConfigured(configPath(), OPTS), false);
167
+ });
168
+
169
+ it('is false when the file exists but has no env block at all', () => {
170
+ fs.writeFileSync(configPath(), JSON.stringify({ hooks: {} }));
171
+ assert.equal(interceptionIsConfigured(configPath(), OPTS), false);
172
+ });
173
+
174
+ it('is true once patched with the exact same proxyUrl/caCertPath', () => {
175
+ patchClaudeCodeInterception(configPath(), OPTS);
176
+ assert.equal(interceptionIsConfigured(configPath(), OPTS), true);
177
+ });
178
+
179
+ it('is false when the configured port has moved - only an exact match counts', () => {
180
+ patchClaudeCodeInterception(configPath(), OPTS);
181
+ assert.equal(
182
+ interceptionIsConfigured(configPath(), { ...OPTS, proxyUrl: 'http://127.0.0.1:9999' }),
183
+ false,
184
+ );
185
+ });
186
+
187
+ it('is false when only one of the two keys is set (partial/corrupted state)', () => {
188
+ fs.writeFileSync(configPath(), JSON.stringify({ env: { HTTPS_PROXY: OPTS.proxyUrl } }));
189
+ assert.equal(interceptionIsConfigured(configPath(), OPTS), false);
190
+ });
191
+
192
+ it('is false again after unpatch - this is the check `troxy proxy enable` uses to avoid a no-op re-prompt, and the inverse also holds', () => {
193
+ patchClaudeCodeInterception(configPath(), OPTS);
194
+ unpatchClaudeCodeInterception(configPath());
195
+ assert.equal(interceptionIsConfigured(configPath(), OPTS), false);
196
+ });
197
+
198
+ it('does not throw when the existing file is corrupted JSON', () => {
199
+ fs.writeFileSync(configPath(), '{ not json');
200
+ assert.doesNotThrow(() => interceptionIsConfigured(configPath(), OPTS));
201
+ assert.equal(interceptionIsConfigured(configPath(), OPTS), false);
202
+ });
203
+ });