troxy-cli 1.29.3 → 1.29.5

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/src/proxy.js ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `troxy proxy status/enable/disable` - the explicit, hands-on control
3
+ * surface for Layer 3 of the Live Model Policy Enforcement plan (local
4
+ * interceptor set up in interceptor.js/tls-ca.js/daemon.js). Deliberately
5
+ * NOT wired into `troxy init`'s own interactive flow yet: per the plan's
6
+ * rollout order, this ships first as an explicit, undocumented opt-in
7
+ * (`--experimental` on enable) so it can be validated by hand against a
8
+ * real desktop app before it becomes something an ordinary `troxy init`
9
+ * run could ever turn on for every user.
10
+ */
11
+ import net from 'node:net';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import fs from 'node:fs';
15
+ import {
16
+ hasClaudeCode, hasClaudeDesktop, claudeCodeSettingsPath,
17
+ patchClaudeCodeInterception, unpatchClaudeCodeInterception, interceptionIsConfigured,
18
+ troxyInterceptorProxyUrl, troxyLocalCaCertPath,
19
+ } from './init.js';
20
+ import { ensureInterceptionCerts, certIsValid, certExpiresWithin } from './tls-ca.js';
21
+ import { enabledProviders, interceptHostsFor } from './providers.js';
22
+ import { INTERCEPTOR_PORT } from './daemon.js';
23
+
24
+ const TROXY_DIR = path.join(os.homedir(), '.troxy');
25
+
26
+ // Quick, bounded TCP probe - "is anything listening on 127.0.0.1:<port>",
27
+ // not a real request. Exists to tell the fail-open layer-6 mismatch
28
+ // (settings.json points HTTPS_PROXY at the interceptor, but nothing is
29
+ // actually there - exactly the outage this whole plan's fail-open design
30
+ // was written around) apart from a healthy setup, so status/enable can say
31
+ // so plainly instead of a user discovering it as a mysterious hang.
32
+ export function probePort(port, timeoutMs = 800) {
33
+ return new Promise(resolve => {
34
+ const socket = net.connect({ host: '127.0.0.1', port, timeout: timeoutMs });
35
+ socket.once('connect', () => { socket.destroy(); resolve(true); });
36
+ socket.once('timeout', () => { socket.destroy(); resolve(false); });
37
+ socket.once('error', () => resolve(false));
38
+ });
39
+ }
40
+
41
+ function _readCert(certPath) {
42
+ try { return fs.readFileSync(certPath, 'utf8'); } catch { return null; }
43
+ }
44
+
45
+ export async function runProxyStatus() {
46
+ console.log('\n Troxy: Live Model Policy Enforcement (interception)\n');
47
+
48
+ const hasClaude = hasClaudeCode();
49
+ const hasDesktop = hasClaudeDesktop();
50
+ console.log(` Terminal Claude Code: ${hasClaude ? 'detected' : 'not detected'}`);
51
+ console.log(` Claude Desktop app: ${hasDesktop ? 'detected' : 'not detected'}`);
52
+
53
+ const settingsPath = claudeCodeSettingsPath();
54
+ const proxyUrl = troxyInterceptorProxyUrl();
55
+ const caCertPath = troxyLocalCaCertPath();
56
+ const configured = interceptionIsConfigured(settingsPath, { proxyUrl, caCertPath });
57
+ console.log(`\n Configured in settings.json: ${configured ? 'yes' : 'no'}`);
58
+ if (!configured) {
59
+ console.log(' Run `troxy proxy enable --experimental` to turn this on.\n');
60
+ return;
61
+ }
62
+
63
+ const bound = await probePort(INTERCEPTOR_PORT);
64
+ if (!bound) {
65
+ console.log(` ⚠ settings.json points at 127.0.0.1:${INTERCEPTOR_PORT}, but nothing is`);
66
+ console.log(' listening there right now - Claude Code and the desktop app will');
67
+ console.log(' fail to connect while this is the case.');
68
+ console.log(' Fix: `troxy restart` (restarts the background daemon), or');
69
+ console.log(' `troxy proxy disable` to remove the setting until this is resolved.\n');
70
+ return;
71
+ }
72
+ console.log(` Interceptor listening: yes (127.0.0.1:${INTERCEPTOR_PORT})`);
73
+
74
+ const caPem = _readCert(caCertPath);
75
+ if (!caPem) {
76
+ console.log(` CA certificate: ✗ not found at ${caCertPath}\n`);
77
+ return;
78
+ }
79
+ console.log(` CA certificate: ${certIsValid(caPem) ? 'valid' : '✗ invalid/expired'}`);
80
+
81
+ const leafPath = path.join(TROXY_DIR, 'tls', 'leaf-api.anthropic.com.crt');
82
+ const leafPem = _readCert(leafPath);
83
+ if (leafPem) {
84
+ const leafOk = certIsValid(leafPem);
85
+ const renewingSoon = leafOk && certExpiresWithin(leafPem, 30);
86
+ console.log(` Leaf certificate: ${leafOk ? 'valid' : '✗ invalid/expired'}${renewingSoon ? ' (renews within 30 days)' : ''}`);
87
+ }
88
+ console.log(` CA cert path: ${caCertPath}`);
89
+ console.log(` Providers intercepted: ${interceptHostsFor(enabledProviders()).join(', ') || '(none)'}\n`);
90
+ }
91
+
92
+ export async function runProxyEnable(flags = {}) {
93
+ if (flags.help || flags.h) {
94
+ console.log(`
95
+ troxy proxy enable --experimental
96
+
97
+ Routes the Claude Desktop app's Code tab (and terminal Claude Code)
98
+ through Troxy's local interceptor, so model policies (e.g. a BLOCK rule
99
+ on a specific model) are enforced in real time even inside the desktop
100
+ app - not just terminal \`claude\`.
101
+
102
+ How: writes HTTPS_PROXY + NODE_EXTRA_CA_CERTS into
103
+ ~/.claude/settings.json, pointing at a certificate authority generated
104
+ ON THIS MACHINE (~/.troxy/tls/). The private key never leaves this
105
+ machine and is never sent to Troxy. It is locked to api.anthropic.com
106
+ only, via a nameConstraints extension - it cannot be used to intercept
107
+ any other site - and it is never installed in your system keychain, only
108
+ referenced by Claude Code itself.
109
+
110
+ \`troxy init\` already offers this for terminal Claude Code automatically.
111
+ This command is for a desktop-only machine (no terminal \`claude\`), or to
112
+ reconfigure by hand. --experimental is required as an explicit
113
+ acknowledgment.
114
+
115
+ Remove any time with: troxy proxy disable
116
+ `);
117
+ process.exit(0);
118
+ }
119
+ if (!flags.experimental) {
120
+ console.error('\n This is experimental. Run: troxy proxy enable --experimental');
121
+ console.error(' Run `troxy proxy enable --help` first to see what this does.\n');
122
+ process.exit(1);
123
+ }
124
+
125
+ console.log("\n Route Claude's model calls through Troxy so policies are enforced in");
126
+ console.log(" real time, including the Claude desktop app's Code tab, which ignores");
127
+ console.log(' the normal ANTHROPIC_BASE_URL setting.\n');
128
+ console.log(' To do that on the desktop app, Troxy generates a certificate authority');
129
+ console.log(' ON THIS MACHINE. The private key never leaves this computer and is');
130
+ console.log(' never sent to Troxy. It is locked to api.anthropic.com only - it cannot');
131
+ console.log(' be used for any other site - and it is NOT installed in your system');
132
+ console.log(' keychain, only referenced by Claude Code. Everything except');
133
+ console.log(' api.anthropic.com passes through untouched and unreadable.\n');
134
+
135
+ process.stdout.write(' Generating certificate authority... ');
136
+ try {
137
+ ensureInterceptionCerts(TROXY_DIR, {
138
+ hostname: os.hostname(),
139
+ leafDnsNames: interceptHostsFor(enabledProviders()),
140
+ });
141
+ console.log('✓');
142
+ } catch (err) {
143
+ console.log('✗');
144
+ console.error(`\n Could not generate certificates: ${err.message}\n`);
145
+ process.exit(1);
146
+ }
147
+
148
+ const settingsPath = claudeCodeSettingsPath();
149
+ process.stdout.write(' Writing ~/.claude/settings.json... ');
150
+ try {
151
+ patchClaudeCodeInterception(settingsPath);
152
+ console.log('✓');
153
+ } catch (err) {
154
+ console.log('✗');
155
+ console.error(`\n Could not write settings.json: ${err.message}\n`);
156
+ process.exit(1);
157
+ }
158
+
159
+ const bound = await probePort(INTERCEPTOR_PORT);
160
+ if (!bound) {
161
+ console.log(`\n ⚠ Nothing is listening on 127.0.0.1:${INTERCEPTOR_PORT} yet - the running`);
162
+ console.log(' background daemon does not have this feature (it predates this');
163
+ console.log(' build). Restart it so the interceptor actually binds:');
164
+ console.log(' troxy restart');
165
+ console.log(' Until then, Claude Code will fail to connect while this is enabled.\n');
166
+ } else {
167
+ console.log(`\n Interceptor is live on 127.0.0.1:${INTERCEPTOR_PORT}. Restart Claude Code`);
168
+ console.log(' (and the desktop app, if open) to pick up the new settings.\n');
169
+ }
170
+ console.log(' Remove any time with: troxy proxy disable\n');
171
+ }
172
+
173
+ export function runProxyDisable() {
174
+ const settingsPath = claudeCodeSettingsPath();
175
+ process.stdout.write('\n Removing HTTPS_PROXY / NODE_EXTRA_CA_CERTS from settings.json... ');
176
+ try {
177
+ unpatchClaudeCodeInterception(settingsPath);
178
+ console.log('✓\n');
179
+ } catch (err) {
180
+ console.log('✗');
181
+ console.error(`\n ${err.message}\n`);
182
+ process.exit(1);
183
+ }
184
+ }
@@ -17,7 +17,7 @@ import fs from 'node:fs';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
19
 
