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.
- package/README.md +6 -0
- package/bin/troxy.js +13 -0
- package/package.json +3 -2
- package/src/auth.js +81 -33
- 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/auth.test.js +12 -1
- 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/src/tests/auth.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert';
|
|
3
3
|
|
|
4
|
-
import { isOpenableUrl } from '../auth.js';
|
|
4
|
+
import { isOpenableUrl, _codeInputStrategy } from '../auth.js';
|
|
5
5
|
|
|
6
6
|
test('isOpenableUrl accepts plain https and rejects everything else (H4)', () => {
|
|
7
7
|
assert.equal(isOpenableUrl('https://dash.troxy.io/cli?code=abc'), true);
|
|
@@ -25,3 +25,14 @@ test('isOpenableUrl accepts plain https and rejects everything else (H4)', () =>
|
|
|
25
25
|
assert.equal(isOpenableUrl(bad), false, `should reject: ${String(bad)}`);
|
|
26
26
|
}
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
test('_codeInputStrategy: a real TTY gets the masked raw-mode prompt', () => {
|
|
30
|
+
assert.equal(_codeInputStrategy(true), 'raw-tty');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('_codeInputStrategy: no TTY falls back to a plain line read, never raw mode ' +
|
|
34
|
+
'(regression: process.stdin.setRawMode is not a function without a real TTY - ' +
|
|
35
|
+
'used to crash `troxy login` outright for a script, CI, or an agent\'s own shell)', () => {
|
|
36
|
+
assert.equal(_codeInputStrategy(false), 'line-read');
|
|
37
|
+
assert.equal(_codeInputStrategy(undefined), 'line-read');
|
|
38
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Found live 2026-09-08: installService() resolved `which troxy`, silently
|
|
2
|
+
// fell back to a guessed path (`/usr/local/bin/troxy`) when that failed, and
|
|
3
|
+
// never checked either result actually existed before writing it into the
|
|
4
|
+
// launchd plist / systemd unit and loading it - so a bad path always
|
|
5
|
+
// "succeeded". verifyTroxyBinExists is the extracted, directly-testable
|
|
6
|
+
// fix; installService (execSync/fs/launchctl-heavy, same as every other
|
|
7
|
+
// platform-integration function in this file) stays covered by source
|
|
8
|
+
// inspection, same convention as proxy-wiring.test.js.
|
|
9
|
+
|
|
10
|
+
import { describe, it } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { verifyTroxyBinExists } from '../init.js';
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const initSrc = readFileSync(join(__dirname, '..', 'init.js'), 'utf8');
|
|
20
|
+
|
|
21
|
+
describe('verifyTroxyBinExists', () => {
|
|
22
|
+
it('does not throw when the given path exists', () => {
|
|
23
|
+
// process.execPath is Node's own binary - guaranteed to exist on any
|
|
24
|
+
// machine running this test, without depending on `troxy` itself being
|
|
25
|
+
// installed anywhere.
|
|
26
|
+
assert.doesNotThrow(() => verifyTroxyBinExists(process.execPath));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('throws a clear, actionable error when the given path does not exist', () => {
|
|
30
|
+
assert.throws(
|
|
31
|
+
() => verifyTroxyBinExists('/definitely/not/a/real/path/troxy'),
|
|
32
|
+
/resolved troxy binary not found at \/definitely\/not\/a\/real\/path\/troxy.*npm link/s,
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('installService: resolved-path verification (source inspection)', () => {
|
|
38
|
+
it('calls verifyTroxyBinExists(troxy) before branching on platform', () => {
|
|
39
|
+
const fnStart = initSrc.indexOf('function installService(apiKey, agentName)');
|
|
40
|
+
assert.ok(fnStart !== -1, 'installService not found');
|
|
41
|
+
const verifyIdx = initSrc.indexOf('verifyTroxyBinExists(troxy)', fnStart);
|
|
42
|
+
const linuxIdx = initSrc.indexOf("platform === 'linux'", fnStart);
|
|
43
|
+
const darwinIdx = initSrc.indexOf("platform === 'darwin'", fnStart);
|
|
44
|
+
assert.ok(verifyIdx !== -1, 'verifyTroxyBinExists(troxy) call not found inside installService');
|
|
45
|
+
assert.ok(verifyIdx < linuxIdx && verifyIdx < darwinIdx,
|
|
46
|
+
'the resolved path must be verified before either platform branch writes/loads a service pointing at it');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('installService: launchd log capture (source inspection)', () => {
|
|
51
|
+
it('darwin plist sets StandardOutPath and StandardErrorPath under ~/.troxy', () => {
|
|
52
|
+
const darwinIdx = initSrc.indexOf("platform === 'darwin'");
|
|
53
|
+
assert.ok(darwinIdx !== -1, "darwin branch not found");
|
|
54
|
+
const darwinBlock = initSrc.slice(darwinIdx, initSrc.indexOf("} else {", darwinIdx));
|
|
55
|
+
assert.match(darwinBlock, /StandardOutPath/, 'plist must declare StandardOutPath');
|
|
56
|
+
assert.match(darwinBlock, /StandardErrorPath/, 'plist must declare StandardErrorPath');
|
|
57
|
+
assert.match(darwinBlock, /daemon-out\.log/);
|
|
58
|
+
assert.match(darwinBlock, /daemon-err\.log/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('creates the ~/.troxy log directory before the plist is loaded', () => {
|
|
62
|
+
const darwinIdx = initSrc.indexOf("platform === 'darwin'");
|
|
63
|
+
const troxyDirIdx = initSrc.indexOf('const troxyDir', darwinIdx);
|
|
64
|
+
const mkdirIdx = initSrc.indexOf('fs.mkdirSync(troxyDir', darwinIdx);
|
|
65
|
+
const loadIdx = initSrc.indexOf('launchctl load', darwinIdx);
|
|
66
|
+
assert.ok(troxyDirIdx !== -1 && mkdirIdx !== -1 && loadIdx !== -1);
|
|
67
|
+
assert.ok(mkdirIdx < loadIdx, 'the log directory must be created before launchctl load runs');
|
|
68
|
+
});
|
|
69
|
+
});
|