troxy-cli 1.29.27 → 1.29.29
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.29",
|
|
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
|
@@ -176,10 +176,10 @@ export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null,
|
|
|
176
176
|
try {
|
|
177
177
|
const installed = await installTroxyGuardMod();
|
|
178
178
|
console.log(installed
|
|
179
|
-
? ` • Troxy
|
|
180
|
-
: ` • Troxy
|
|
179
|
+
? ` • Troxy model routing (terminal + Desktop) ✓`
|
|
180
|
+
: ` • Troxy model routing - (needs Claude Code 2.1.287+, skipped)`);
|
|
181
181
|
} catch (err) {
|
|
182
|
-
console.log(` • Troxy
|
|
182
|
+
console.log(` • Troxy model routing ✗ (${err.message})`);
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
if (hasClaude) {
|
package/src/interceptor.js
CHANGED
|
@@ -103,6 +103,23 @@ export function detectSurface(incomingHeaders) {
|
|
|
103
103
|
// header is omitted entirely rather than forwarding a stale value.
|
|
104
104
|
const _SANDBOX_STATE_FRESHNESS_MS = 2 * 60 * 1000;
|
|
105
105
|
const _DEFAULT_SANDBOX_STATE_DIR = path.join(os.homedir(), '.troxy', 'sandbox');
|
|
106
|
+
// Written by the troxy-guard mod when it reroutes a prompt to a cheaper model:
|
|
107
|
+
// { from, to, ts } per session. Forwarded so the proxy can credit the saving to
|
|
108
|
+
// the model the user originally asked for (it only ever sees the rerouted one).
|
|
109
|
+
const _DEFAULT_MOD_ROUTE_DIR = path.join(os.homedir(), '.troxy', 'mod-route');
|
|
110
|
+
const _MOD_ROUTE_MAX_AGE_MS = 30 * 60 * 1000;
|
|
111
|
+
|
|
112
|
+
function _readModRoute(dir, sessionId) {
|
|
113
|
+
try {
|
|
114
|
+
const file = sessionArmPath(dir, sessionId);
|
|
115
|
+
if (!file) return null;
|
|
116
|
+
const n = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
117
|
+
if (typeof n.from !== 'string' || typeof n.to !== 'string' || !(Date.now() - n.ts < _MOD_ROUTE_MAX_AGE_MS)) return null;
|
|
118
|
+
return n;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
106
123
|
|
|
107
124
|
// Returns the hook's last recorded state ('treated' | 'holdout' | 'skipped')
|
|
108
125
|
// if the state file exists, parses, and was written within the freshness
|
|
@@ -135,7 +152,7 @@ function _readSessionArm(sandboxStateDir, sessionId) {
|
|
|
135
152
|
}
|
|
136
153
|
}
|
|
137
154
|
|
|
138
|
-
export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxStateDir = _DEFAULT_SANDBOX_STATE_DIR) {
|
|
155
|
+
export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxStateDir = _DEFAULT_SANDBOX_STATE_DIR, modRouteDir = _DEFAULT_MOD_ROUTE_DIR) {
|
|
139
156
|
const headers = {};
|
|
140
157
|
for (const [k, v] of Object.entries(incomingHeaders)) {
|
|
141
158
|
if (_HOP_BY_HOP_HEADERS.has(k) || k === 'authorization' || k === 'accept-encoding') continue;
|
|
@@ -154,6 +171,11 @@ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxState
|
|
|
154
171
|
? _readSessionArm(sandboxStateDir, incomingHeaders['x-claude-code-session-id'])
|
|
155
172
|
: _readFreshSandboxState(sandboxStateDir);
|
|
156
173
|
if (sandboxState) headers['x-troxy-tool-sandbox'] = sandboxState;
|
|
174
|
+
const modRoute = _readModRoute(modRouteDir, incomingHeaders['x-claude-code-session-id']);
|
|
175
|
+
if (modRoute) {
|
|
176
|
+
headers['x-troxy-mod-routed-from'] = modRoute.from;
|
|
177
|
+
headers['x-troxy-mod-routed-to'] = modRoute.to;
|
|
178
|
+
}
|
|
157
179
|
headers.authorization = `Bearer ${troxyKey}`;
|
|
158
180
|
return headers;
|
|
159
181
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-guard",
|
|
3
3
|
"version": "0.1.0",
|
|
4
|
-
"description": "Troxy
|
|
4
|
+
"description": "Troxy keeps your Claude usage bar going longer. Type /troxy off to pause it.",
|
|
5
|
+
"author": { "name": "Troxy" }
|
|
5
6
|
}
|
|
@@ -108,11 +108,27 @@ function troxyCommand(args, paused) {
|
|
|
108
108
|
// Whether this session has shown its one "Troxy is active" notice yet.
|
|
109
109
|
let announced = false;
|
|
110
110
|
|
|
111
|
+
// Where the local daemon looks for this session's routing note (see interceptor.js).
|
|
112
|
+
// Only when installed under ~/.claude/skills, so a --plugin-dir test run never writes
|
|
113
|
+
// into a real user's folder.
|
|
114
|
+
function modRouteFile(pluginRoot, sessionId) {
|
|
115
|
+
const suffix = '/.claude/skills/troxy-guard';
|
|
116
|
+
if (typeof pluginRoot !== 'string' || !pluginRoot.endsWith(suffix)) return null;
|
|
117
|
+
if (!/^[A-Za-z0-9-]{8,128}$/.test(String(sessionId))) return null;
|
|
118
|
+
return `${pluginRoot.slice(0, -suffix.length)}/.troxy/mod-route/sessions/${sessionId}.json`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// What was last written, so the file is only rewritten when the note changes.
|
|
122
|
+
let lastNote = null;
|
|
123
|
+
|
|
111
124
|
// The decision for the turn in flight: { score, tokens } or null (no change).
|
|
112
125
|
let decision = null;
|
|
113
126
|
|
|
114
127
|
async function scorePrompt($, e, next) {
|
|
115
128
|
decision = null;
|
|
129
|
+
const sid = await $.session.id();
|
|
130
|
+
const file = modRouteFile($.plugin.root, sid);
|
|
131
|
+
if (file && lastNote !== `${sid}:{}`) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); } // clear the previous prompt's note
|
|
116
132
|
if (await $.store.get('paused')) return next(e);
|
|
117
133
|
const kind = e.origin && e.origin.kind;
|
|
118
134
|
if (kind !== 'composer' && kind !== 'bridge' && kind !== 'sdk') return next(e);
|
|
@@ -135,6 +151,15 @@ async function* routeStep($, e, next) {
|
|
|
135
151
|
announced = true;
|
|
136
152
|
$.ui.toast('Troxy is active');
|
|
137
153
|
}
|
|
154
|
+
if (model !== e.model) {
|
|
155
|
+
const sid = await $.session.id();
|
|
156
|
+
const file = modRouteFile($.plugin.root, sid);
|
|
157
|
+
const key = `${sid}:${e.model}>${model}`;
|
|
158
|
+
if (file && lastNote !== key) {
|
|
159
|
+
lastNote = key;
|
|
160
|
+
await $.fs.write(file, JSON.stringify({ from: e.model, to: model, ts: await $.clock.now() }));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
138
163
|
return yield* next(model === e.model ? e : { ...e, model });
|
|
139
164
|
}
|
|
140
165
|
|
|
@@ -156,4 +181,4 @@ export function register(on) {
|
|
|
156
181
|
}
|
|
157
182
|
|
|
158
183
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
159
|
-
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, MODELS };
|
|
184
|
+
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, MODELS };
|
|
@@ -122,6 +122,35 @@ describe('buildTroxyForwardHeaders', () => {
|
|
|
122
122
|
assert.equal(headers['x-troxy-tool-sandbox'], 'treated');
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
describe('mod routing note', () => {
|
|
126
|
+
const SID = 'abcd1234-ef56-7890-abcd-ef1234567890';
|
|
127
|
+
const note = (dir, obj) => {
|
|
128
|
+
fs.mkdirSync(path.join(dir, 'sessions'), { recursive: true });
|
|
129
|
+
fs.writeFileSync(path.join(dir, 'sessions', `${SID}.json`), JSON.stringify(obj));
|
|
130
|
+
};
|
|
131
|
+
const build = (dir, sid = SID) => buildTroxyForwardHeaders(
|
|
132
|
+
{ host: 'api.anthropic.com', 'x-claude-code-session-id': sid }, 'txy-key', undefined, dir);
|
|
133
|
+
|
|
134
|
+
it('adds from/to headers when the session has a fresh routing note', () => {
|
|
135
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-modroute-'));
|
|
136
|
+
note(dir, { from: 'claude-opus-5-5', to: 'claude-haiku-4-5-20251001', ts: Date.now() });
|
|
137
|
+
const h = build(dir);
|
|
138
|
+
assert.equal(h['x-troxy-mod-routed-from'], 'claude-opus-5-5');
|
|
139
|
+
assert.equal(h['x-troxy-mod-routed-to'], 'claude-haiku-4-5-20251001');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('adds nothing when there is no note, a cleared note, a stale note, or a bad session id', () => {
|
|
143
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-modroute-'));
|
|
144
|
+
assert.equal(build(dir)['x-troxy-mod-routed-from'], undefined);
|
|
145
|
+
note(dir, {});
|
|
146
|
+
assert.equal(build(dir)['x-troxy-mod-routed-from'], undefined);
|
|
147
|
+
note(dir, { from: 'a', to: 'b', ts: Date.now() - 31 * 60 * 1000 });
|
|
148
|
+
assert.equal(build(dir)['x-troxy-mod-routed-from'], undefined);
|
|
149
|
+
note(dir, { from: 'a', to: 'b', ts: Date.now() });
|
|
150
|
+
assert.equal(build(dir, '../etc/passwd')['x-troxy-mod-routed-from'], undefined);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
125
154
|
it('omits x-troxy-tool-sandbox header when no state file exists', () => {
|
|
126
155
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
|
|
127
156
|
|
|
@@ -2,7 +2,7 @@ import { test } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { _internal } from '../mods/troxy-guard/hooks/register.js';
|
|
4
4
|
|
|
5
|
-
const { parseScore, pickModel, troxyCommand, MODELS } = _internal;
|
|
5
|
+
const { parseScore, pickModel, troxyCommand, modRouteFile, MODELS } = _internal;
|
|
6
6
|
|
|
7
7
|
test('parseScore: reads a bare number and clamps to 0..1', () => {
|
|
8
8
|
assert.equal(parseScore('0.35'), 0.35);
|
|
@@ -54,3 +54,14 @@ test('troxyCommand: off and on flip the setting, status and anything else only r
|
|
|
54
54
|
assert.match(troxyCommand('status', false).text, /on/);
|
|
55
55
|
assert.equal(troxyCommand('nonsense', false).paused, false);
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
test('modRouteFile: derives the daemon note path from the installed plugin folder', () => {
|
|
59
|
+
const sid = 'abcd1234-ef56-7890-abcd-ef1234567890';
|
|
60
|
+
assert.equal(modRouteFile('/Users/a/.claude/skills/troxy-guard', sid), `/Users/a/.troxy/mod-route/sessions/${sid}.json`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('modRouteFile: null when not installed in ~/.claude/skills (e.g. --plugin-dir) or the id is unsafe', () => {
|
|
64
|
+
const sid = 'abcd1234-ef56-7890-abcd-ef1234567890';
|
|
65
|
+
assert.equal(modRouteFile('/work/troxy/src/mods/troxy-guard', sid), null);
|
|
66
|
+
assert.equal(modRouteFile('/Users/a/.claude/skills/troxy-guard', '../x'), null);
|
|
67
|
+
});
|