troxy-cli 1.29.26 → 1.29.28
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.28",
|
|
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/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
|
}
|
|
@@ -48,7 +48,8 @@ async function failClosed($, e, next, err) {
|
|
|
48
48
|
|
|
49
49
|
// --- Routing: extend the usage bar by sending easy prompts to a cheaper model.
|
|
50
50
|
//
|
|
51
|
-
// Ported from the proxy's scorer (troxy-tf-live model-proxy/app.py
|
|
51
|
+
// Ported from the proxy's scorer (troxy-tf-live model-proxy/app.py; bands match
|
|
52
|
+
// the production ROUTE_SCORER_BANDS=0.2:haiku,0.6:sonnet,0.8:opus,
|
|
52
53
|
// never-upward rule, 50k context floor for Haiku). The mod scores each human
|
|
53
54
|
// prompt once with the user's own session via $.model.complete, so there is no
|
|
54
55
|
// Troxy round trip and no credential to carry. One decision per prompt, applied
|
|
@@ -60,7 +61,7 @@ const MODELS = {
|
|
|
60
61
|
opus: 'claude-opus-5-5',
|
|
61
62
|
};
|
|
62
63
|
const RANK = { haiku: 0, sonnet: 1, opus: 2 };
|
|
63
|
-
const BANDS = [[0.
|
|
64
|
+
const BANDS = [[0.2, 'haiku'], [0.6, 'sonnet'], [0.8, 'opus']];
|
|
64
65
|
const HAIKU_CONTEXT_FLOOR_TOKENS = 50000;
|
|
65
66
|
const SCORE_TIMEOUT_MS = 3000;
|
|
66
67
|
const SCORER_PROMPT_CHARS = 4000;
|
|
@@ -107,11 +108,27 @@ function troxyCommand(args, paused) {
|
|
|
107
108
|
// Whether this session has shown its one "Troxy is active" notice yet.
|
|
108
109
|
let announced = false;
|
|
109
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
|
+
|
|
110
124
|
// The decision for the turn in flight: { score, tokens } or null (no change).
|
|
111
125
|
let decision = null;
|
|
112
126
|
|
|
113
127
|
async function scorePrompt($, e, next) {
|
|
114
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
|
|
115
132
|
if (await $.store.get('paused')) return next(e);
|
|
116
133
|
const kind = e.origin && e.origin.kind;
|
|
117
134
|
if (kind !== 'composer' && kind !== 'bridge' && kind !== 'sdk') return next(e);
|
|
@@ -134,6 +151,15 @@ async function* routeStep($, e, next) {
|
|
|
134
151
|
announced = true;
|
|
135
152
|
$.ui.toast('Troxy is active');
|
|
136
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
|
+
}
|
|
137
163
|
return yield* next(model === e.model ? e : { ...e, model });
|
|
138
164
|
}
|
|
139
165
|
|
|
@@ -155,4 +181,4 @@ export function register(on) {
|
|
|
155
181
|
}
|
|
156
182
|
|
|
157
183
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
158
|
-
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);
|
|
@@ -16,8 +16,10 @@ test('parseScore: unparseable text is null so the caller keeps the requested mod
|
|
|
16
16
|
assert.equal(parseScore('hard'), null);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
test('pickModel: bands route down from opus (<=0.
|
|
19
|
+
test('pickModel: bands route down from opus (<=0.2 haiku, <=0.6 sonnet, <=0.8 opus)', () => {
|
|
20
20
|
assert.equal(pickModel(0.05, MODELS.opus, 1000), MODELS.haiku);
|
|
21
|
+
assert.equal(pickModel(0.2, MODELS.opus, 1000), MODELS.haiku);
|
|
22
|
+
assert.equal(pickModel(0.25, MODELS.opus, 1000), MODELS.sonnet);
|
|
21
23
|
assert.equal(pickModel(0.4, MODELS.opus, 1000), MODELS.sonnet);
|
|
22
24
|
assert.equal(pickModel(0.7, MODELS.opus, 1000), MODELS.opus);
|
|
23
25
|
assert.equal(pickModel(0.95, MODELS.opus, 1000), MODELS.opus);
|
|
@@ -52,3 +54,14 @@ test('troxyCommand: off and on flip the setting, status and anything else only r
|
|
|
52
54
|
assert.match(troxyCommand('status', false).text, /on/);
|
|
53
55
|
assert.equal(troxyCommand('nonsense', false).paused, false);
|
|
54
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
|
+
});
|