20
- import { patchClaudeCodeConfig, patchClaudeCodeHooks } from '../init.js';
20
+ import { patchClaudeCodeConfig, patchClaudeCodeHooks, unpatchClaudeCodeConfig, unpatchClaudeCodeHooks } from '../init.js';
21
21
 
22
22
  let dir;
23
23
  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-cc-')); });
@@ -71,6 +71,52 @@ describe('patchClaudeCodeConfig', () => {
71
71
  });
72
72
  });
73
73
 
74
+ // Uninstall symmetry: removes the troxy entry from EVERY project in the
75
+ // file, not just process.cwd() - `troxy init` may have run from several
76
+ // project directories over time, each getting its own entry, and uninstall
77
+ // has no reliable way to know which cwd(s) were ever used.
78
+ describe('unpatchClaudeCodeConfig', () => {
79
+ const configPath = () => path.join(dir, '.claude.json');
80
+ const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
81
+
82
+ it('removes the troxy entry from a single project', () => {
83
+ patchClaudeCodeConfig(configPath(), 'txy-abc', '/Users/x/proj');
84
+ unpatchClaudeCodeConfig(configPath());
85
+ const cfg = read();
86
+ assert.equal(cfg.projects['/Users/x/proj'].mcpServers.troxy, undefined);
87
+ });
88
+
89
+ it('removes it from every project that has one, in a single pass', () => {
90
+ patchClaudeCodeConfig(configPath(), 'txy-abc', '/Users/x/proj-a');
91
+ patchClaudeCodeConfig(configPath(), 'txy-abc', '/Users/x/proj-b');
92
+ unpatchClaudeCodeConfig(configPath());
93
+ const cfg = read();
94
+ assert.equal(cfg.projects['/Users/x/proj-a'].mcpServers.troxy, undefined);
95
+ assert.equal(cfg.projects['/Users/x/proj-b'].mcpServers.troxy, undefined);
96
+ });
97
+
98
+ it('leaves other tools\' mcpServers entries and unrelated project keys untouched', () => {
99
+ fs.writeFileSync(configPath(), JSON.stringify({
100
+ projects: { '/Users/x/proj': { allowedTools: ['Bash'], mcpServers: { someOtherTool: { command: 'x' } } } },
101
+ }));
102
+ patchClaudeCodeConfig(configPath(), 'txy-abc', '/Users/x/proj');
103
+ unpatchClaudeCodeConfig(configPath());
104
+ const cfg = read();
105
+ assert.deepEqual(cfg.projects['/Users/x/proj'].allowedTools, ['Bash']);
106
+ assert.ok(cfg.projects['/Users/x/proj'].mcpServers.someOtherTool);
107
+ });
108
+
109
+ it('does not throw and does not create a file when called against a non-existent config file', () => {
110
+ assert.doesNotThrow(() => unpatchClaudeCodeConfig(configPath()));
111
+ assert.equal(fs.existsSync(configPath()), false);
112
+ });
113
+
114
+ it('does not throw when the existing file is corrupted JSON', () => {
115
+ fs.writeFileSync(configPath(), '{ not json');
116
+ assert.doesNotThrow(() => unpatchClaudeCodeConfig(configPath()));
117
+ });
118
+ });
119
+
74
120
  describe('patchClaudeCodeHooks', () => {
75
121
  const configPath = () => path.join(dir, 'settings.json');
76
122
  const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
@@ -178,3 +224,52 @@ describe('patchClaudeCodeHooks: PreToolUse gate', () => {
178
224
  assert.equal(cfg.hooks.Stop[0].hooks[0].command, 'troxy hook-report');
179
225
  });
180
226
  });
