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.27",
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 policy engine (terminal + Desktop) ✓`
180
- : ` • Troxy policy engine - (needs Claude Code 2.1.287+, skipped)`);
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 policy engine ✗ (${err.message})`);
182
+ console.log(` • Troxy model routing ✗ (${err.message})`);
183
183
  }
184
184
  }
185
185
  if (hasClaude) {
@@ -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's policy engine, enforcing your org's ALLOW/BLOCK/ESCALATE rules on every tool call."
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
+ });