troxy-cli 1.29.25 → 1.29.26
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/package.json +1 -1
- package/src/init.js +34 -0
- package/src/mods/troxy-guard/.claude-plugin/plugin.json +5 -0
- package/src/mods/troxy-guard/hooks/hooks.json +1 -0
- package/src/mods/troxy-guard/hooks/register.js +158 -0
- package/src/mods/troxy-guard/tsconfig.json +3 -0
- package/src/mods/version-check.js +24 -0
- package/src/tests/init-mods.test.js +76 -0
- package/src/tests/mods-fallback.test.js +63 -0
- package/src/tests/mods-route.test.js +54 -0
- package/src/tests/mods-troxy-guard.test.js +64 -0
- package/src/tests/mods-version-check.test.js +44 -0
- package/src/uninstall.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.26",
|
|
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/init.js
CHANGED
|
@@ -11,6 +11,32 @@ import { enabledProviders, interceptHostsFor } from './providers.js';
|
|
|
11
11
|
import { probePort } from './proxy.js';
|
|
12
12
|
import { launchdUserDomain } from './launchd.js';
|
|
13
13
|
import { reclaimOwnership } from './sudo-ownership.js';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
import { detectInstalledClaudeCodeVersion, supportsMods } from './mods/version-check.js';
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const TROXY_GUARD_MOD_SOURCE_DIR = path.join(__dirname, 'mods', 'troxy-guard');
|
|
19
|
+
|
|
20
|
+
// mods-policy-engine plan (2026-10-06), Task 4. Mods need Claude Code >=2.1.287
|
|
21
|
+
// - installing unconditionally would silently do nothing (or worse, leave an
|
|
22
|
+
// inert directory) on anyone still on an older version (e.g. the Homebrew
|
|
23
|
+
// cask, which was still frozen at 2.1.285 as of 2026-10-06 even after
|
|
24
|
+
// `brew update` - see project_claude_code_mods_investigation memory). A
|
|
25
|
+
// plugin dropped into ~/.claude/skills/<name>/ loads automatically with zero
|
|
26
|
+
// flags and zero settings.json edits - verified directly against a real
|
|
27
|
+
// session, not assumed from docs.
|
|
28
|
+
export async function installTroxyGuardMod(home = os.homedir(), detectFn = detectInstalledClaudeCodeVersion) {
|
|
29
|
+
const version = await detectFn();
|
|
30
|
+
if (!version || !supportsMods(`${version.major}.${version.minor}.${version.patch}`)) return false;
|
|
31
|
+
const destDir = path.join(home, '.claude', 'skills', 'troxy-guard');
|
|
32
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
33
|
+
fs.cpSync(TROXY_GUARD_MOD_SOURCE_DIR, destDir, { recursive: true });
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function uninstallTroxyGuardMod(home = os.homedir()) {
|
|
38
|
+
fs.rmSync(path.join(home, '.claude', 'skills', 'troxy-guard'), { recursive: true, force: true });
|
|
39
|
+
}
|
|
14
40
|
|
|
15
41
|
// `npx troxy-cli@latest <cmd>` puts a `troxy` shim on PATH only for that one
|
|
16
42
|
// child process (npx's own temp-PATH trick) - it never installs a persistent
|
|
@@ -147,6 +173,14 @@ export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null,
|
|
|
147
173
|
} catch (err) {
|
|
148
174
|
console.log(` • Claude Code usage capture ✗ (${err.message})`);
|
|
149
175
|
}
|
|
176
|
+
try {
|
|
177
|
+
const installed = await installTroxyGuardMod();
|
|
178
|
+
console.log(installed
|
|
179
|
+
? ` • Troxy policy engine (terminal + Desktop) ✓`
|
|
180
|
+
: ` • Troxy policy engine - (needs Claude Code 2.1.287+, skipped)`);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.log(` • Troxy policy engine ✗ (${err.message})`);
|
|
183
|
+
}
|
|
150
184
|
}
|
|
151
185
|
if (hasClaude) {
|
|
152
186
|
// Terminal used to get base-URL substitution (maybeEnableClaudeCodeProxy)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.js"] }
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const POLICY_CHECK_URL = 'https://api.troxy.io/agent/policy-check';
|
|
2
|
+
const TIMEOUT_MS = 5000;
|
|
3
|
+
|
|
4
|
+
function toDomainAndFields(e) {
|
|
5
|
+
// Mapping a real Bash command to structured policy fields (which domain,
|
|
6
|
+
// which action_verb/resource) is deliberately out of scope for this first
|
|
7
|
+
// build - see the plan's "Explicitly out of scope" section. Returning null
|
|
8
|
+
// here is a safe no-op: the existing proxy remains the backstop for every
|
|
9
|
+
// real Bash action until a later plan adds real command classification.
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function guardBash($, e, next) {
|
|
14
|
+
const mapped = _internal.toDomainAndFields(e);
|
|
15
|
+
if (!mapped) return next(e);
|
|
16
|
+
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
19
|
+
let result;
|
|
20
|
+
try {
|
|
21
|
+
const res = await $.http.fetch(POLICY_CHECK_URL, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'content-type': 'application/json' },
|
|
24
|
+
body: JSON.stringify({ domain: mapped.domain, fields: mapped.fields }),
|
|
25
|
+
signal: controller.signal,
|
|
26
|
+
});
|
|
27
|
+
result = res.ok ? await res.json() : { decision: 'ESCALATE', reason: 'policy service returned an error' };
|
|
28
|
+
} finally {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (result.decision === 'BLOCK') {
|
|
33
|
+
return { deny: 'Blocked by your org\'s Troxy policy' + (result.policy_id ? ' (' + result.policy_id + ')' : '') };
|
|
34
|
+
}
|
|
35
|
+
if (result.decision === 'ESCALATE') {
|
|
36
|
+
const answer = await $.ui.ask('Troxy: ' + (result.reason || 'this action needs your approval') + '. Allow it?', { choices: ['Allow', 'Deny'] });
|
|
37
|
+
if (answer !== 'Allow') return { deny: 'Denied by you after a Troxy escalation.' };
|
|
38
|
+
}
|
|
39
|
+
return next(e);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function failClosed($, e, next, err) {
|
|
43
|
+
// Registered via .catch() below - this runs if guardBash throws, or if
|
|
44
|
+
// Claude Code decides the hook exceeded its time budget and would otherwise
|
|
45
|
+
// just skip it. A skipped policy hook must never silently mean "allow".
|
|
46
|
+
return { deny: 'Troxy could not evaluate this action (' + err.message + ') - denied by default rather than allowed.' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Routing: extend the usage bar by sending easy prompts to a cheaper model.
|
|
50
|
+
//
|
|
51
|
+
// Ported from the proxy's scorer (troxy-tf-live model-proxy/app.py: bands,
|
|
52
|
+
// never-upward rule, 50k context floor for Haiku). The mod scores each human
|
|
53
|
+
// prompt once with the user's own session via $.model.complete, so there is no
|
|
54
|
+
// Troxy round trip and no credential to carry. One decision per prompt, applied
|
|
55
|
+
// to every step of its turn, so the cache prefix stays stable within a turn.
|
|
56
|
+
|
|
57
|
+
const MODELS = {
|
|
58
|
+
haiku: 'claude-haiku-4-5-20251001',
|
|
59
|
+
sonnet: 'claude-sonnet-5-5',
|
|
60
|
+
opus: 'claude-opus-5-5',
|
|
61
|
+
};
|
|
62
|
+
const RANK = { haiku: 0, sonnet: 1, opus: 2 };
|
|
63
|
+
const BANDS = [[0.1, 'haiku'], [0.6, 'sonnet'], [0.8, 'opus']];
|
|
64
|
+
const HAIKU_CONTEXT_FLOOR_TOKENS = 50000;
|
|
65
|
+
const SCORE_TIMEOUT_MS = 3000;
|
|
66
|
+
const SCORER_PROMPT_CHARS = 4000;
|
|
67
|
+
const SCORER_SYSTEM = 'You are a routing classifier for a coding-assistant proxy. Read the request below '
|
|
68
|
+
+ 'and reply with ONLY a number between 0.0 and 1.0 estimating how much reasoning or '
|
|
69
|
+
+ 'capability it needs: 0.0 for trivial/routine work a small fast model handles fine, '
|
|
70
|
+
+ '1.0 for something that genuinely needs the most capable model available. '
|
|
71
|
+
+ 'Reply with the number and nothing else - no words, no punctuation.';
|
|
72
|
+
|
|
73
|
+
function tierOf(model) {
|
|
74
|
+
const m = String(model || '').toLowerCase();
|
|
75
|
+
for (const t of Object.keys(RANK)) if (m.includes(t)) return t;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseScore(text) {
|
|
80
|
+
const m = /\d*\.?\d+/.exec(String(text || ''));
|
|
81
|
+
if (!m) return null;
|
|
82
|
+
const n = Number(m[0]);
|
|
83
|
+
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pickModel(score, requested, contextTokens) {
|
|
87
|
+
const rq = tierOf(requested);
|
|
88
|
+
if (rq === null) return requested; // unknown family (e.g. a newer tier): leave it alone
|
|
89
|
+
let tier = rq;
|
|
90
|
+
for (const [limit, name] of BANDS) {
|
|
91
|
+
if (score <= limit) { if (RANK[name] < RANK[rq]) tier = name; break; }
|
|
92
|
+
}
|
|
93
|
+
if (tier === 'haiku' && rq !== 'haiku' && contextTokens > HAIKU_CONTEXT_FLOOR_TOKENS) {
|
|
94
|
+
tier = RANK.sonnet < RANK[rq] ? 'sonnet' : rq;
|
|
95
|
+
}
|
|
96
|
+
return tier === rq ? requested : MODELS[tier];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// /troxy off|on|status. Pure so it can be tested; the hook below stores the result.
|
|
100
|
+
function troxyCommand(args, paused) {
|
|
101
|
+
const a = String(args || '').trim().toLowerCase();
|
|
102
|
+
if (a === 'off') return { paused: true, text: 'Troxy routing is off. Your chosen model is used as-is. Type /troxy on to resume.' };
|
|
103
|
+
if (a === 'on') return { paused: false, text: 'Troxy routing is on.' };
|
|
104
|
+
return { paused, text: paused ? 'Troxy routing is off. Type /troxy on to resume.' : 'Troxy routing is on. Type /troxy off to pause it.' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Whether this session has shown its one "Troxy is active" notice yet.
|
|
108
|
+
let announced = false;
|
|
109
|
+
|
|
110
|
+
// The decision for the turn in flight: { score, tokens } or null (no change).
|
|
111
|
+
let decision = null;
|
|
112
|
+
|
|
113
|
+
async function scorePrompt($, e, next) {
|
|
114
|
+
decision = null;
|
|
115
|
+
if (await $.store.get('paused')) return next(e);
|
|
116
|
+
const kind = e.origin && e.origin.kind;
|
|
117
|
+
if (kind !== 'composer' && kind !== 'bridge' && kind !== 'sdk') return next(e);
|
|
118
|
+
const r = await $.model.complete(
|
|
119
|
+
{ model: 'haiku', system: SCORER_SYSTEM, prompt: e.text.slice(0, SCORER_PROMPT_CHARS), maxTokens: 8, effort: 'low', timeoutMs: SCORE_TIMEOUT_MS },
|
|
120
|
+
);
|
|
121
|
+
const score = r.isAnswered ? parseScore(r.text) : null;
|
|
122
|
+
if (score !== null) {
|
|
123
|
+
const u = await $.session.usage();
|
|
124
|
+
decision = { score, tokens: u.context ? u.context.tokens : 0 };
|
|
125
|
+
}
|
|
126
|
+
return next(e);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function* routeStep($, e, next) {
|
|
130
|
+
// Main loop only: a subagent keeps whatever model it was given.
|
|
131
|
+
if (!decision || e.agentId) return yield* next(e);
|
|
132
|
+
const model = pickModel(decision.score, e.model, decision.tokens);
|
|
133
|
+
if (model !== e.model && !announced) {
|
|
134
|
+
announced = true;
|
|
135
|
+
$.ui.toast('Troxy is active');
|
|
136
|
+
}
|
|
137
|
+
return yield* next(model === e.model ? e : { ...e, model });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function register(on) {
|
|
141
|
+
on('tool.call', { tool: 'Bash' }, guardBash).catch(failClosed);
|
|
142
|
+
on('session.start', async ($, e, next) => {
|
|
143
|
+
await $.command.register({ name: 'troxy', description: 'Pause or resume Troxy model routing', argumentHint: '[on|off|status]' });
|
|
144
|
+
return next(e);
|
|
145
|
+
});
|
|
146
|
+
on('command.run', { command: 'troxy' }, async ($, e) => {
|
|
147
|
+
const r = troxyCommand(e.args, Boolean(await $.store.get('paused')));
|
|
148
|
+
await $.store.set('paused', r.paused);
|
|
149
|
+
return { text: r.text };
|
|
150
|
+
});
|
|
151
|
+
// Routing fails open: a scorer or hook error must never break the session,
|
|
152
|
+
// it just means the requested model is used as-is.
|
|
153
|
+
on('prompt.submit', scorePrompt).catch(($, e, next) => { decision = null; return next(e); });
|
|
154
|
+
on('turn.step', routeStep).catch(async function* ($, e, next) { return yield* next(e); });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Exported for tests only - not part of the mod's runtime contract.
|
|
158
|
+
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, MODELS };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function supportsMods(versionString) {
|
|
4
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(versionString || '').trim());
|
|
5
|
+
if (!m) return false;
|
|
6
|
+
const actual = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
7
|
+
const floor = [2, 1, 287];
|
|
8
|
+
for (let i = 0; i < 3; i++) {
|
|
9
|
+
if (actual[i] > floor[i]) return true;
|
|
10
|
+
if (actual[i] < floor[i]) return false;
|
|
11
|
+
}
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function detectInstalledClaudeCodeVersion(execFn = execFileSync) {
|
|
16
|
+
try {
|
|
17
|
+
const out = execFn('claude', ['--version'], { encoding: 'utf8' });
|
|
18
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(out.trim());
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { installTroxyGuardMod, uninstallTroxyGuardMod } from '../init.js';
|
|
7
|
+
|
|
8
|
+
test('installTroxyGuardMod: copies the mod when the version supports it', async () => {
|
|
9
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
|
|
10
|
+
try {
|
|
11
|
+
const installed = await installTroxyGuardMod(tmpHome, async () => ({ major: 2, minor: 1, patch: 289 }));
|
|
12
|
+
assert.equal(installed, true);
|
|
13
|
+
assert.ok(fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard', '.claude-plugin', 'plugin.json')));
|
|
14
|
+
assert.ok(fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard', 'hooks', 'register.js')));
|
|
15
|
+
} finally {
|
|
16
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('installTroxyGuardMod: skips silently below the version floor', async () => {
|
|
21
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
|
|
22
|
+
try {
|
|
23
|
+
const installed = await installTroxyGuardMod(tmpHome, async () => ({ major: 2, minor: 1, patch: 285 }));
|
|
24
|
+
assert.equal(installed, false);
|
|
25
|
+
assert.ok(!fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard')));
|
|
26
|
+
} finally {
|
|
27
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('installTroxyGuardMod: skips silently when Claude Code is not found at all', async () => {
|
|
32
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
|
|
33
|
+
try {
|
|
34
|
+
const installed = await installTroxyGuardMod(tmpHome, async () => null);
|
|
35
|
+
assert.equal(installed, false);
|
|
36
|
+
assert.ok(!fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard')));
|
|
37
|
+
} finally {
|
|
38
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('installTroxyGuardMod: re-running replaces a stale copy rather than erroring on an existing directory', async () => {
|
|
43
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
|
|
44
|
+
try {
|
|
45
|
+
const destDir = path.join(tmpHome, '.claude', 'skills', 'troxy-guard');
|
|
46
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
47
|
+
fs.writeFileSync(path.join(destDir, 'stale-leftover-file.txt'), 'from a previous version');
|
|
48
|
+
await installTroxyGuardMod(tmpHome, async () => ({ major: 2, minor: 1, patch: 289 }));
|
|
49
|
+
assert.ok(fs.existsSync(path.join(destDir, '.claude-plugin', 'plugin.json')));
|
|
50
|
+
assert.ok(!fs.existsSync(path.join(destDir, 'stale-leftover-file.txt')), 'expected the stale file to be gone after a clean reinstall');
|
|
51
|
+
} finally {
|
|
52
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('uninstallTroxyGuardMod: removes the installed mod directory', () => {
|
|
57
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
|
|
58
|
+
try {
|
|
59
|
+
const destDir = path.join(tmpHome, '.claude', 'skills', 'troxy-guard');
|
|
60
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
61
|
+
fs.writeFileSync(path.join(destDir, 'plugin.json'), '{}');
|
|
62
|
+
uninstallTroxyGuardMod(tmpHome);
|
|
63
|
+
assert.ok(!fs.existsSync(destDir));
|
|
64
|
+
} finally {
|
|
65
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('uninstallTroxyGuardMod: a no-op (not a throw) when nothing was installed', () => {
|
|
70
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
|
|
71
|
+
try {
|
|
72
|
+
assert.doesNotThrow(() => uninstallTroxyGuardMod(tmpHome));
|
|
73
|
+
} finally {
|
|
74
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { strict as assert } from 'assert';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { installTroxyGuardMod } from '../init.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const initJsPath = path.join(__dirname, '..', 'init.js');
|
|
12
|
+
|
|
13
|
+
test('on an unsupported Claude Code version, a real troxy init completes and installs no mod, with no error', () => {
|
|
14
|
+
// A fake `claude` reporting 2.1.285 (below the 2.1.287 floor) sits first on
|
|
15
|
+
// PATH, so this does not depend on which Claude Code the machine has installed.
|
|
16
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mods-fallback-'));
|
|
17
|
+
try {
|
|
18
|
+
const fakeBin = path.join(tmpHome, 'fakebin');
|
|
19
|
+
fs.mkdirSync(fakeBin);
|
|
20
|
+
fs.writeFileSync(path.join(fakeBin, 'claude'), '#!/bin/sh\necho "2.1.285 (Claude Code)"\n', { mode: 0o755 });
|
|
21
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'Claude'), { recursive: true });
|
|
22
|
+
const desktopConfigPath =
|
|
23
|
+
process.platform === 'darwin'
|
|
24
|
+
? path.join(tmpHome, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
|
|
25
|
+
: path.join(tmpHome, '.config', 'claude', 'claude_desktop_config.json');
|
|
26
|
+
fs.mkdirSync(path.dirname(desktopConfigPath), { recursive: true });
|
|
27
|
+
fs.writeFileSync(desktopConfigPath, '{}');
|
|
28
|
+
|
|
29
|
+
const script = `
|
|
30
|
+
import { reprovisionKeyConsumers } from ${JSON.stringify('file://' + initJsPath)};
|
|
31
|
+
await reprovisionKeyConsumers('test-api-key-ci', 'claude-code', false, false);
|
|
32
|
+
`;
|
|
33
|
+
const scriptPath = path.join(tmpHome, 'run.mjs');
|
|
34
|
+
fs.writeFileSync(scriptPath, script);
|
|
35
|
+
|
|
36
|
+
assert.doesNotThrow(() => {
|
|
37
|
+
execFileSync(process.execPath, [scriptPath], {
|
|
38
|
+
env: { ...process.env, HOME: tmpHome, PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, TROXY_TEST_SKIP_SERVICE_INSTALL: '1' },
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
});
|
|
41
|
+
}, 'expected a real troxy init run to complete without throwing, even though this machine is below the mods version floor');
|
|
42
|
+
|
|
43
|
+
assert.ok(
|
|
44
|
+
!fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard')),
|
|
45
|
+
'expected no mod directory on a version below the floor',
|
|
46
|
+
);
|
|
47
|
+
} finally {
|
|
48
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('with the mod absent entirely (the org-lockdown case), installTroxyGuardMod leaves zero files behind - the proxy remains the sole backstop', async () => {
|
|
53
|
+
// Duplicated deliberately from Task 4's own test, as this plan's named
|
|
54
|
+
// Review Focus regression, so it survives independently of that file.
|
|
55
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mods-fallback-'));
|
|
56
|
+
try {
|
|
57
|
+
const installed = await installTroxyGuardMod(tmpHome, async () => null);
|
|
58
|
+
assert.equal(installed, false);
|
|
59
|
+
assert.ok(!fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard')));
|
|
60
|
+
} finally {
|
|
61
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { _internal } from '../mods/troxy-guard/hooks/register.js';
|
|
4
|
+
|
|
5
|
+
const { parseScore, pickModel, troxyCommand, MODELS } = _internal;
|
|
6
|
+
|
|
7
|
+
test('parseScore: reads a bare number and clamps to 0..1', () => {
|
|
8
|
+
assert.equal(parseScore('0.35'), 0.35);
|
|
9
|
+
assert.equal(parseScore(' 0.8\n'), 0.8);
|
|
10
|
+
assert.equal(parseScore('1.7'), 1);
|
|
11
|
+
assert.equal(parseScore('Score: 0.2'), 0.2);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('parseScore: unparseable text is null so the caller keeps the requested model', () => {
|
|
15
|
+
assert.equal(parseScore(''), null);
|
|
16
|
+
assert.equal(parseScore('hard'), null);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('pickModel: bands route down from opus (<=0.1 haiku, <=0.6 sonnet, <=0.8 opus)', () => {
|
|
20
|
+
assert.equal(pickModel(0.05, MODELS.opus, 1000), MODELS.haiku);
|
|
21
|
+
assert.equal(pickModel(0.4, MODELS.opus, 1000), MODELS.sonnet);
|
|
22
|
+
assert.equal(pickModel(0.7, MODELS.opus, 1000), MODELS.opus);
|
|
23
|
+
assert.equal(pickModel(0.95, MODELS.opus, 1000), MODELS.opus);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('pickModel: never routes upward', () => {
|
|
27
|
+
assert.equal(pickModel(0.95, MODELS.sonnet, 1000), MODELS.sonnet);
|
|
28
|
+
assert.equal(pickModel(0.7, MODELS.haiku, 1000), MODELS.haiku);
|
|
29
|
+
assert.equal(pickModel(0.4, MODELS.haiku, 1000), MODELS.haiku);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('pickModel: no haiku once the context passes the 50k floor, sonnet instead', () => {
|
|
33
|
+
assert.equal(pickModel(0.05, MODELS.opus, 60000), MODELS.sonnet);
|
|
34
|
+
assert.equal(pickModel(0.05, MODELS.sonnet, 60000), MODELS.sonnet);
|
|
35
|
+
assert.equal(pickModel(0.05, MODELS.haiku, 60000), MODELS.haiku);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('pickModel: a model it does not recognise is left alone', () => {
|
|
39
|
+
assert.equal(pickModel(0.05, 'some-other-model', 1000), 'some-other-model');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('pickModel: a [1m] suffix is understood when comparing tiers', () => {
|
|
43
|
+
assert.equal(pickModel(0.4, MODELS.opus + '[1m]', 1000), MODELS.sonnet);
|
|
44
|
+
assert.equal(pickModel(0.95, MODELS.opus + '[1m]', 1000), MODELS.opus + '[1m]');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('troxyCommand: off and on flip the setting, status and anything else only report it', () => {
|
|
48
|
+
assert.deepEqual(troxyCommand('off', false), { paused: true, text: 'Troxy routing is off. Your chosen model is used as-is. Type /troxy on to resume.' });
|
|
49
|
+
assert.deepEqual(troxyCommand(' ON ', true), { paused: false, text: 'Troxy routing is on.' });
|
|
50
|
+
assert.equal(troxyCommand('status', true).paused, true);
|
|
51
|
+
assert.match(troxyCommand('', true).text, /off/);
|
|
52
|
+
assert.match(troxyCommand('status', false).text, /on/);
|
|
53
|
+
assert.equal(troxyCommand('nonsense', false).paused, false);
|
|
54
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { _internal } from '../mods/troxy-guard/hooks/register.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const MOD_DIR = path.join(__dirname, '..', 'mods', 'troxy-guard');
|
|
12
|
+
|
|
13
|
+
// --- Unit tests of the guard logic itself (fast, no subprocess needed) ---
|
|
14
|
+
//
|
|
15
|
+
// checkPolicy is deliberately NOT extracted as its own function: `claude
|
|
16
|
+
// plugin validate` enforces that $ is only ever used inline at its own call
|
|
17
|
+
// site (`$.noun.event(...)`), never forwarded as an argument to a helper -
|
|
18
|
+
// an early version of this file failed validation for exactly that. That
|
|
19
|
+
// collapses the testable surface here to toDomainAndFields and failClosed;
|
|
20
|
+
// the BLOCK/ESCALATE/ALLOW branches are exercised by the real subprocess
|
|
21
|
+
// test below instead, against a real $.http.fetch call.
|
|
22
|
+
|
|
23
|
+
function fakeNext(e) { return Promise.resolve({ _passedThrough: e }); }
|
|
24
|
+
|
|
25
|
+
test('guardBash: unmapped command (current real scope) passes through unchanged', async () => {
|
|
26
|
+
const result = await _internal.guardBash({}, { marker: 3 }, fakeNext);
|
|
27
|
+
assert.deepEqual(result, { _passedThrough: { marker: 3 } });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('failClosed: denies with the error message rather than throwing or allowing', async () => {
|
|
31
|
+
const result = await _internal.failClosed({}, {}, fakeNext, new Error('fetch failed'));
|
|
32
|
+
assert.ok(result.deny.includes('fetch failed'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// --- Real subprocess test: the mod actually loads and Bash passes through ---
|
|
36
|
+
|
|
37
|
+
test('real subprocess: the mod loads via --plugin-dir and claude plugin validate recognizes the tool.call hook', () => {
|
|
38
|
+
const out = execFileSync('npx', ['-y', '@anthropic-ai/claude-code@2.1.289', 'plugin', 'validate', MOD_DIR], {
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
timeout: 60000,
|
|
41
|
+
});
|
|
42
|
+
assert.ok(out.includes('tool.call'), `expected the validator to list a tool.call hook, got: ${out}`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('real subprocess: a real Bash command still runs normally through the mod (current scope: always passes through)', { skip: !fs.existsSync(path.join(os.homedir(), '.claude.json')) && 'no local Claude Code login available - this test needs a real, already-authenticated session (see project_claude_code_mods_investigation memory: a copied .claude.json alone was not sufficient to authenticate a fresh HOME in this environment, so this runs against the real HOME instead of an isolated one)' }, () => {
|
|
46
|
+
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-guard-e2e-workdir-'));
|
|
47
|
+
try {
|
|
48
|
+
fs.mkdirSync(path.join(workdir, '.claude'), { recursive: true });
|
|
49
|
+
fs.writeFileSync(
|
|
50
|
+
path.join(workdir, '.claude', 'settings.local.json'),
|
|
51
|
+
JSON.stringify({ permissions: { allow: ['Bash(echo:*)'] } }),
|
|
52
|
+
);
|
|
53
|
+
const out = execFileSync(
|
|
54
|
+
'npx',
|
|
55
|
+
['-y', '@anthropic-ai/claude-code@2.1.289', '--plugin-dir', MOD_DIR, '-p',
|
|
56
|
+
'Run this exact bash command: echo TROXY_GUARD_PASSTHROUGH_TEST', '--output-format', 'json'],
|
|
57
|
+
{ cwd: workdir, encoding: 'utf8', timeout: 90000 },
|
|
58
|
+
);
|
|
59
|
+
const result = JSON.parse(out);
|
|
60
|
+
assert.equal(result.is_error, false);
|
|
61
|
+
} finally {
|
|
62
|
+
fs.rmSync(workdir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { supportsMods, detectInstalledClaudeCodeVersion } from '../mods/version-check.js';
|
|
4
|
+
|
|
5
|
+
test('supportsMods: below floor returns false', () => {
|
|
6
|
+
assert.equal(supportsMods('2.1.285 (Claude Code)'), false);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('supportsMods: at floor returns true', () => {
|
|
10
|
+
assert.equal(supportsMods('2.1.287 (Claude Code)'), true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('supportsMods: above floor (minor bump) returns true', () => {
|
|
14
|
+
assert.equal(supportsMods('2.2.0 (Claude Code)'), true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('supportsMods: above floor (patch bump) returns true', () => {
|
|
18
|
+
assert.equal(supportsMods('2.1.289 (Claude Code)'), true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('supportsMods: below floor (lower major) returns false', () => {
|
|
22
|
+
assert.equal(supportsMods('1.9.999 (Claude Code)'), false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('supportsMods: unparseable string returns false, not a throw', () => {
|
|
26
|
+
assert.equal(supportsMods('not a version'), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('supportsMods: empty/undefined returns false', () => {
|
|
30
|
+
assert.equal(supportsMods(undefined), false);
|
|
31
|
+
assert.equal(supportsMods(''), false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('detectInstalledClaudeCodeVersion: parses a real-shaped execFn result', async () => {
|
|
35
|
+
const fakeExec = () => '2.1.289 (Claude Code)\n';
|
|
36
|
+
const result = await detectInstalledClaudeCodeVersion(fakeExec);
|
|
37
|
+
assert.deepEqual(result, { major: 2, minor: 1, patch: 289 });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('detectInstalledClaudeCodeVersion: returns null, not a throw, when the command fails', async () => {
|
|
41
|
+
const fakeExec = () => { throw new Error('command not found: claude'); };
|
|
42
|
+
const result = await detectInstalledClaudeCodeVersion(fakeExec);
|
|
43
|
+
assert.equal(result, null);
|
|
44
|
+
});
|
package/src/uninstall.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
claudeCodeConfigPath, claudeCodeSettingsPath,
|
|
10
10
|
unpatchClaudeCodeConfig, _unpatchClaudeCodeHooksFile,
|
|
11
11
|
unpatchClaudeCodeProxy, unpatchClaudeCodeInterception,
|
|
12
|
+
uninstallTroxyGuardMod,
|
|
12
13
|
} from './init.js';
|
|
13
14
|
import { launchdUserDomain } from './launchd.js';
|
|
14
15
|
|
|
@@ -147,6 +148,7 @@ export async function runUninstall() {
|
|
|
147
148
|
_unpatchClaudeCodeHooksFile(claudeCodeSettingsPath());
|
|
148
149
|
unpatchClaudeCodeProxy(claudeCodeSettingsPath());
|
|
149
150
|
unpatchClaudeCodeInterception(claudeCodeSettingsPath());
|
|
151
|
+
uninstallTroxyGuardMod();
|
|
150
152
|
console.log('✓');
|
|
151
153
|
} catch (err) {
|
|
152
154
|
console.log('✗');
|