227
+
228
+ // Uninstall symmetry: removes exactly the Troxy-owned Stop/PreToolUse
229
+ // entries (identified the same way patchClaudeCodeHooks itself finds them
230
+ // to update in place - by command containing 'hook-report'/'pretooluse-hook'),
231
+ // leaving any of the user's own unrelated hooks in the same file untouched.
232
+ describe('unpatchClaudeCodeHooks', () => {
233
+ const configPath = () => path.join(dir, 'settings.json');
234
+ const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
235
+
236
+ it('removes the Troxy Stop and PreToolUse entries', () => {
237
+ patchClaudeCodeHooks(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
238
+ unpatchClaudeCodeHooks(configPath());
239
+ const cfg = read();
240
+ assert.equal(cfg.hooks.Stop.length, 0);
241
+ assert.equal(cfg.hooks.PreToolUse.length, 0);
242
+ });
243
+
244
+ it("preserves the user's own unrelated Stop and PreToolUse hooks in the same file", () => {
245
+ fs.writeFileSync(configPath(), JSON.stringify({
246
+ hooks: {
247
+ Stop: [{ hooks: [{ type: 'command', command: 'my-own-script.sh' }] }],
248
+ PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'validate.sh' }] }],
249
+ },
250
+ }));
251
+ patchClaudeCodeHooks(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
252
+ unpatchClaudeCodeHooks(configPath());
253
+ const cfg = read();
254
+ assert.equal(cfg.hooks.Stop.length, 1);
255
+ assert.equal(cfg.hooks.Stop[0].hooks[0].command, 'my-own-script.sh');
256
+ assert.equal(cfg.hooks.PreToolUse.length, 1);
257
+ assert.equal(cfg.hooks.PreToolUse[0].hooks[0].command, 'validate.sh');
258
+ });
259
+
260
+ it('does not throw and does not create a file when called against a non-existent settings file', () => {
261
+ assert.doesNotThrow(() => unpatchClaudeCodeHooks(configPath()));
262
+ assert.equal(fs.existsSync(configPath()), false);
263
+ });
264
+
265
+ it('does not throw when the existing file is corrupted JSON', () => {
266
+ fs.writeFileSync(configPath(), '{ not json');
267
+ assert.doesNotThrow(() => unpatchClaudeCodeHooks(configPath()));
268
+ });
269
+
270
+ it('is idempotent - calling twice is safe', () => {
271
+ patchClaudeCodeHooks(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
272
+ unpatchClaudeCodeHooks(configPath());
273
+ assert.doesNotThrow(() => unpatchClaudeCodeHooks(configPath()));
274
+ });
275
+ });
@@ -14,7 +14,7 @@ import fs from 'node:fs';
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
16
16
 
17
- import { patchClaudeCodeProxy, troxyModelProxyBaseUrl } from '../init.js';
17
+ import { patchClaudeCodeProxy, unpatchClaudeCodeProxy, troxyModelProxyBaseUrl, migrateBaseUrlProxyToInterception } from '../init.js';
18
18
 
19
19
  let dir;
20
20
  beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-cc-proxy-')); });
@@ -88,3 +88,122 @@ describe('patchClaudeCodeProxy', () => {
88
88
  assert.doesNotThrow(() => patchClaudeCodeProxy(configPath(), KEY));
89
89
  });
90
90
  });
91
+
92
+ // Uninstall symmetry (fail-open layer 6 of the Live Model Policy Enforcement
93
+ // plan): before this existed, `troxy uninstall` deleted ~/.troxy but never
94
+ // touched these three keys, so a machine that had them set kept routing
95
+ // through a proxy that no longer existed.
96
+ describe('unpatchClaudeCodeProxy', () => {
97
+ const configPath = () => path.join(dir, 'settings.json');
98
+ const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
99
+ const KEY = 'txy-test-key-123';
100
+
101
+ it('removes exactly the three keys patchClaudeCodeProxy writes', () => {
102
+ patchClaudeCodeProxy(configPath(), KEY);
103
+ unpatchClaudeCodeProxy(configPath());
104
+ const cfg = read();
105
+ assert.equal(cfg.env.ANTHROPIC_BASE_URL, undefined);
106
+ assert.equal(cfg.env.ANTHROPIC_AUTH_TOKEN, undefined);
107
+ assert.equal(cfg.env.ENABLE_TOOL_SEARCH, undefined);
108
+ });
109
+
110
+ it('leaves unrelated env keys (e.g. the interception keys) and top-level keys untouched', () => {
111
+ fs.writeFileSync(configPath(), JSON.stringify({
112
+ env: { HTTPS_PROXY: 'http://127.0.0.1:48173', SOME_OTHER_VAR: 'keep-me' },
113
+ hooks: { Stop: [{ hooks: [{ command: 'x' }] }] },
114
+ }));
115
+ patchClaudeCodeProxy(configPath(), KEY);
116
+ unpatchClaudeCodeProxy(configPath());
117
+ const cfg = read();
118
+ assert.equal(cfg.env.HTTPS_PROXY, 'http://127.0.0.1:48173');
119
+ assert.equal(cfg.env.SOME_OTHER_VAR, 'keep-me');
120
+ assert.equal(cfg.hooks.Stop.length, 1);
121
+ });
122
+
123
+ it('does not throw and does not create a file when called against a non-existent settings file', () => {
124
+ assert.doesNotThrow(() => unpatchClaudeCodeProxy(configPath()));
125
+ assert.equal(fs.existsSync(configPath()), false);
126
+ });
127
+
128
+ it('does not throw when the existing file is corrupted JSON', () => {
129
+ fs.writeFileSync(configPath(), '{ not json');
130
+ assert.doesNotThrow(() => unpatchClaudeCodeProxy(configPath()));
131
+ });
132
+
133
+ it('is idempotent - calling twice is safe', () => {
134
+ patchClaudeCodeProxy(configPath(), KEY);
135
+ unpatchClaudeCodeProxy(configPath());
136
+ assert.doesNotThrow(() => unpatchClaudeCodeProxy(configPath()));
137
+ });
138
+ });
139
+
140
+ // New for the terminal-interception-passthrough plan (2026-09-08): a
141
+ // machine that already ran `troxy init --proxy` has the old base-URL
142
+ // substitution keys set, which is the live billing bug (terminal calls
143
+ // silently charged to the org's own API key instead of the user's
144
+ // subscription). This is the auto-migration step that removes them the
145
+ // next time init/rotate-key/update runs, regardless of whether the user
146
+ // then opts into interception.
147
+ describe('migrateBaseUrlProxyToInterception', () => {
148
+ const configPath = () => path.join(dir, 'settings.json');
149
+ const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
150
+ const KEY = 'txy-test-key-123';
151
+
152
+ it('returns false and does nothing when the file does not exist', () => {
153
+ assert.equal(migrateBaseUrlProxyToInterception(configPath()), false);
154
+ assert.equal(fs.existsSync(configPath()), false);
155
+ });
156
+
157
+ it('returns false and does nothing when neither old key is set', () => {
158
+ fs.writeFileSync(configPath(), JSON.stringify({ env: { SOME_OTHER_VAR: 'keep-me' } }));
159
+ assert.equal(migrateBaseUrlProxyToInterception(configPath()), false);
160
+ assert.equal(read().env.SOME_OTHER_VAR, 'keep-me');
161
+ });
162
+
163
+ it('returns true and removes all three base-URL-proxy keys when ANTHROPIC_BASE_URL is set', () => {
164
+ patchClaudeCodeProxy(configPath(), KEY);
165
+ assert.equal(migrateBaseUrlProxyToInterception(configPath()), true);
166
+ const cfg = read();
167
+ assert.equal(cfg.env.ANTHROPIC_BASE_URL, undefined);
168
+ assert.equal(cfg.env.ANTHROPIC_AUTH_TOKEN, undefined);
169
+ assert.equal(cfg.env.ENABLE_TOOL_SEARCH, undefined);
170
+ });
171
+
172
+ it('returns true when only ANTHROPIC_AUTH_TOKEN is set (partial/manually-edited state)', () => {
173
+ fs.writeFileSync(configPath(), JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'txy-abc' } }));
174
+ assert.equal(migrateBaseUrlProxyToInterception(configPath()), true);
175
+ assert.equal(read().env.ANTHROPIC_AUTH_TOKEN, undefined);
176
+ });
177
+
178
+ it('does not touch a non-Troxy ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN (e.g. a user\'s own LiteLLM/Bedrock gateway)', () => {
179
+ fs.writeFileSync(configPath(), JSON.stringify({
180
+ env: { ANTHROPIC_BASE_URL: 'https://litellm.internal/v1', ANTHROPIC_AUTH_TOKEN: 'sk-my-own-key' },
181
+ }));
182
+ assert.equal(migrateBaseUrlProxyToInterception(configPath()), false);
183
+ const cfg = read();
184
+ assert.equal(cfg.env.ANTHROPIC_BASE_URL, 'https://litellm.internal/v1');
185
+ assert.equal(cfg.env.ANTHROPIC_AUTH_TOKEN, 'sk-my-own-key');
186
+ });
187
+
188
+ it('preserves unrelated env keys (e.g. interception keys already set) and top-level keys', () => {
189
+ fs.writeFileSync(configPath(), JSON.stringify({
190
+ env: { ANTHROPIC_BASE_URL: 'https://proxy.troxy.io', HTTPS_PROXY: 'http://127.0.0.1:48173' },
191
+ hooks: { Stop: [{ hooks: [{ command: 'x' }] }] },
192
+ }));
193
+ migrateBaseUrlProxyToInterception(configPath());
194
+ const cfg = read();
195
+ assert.equal(cfg.env.HTTPS_PROXY, 'http://127.0.0.1:48173');
196
+ assert.equal(cfg.hooks.Stop.length, 1);
197
+ });
198
+
199
+ it('does not throw when the existing file is corrupted JSON', () => {
200
+ fs.writeFileSync(configPath(), '{ not json');
201
+ assert.doesNotThrow(() => migrateBaseUrlProxyToInterception(configPath()));
202
+ });
203
+
204
+ it('is idempotent - calling twice is safe and returns false the second time', () => {
205
+ patchClaudeCodeProxy(configPath(), KEY);
206
+ assert.equal(migrateBaseUrlProxyToInterception(configPath()), true);
207
+ assert.equal(migrateBaseUrlProxyToInterception(configPath()), false);
208
+ });
209
+ });
@@ -0,0 +1,85 @@
1
+ // daemon.js's startInterceptor() is thin orchestration over already-tested
2
+ // pieces (createInterceptor, ensureInterceptionCerts, providers.js) - these
3
+ // tests cover the wiring itself (it actually binds, it actually generates
4
+ // certs on first run, a second call is idempotent), not the underlying
5
+ // TLS/CA/routing behavior, which interceptor.test.js and tls-ca.test.js
6
+ // already cover thoroughly.
7
+ //
8
+ // Port 0 (OS-assigned) is used everywhere except the EADDRINUSE test,
9
+ // deliberately - node:test runs `it()` blocks within a file concurrently
10
+ // by default, and a separate "probe a free port, close it, then bind that
11
+ // same number" pattern across multiple concurrent tests races: two tests'
12
+ // probes can receive the SAME OS-assigned ephemeral port in the gap before
13
+ // either has actually bound it (confirmed live - this reproduced on every
14
+ // run before switching to port 0). Binding directly with port 0 makes
15
+ // "find a free port" and "bind it" one atomic step, which is the only way
16
+ // to avoid that race entirely.
17
+
18
+ import { describe, it, beforeEach, afterEach } from 'node:test';
19
+ import assert from 'node:assert/strict';
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import net from 'node:net';
24
+
25
+ import { startInterceptor } from '../daemon.js';
26
+
27
+ let dir;
28
+ let servers = [];
29
+ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-daemon-')); servers = []; });
30
+ afterEach(() => {
31
+ for (const s of servers) { try { s.close(); } catch {} }
32
+ fs.rmSync(dir, { recursive: true, force: true });
33
+ });
34
+
35
+ describe('startInterceptor', () => {
36
+ it('starts and actually binds - Phase 1 always has at least the anthropic provider enabled', async () => {
37
+ const server = startInterceptor({ troxyDir: dir, port: 0 });
38
+ servers.push(server);
39
+ assert.ok(server, 'expected a server instance, got null');
40
+ await new Promise((resolve, reject) => {
41
+ server.on('listening', resolve);
42
+ server.on('error', reject);
43
+ });
44
+ assert.equal(server.listening, true);
45
+ });
46
+
47
+ it('generates the CA + leaf certificate files under the given troxyDir on first run', async () => {
48
+ const server = startInterceptor({ troxyDir: dir, port: 0 });
49
+ servers.push(server);
50
+ await new Promise((resolve, reject) => { server.on('listening', resolve); server.on('error', reject); });
51
+
52
+ assert.ok(fs.existsSync(path.join(dir, 'tls', 'troxy-local-ca.crt')));
53
+ assert.ok(fs.existsSync(path.join(dir, 'tls', 'leaf-api.anthropic.com.crt')));
54
+ });
55
+
56
+ it('a second call reuses the same CA rather than regenerating it', async () => {
57
+ const server1 = startInterceptor({ troxyDir: dir, port: 0 });
58
+ servers.push(server1);
59
+ await new Promise((resolve, reject) => { server1.on('listening', resolve); server1.on('error', reject); });
60
+ const firstCa = fs.readFileSync(path.join(dir, 'tls', 'troxy-local-ca.crt'), 'utf8');
61
+
62
+ const server2 = startInterceptor({ troxyDir: dir, port: 0 });
63
+ servers.push(server2);
64
+ await new Promise((resolve, reject) => { server2.on('listening', resolve); server2.on('error', reject); });
65
+ const secondCa = fs.readFileSync(path.join(dir, 'tls', 'troxy-local-ca.crt'), 'utf8');
66
+
67
+ assert.equal(secondCa, firstCa);
68
+ });
69
+
70
+ it('emits a clean EADDRINUSE error instead of crashing when the port is already taken', async () => {
71
+ // This one genuinely needs a specific, known port to collide on, so it
72
+ // can't use port 0 - occupies it directly with a plain server first,
73
+ // reading back the OS-assigned port from THAT bind (also atomic, no
74
+ // separate probe-then-close step).
75
+ const occupied = net.createServer();
76
+ servers.push(occupied);
77
+ await new Promise(r => occupied.listen(0, '127.0.0.1', r));
78
+ const port = occupied.address().port;
79
+
80
+ const server = startInterceptor({ troxyDir: dir, port });
81
+ servers.push(server);
82
+ const err = await new Promise((resolve) => { server.on('error', resolve); });
83
+ assert.equal(err.code, 'EADDRINUSE');
84
+ });
85
+ });
@@ -0,0 +1,95 @@
1
+ // hasClaudeDesktop() - Layer 3 of the Live Model Policy Enforcement plan.
2
+ // Same technique tool_detect.js already uses for Cursor/Windsurf/etc: check
3
+ // the app's own install directory, never a config file troxy itself might
4
+ // have created (~/.claude.json and ~/.claude/settings.json are both created
5
+ // lazily by troxy - their mere existence proves nothing about whether
6
+ // Desktop is actually installed).
7
+ //
8
+ // Unlike tool_detect.js's `home` (cached at module load, forcing a
9
+ // "set HOME before first import" trick), hasClaudeDesktop() reads
10
+ // os.homedir() fresh on every call - so these tests just set process.env.HOME
11
+ // per test, no import-order dance required.
12
+
13
+ import { describe, it, beforeEach, afterEach } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ import { hasClaudeDesktop } from '../init.js';
20
+
21
+ let dir;
22
+ let originalHome, originalLocalAppData, originalAppData;
23
+
24
+ beforeEach(() => {
25
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-desktop-detect-'));
26
+ originalHome = process.env.HOME;
27
+ originalLocalAppData = process.env.LOCALAPPDATA;
28
+ originalAppData = process.env.APPDATA;
29
+ process.env.HOME = dir;
30
+ delete process.env.LOCALAPPDATA;
31
+ delete process.env.APPDATA;
32
+ });
33
+
34
+ afterEach(() => {
35
+ process.env.HOME = originalHome;
36
+ if (originalLocalAppData === undefined) delete process.env.LOCALAPPDATA;
37
+ else process.env.LOCALAPPDATA = originalLocalAppData;
38
+ if (originalAppData === undefined) delete process.env.APPDATA;
39
+ else process.env.APPDATA = originalAppData;
40
+ fs.rmSync(dir, { recursive: true, force: true });
41
+ });
42
+
43
+ describe('hasClaudeDesktop', () => {
44
+ it('reports false against a clean home dir with no app installed', () => {
45
+ assert.equal(hasClaudeDesktop(), false);
46
+ });
47
+
48
+ it('does NOT treat a troxy-created ~/.claude.json or ~/.claude/settings.json as evidence Desktop is installed', () => {
49
+ fs.writeFileSync(path.join(dir, '.claude.json'), '{}');
50
+ fs.mkdirSync(path.join(dir, '.claude'), { recursive: true });
51
+ fs.writeFileSync(path.join(dir, '.claude', 'settings.json'), '{}');
52
+ assert.equal(
53
+ hasClaudeDesktop(),
54
+ false,
55
+ 'config files troxy itself creates must never look like Desktop being installed',
56
+ );
57
+ });
58
+
59
+ it("detects Desktop from ~/Library/Application Support/Claude (macOS)", () => {
60
+ fs.mkdirSync(path.join(dir, 'Library/Application Support/Claude'), { recursive: true });
61
+ assert.equal(hasClaudeDesktop(), true);
62
+ });
63
+
64
+ it("detects Desktop from ~/Applications/Claude.app (per-user Applications folder)", () => {
65
+ fs.mkdirSync(path.join(dir, 'Applications/Claude.app'), { recursive: true });
66
+ assert.equal(hasClaudeDesktop(), true);
67
+ });
68
+
69
+ it('detects Desktop from ~/.config/Claude (Linux)', () => {
70
+ fs.mkdirSync(path.join(dir, '.config/Claude'), { recursive: true });
71
+ assert.equal(hasClaudeDesktop(), true);
72
+ });
73
+
74
+ it('detects Desktop from %APPDATA%/Claude when APPDATA is set (Windows)', () => {
75
+ const appData = path.join(dir, 'AppData/Roaming');
76
+ fs.mkdirSync(path.join(appData, 'Claude'), { recursive: true });
77
+ process.env.APPDATA = appData;
78
+ assert.equal(hasClaudeDesktop(), true);
79
+ });
80
+
81
+ it('detects Desktop from %LOCALAPPDATA%/AnthropicClaude when LOCALAPPDATA is set (Windows)', () => {
82
+ const localAppData = path.join(dir, 'AppData/Local');
83
+ fs.mkdirSync(path.join(localAppData, 'AnthropicClaude'), { recursive: true });
84
+ process.env.LOCALAPPDATA = localAppData;
85
+ assert.equal(hasClaudeDesktop(), true);
86
+ });
87
+
88
+ it('never throws when a candidate path is unreadable/permission-denied rather than merely absent', () => {
89
+ // fs.existsSync itself never throws for a normal missing path, but the
90
+ // implementation wraps every check in try/catch specifically so a
91
+ // stranger failure mode (e.g. a permission-denied parent dir) degrades
92
+ // to "not detected" rather than crashing troxy init.
93
+ assert.doesNotThrow(() => hasClaudeDesktop());
94
+ });
95
+ });