troxy-cli 1.29.52 → 1.29.54

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.52",
3
+ "version": "1.29.54",
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/daemon.js CHANGED
@@ -23,7 +23,7 @@ import { api } from './api.js';
23
23
  import { createInterceptor } from './interceptor.js';
24
24
  import { enabledProviders, interceptHostsFor, troxyRouteFor } from './providers.js';
25
25
  import { ensureInterceptionCerts } from './tls-ca.js';
26
- import { migrateBaseUrlProxyToInterception, claudeCodeSettingsPath } from './init.js';
26
+ import { migrateBaseUrlProxyToInterception, claudeCodeSettingsPath, refreshTroxyGuardMod } from './init.js';
27
27
 
28
28
  const INTERVAL_MS = 60_000;
29
29
 
@@ -139,6 +139,8 @@ export async function runDaemon() {
139
139
  process.on('unhandledRejection', (err) => {
140
140
  process.stderr.write(`[troxy-daemon] unhandled rejection (continuing): ${err?.stack || err}\n`);
141
141
  });
142
+ // A CLI upgrade (npm or troxy update) then a daemon restart brings the mod up to date.
143
+ refreshTroxyGuardMod().catch((err) => process.stderr.write(`[troxy-daemon] mod refresh failed: ${err.message}\n`));
142
144
 
143
145
  const cfg = loadConfig();
144
146
  if (!cfg?.apiKey) {
package/src/init.js CHANGED
@@ -38,6 +38,18 @@ export async function installTroxyGuardMod(home = os.homedir(), detectFn = detec
38
38
  return true;
39
39
  }
40
40
 
41
+ // Only `troxy init` used to install the mod, so a CLI upgrade left the old mod running.
42
+ // Re-copy it when the installed one is stamped with a different CLI version; a machine
43
+ // without the mod (never ran init, or uninstalled) is left alone.
44
+ export async function refreshTroxyGuardMod(home = os.homedir(), detectFn = detectInstalledClaudeCodeVersion) {
45
+ const manifest = path.join(home, '.claude', 'skills', 'troxy-guard', '.claude-plugin', 'plugin.json');
46
+ let installed;
47
+ try { installed = JSON.parse(fs.readFileSync(manifest, 'utf8')).version; } catch { return false; }
48
+ const { version } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
49
+ if (installed === version) return false;
50
+ return installTroxyGuardMod(home, detectFn);
51
+ }
52
+
41
53
  export function uninstallTroxyGuardMod(home = os.homedir()) {
42
54
  fs.rmSync(path.join(home, '.claude', 'skills', 'troxy-guard'), { recursive: true, force: true });
43
55
  }
@@ -113,6 +125,7 @@ export async function refreshHostedInstructions(key) {
113
125
 
114
126
  export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null, conciseOptIn = null) {
115
127
  const platform = process.platform;
128
+ try { if (await refreshTroxyGuardMod()) console.log(' • Troxy model routing updated ✓'); } catch {}
116
129
  const { detected, hasOpenClaw, hasClaude, none } = detectMcpClients();
117
130
 
118
131
  if (none) {
@@ -118,9 +118,9 @@ function _readModRoute(dir, sessionId) {
118
118
  const arm = n.arm === 'treated' || n.arm === 'holdout' ? n.arm : null;
119
119
  const isFresh = Date.now() - n.ts < _MOD_ROUTE_MAX_AGE_MS;
120
120
  const routed = isFresh && typeof n.from === 'string' && typeof n.to === 'string';
121
- return { arm, from: routed ? n.from : null, to: routed ? n.to : null, compact: isFresh && n.compact === true };
121
+ return { arm, from: routed ? n.from : null, to: routed ? n.to : null, compact: isFresh && n.compact === true, cold: isFresh && typeof n.cold === 'boolean' ? n.cold : null };
122
122
  } catch {
123
- return { arm: null, from: null, to: null, compact: false };
123
+ return { arm: null, from: null, to: null, compact: false, cold: null };
124
124
  }
125
125
  }
126
126
 
@@ -183,6 +183,8 @@ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxState
183
183
  if (modRoute && modRoute.arm) headers['x-troxy-mod-arm'] = modRoute.arm;
184
184
  // The mod's idle compaction is in flight: this is its summary request.
185
185
  if (modRoute && modRoute.compact) headers['x-troxy-mod-compact'] = '1';
186
+ // Tagged: whether this request hit the cold or warm path (for measurement).
187
+ if (modRoute && typeof modRoute.cold === 'boolean') headers['x-troxy-switch'] = modRoute.cold ? 'cold' : 'warm';
186
188
  headers.authorization = `Bearer ${troxyKey}`;
187
189
  return headers;
188
190
  }
@@ -453,9 +453,10 @@ async function* routeStep($, e, next) {
453
453
  const h = held.get(sid) || { decision: null, prompts: 0 };
454
454
  const newPrompt = Boolean(decision) && decision !== h.decision;
455
455
  if (newPrompt) h.decision = decision;
456
+ const isCold = !ls || now - ls.at >= WARM_MS || coldNext.has(sid);
456
457
  const model = (await $.store.get('paused')) ? want : stepModel({
457
458
  arm, requested: want, switched,
458
- cold: !ls || now - ls.at >= WARM_MS || coldNext.has(sid),
459
+ cold: isCold,
459
460
  score: decision ? decision.score : null, warm: ls && ls.model,
460
461
  newPrompt, heldPrompts: h.prompts, tokens: decision ? decision.tokens : Infinity,
461
462
  });
@@ -470,7 +471,8 @@ async function* routeStep($, e, next) {
470
471
  const key = `${sid}:${arm}:${want}>${model}`;
471
472
  if (file && lastNote !== key) {
472
473
  lastNote = key;
473
- await $.fs.write(file, JSON.stringify(model === want ? { arm, ts: now } : { arm, from: want, to: model, ts: now }));
474
+ const route = model === want ? { arm, cold: isCold, ts: now } : { arm, from: want, to: model, cold: isCold, ts: now };
475
+ await $.fs.write(file, JSON.stringify(route));
474
476
  }
475
477
  try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
476
478
  if (model !== want && turn) turn.ratio = PRICE[tierOf(want)] / PRICE[tierOf(model)];
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import fs from 'node:fs';
6
- import { installTroxyGuardMod, uninstallTroxyGuardMod } from '../init.js';
6
+ import { installTroxyGuardMod, uninstallTroxyGuardMod, refreshTroxyGuardMod } from '../init.js';
7
7
 
8
8
  test('installTroxyGuardMod: copies the mod when the version supports it', async () => {
9
9
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
@@ -77,3 +77,20 @@ test('uninstallTroxyGuardMod: a no-op (not a throw) when nothing was installed',
77
77
  fs.rmSync(tmpHome, { recursive: true, force: true });
78
78
  }
79
79
  });
80
+
81
+ test('refreshTroxyGuardMod: re-copies a stale mod, leaves a current or missing one alone', async () => {
82
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-mod-home-'));
83
+ const cc = async () => ({ major: 2, minor: 1, patch: 289 });
84
+ const manifest = path.join(tmpHome, '.claude', 'skills', 'troxy-guard', '.claude-plugin', 'plugin.json');
85
+ try {
86
+ assert.equal(await refreshTroxyGuardMod(tmpHome, cc), false); // never installed: stays off
87
+ assert.equal(fs.existsSync(manifest), false);
88
+ await installTroxyGuardMod(tmpHome, cc);
89
+ assert.equal(await refreshTroxyGuardMod(tmpHome, cc), false); // already current
90
+ fs.writeFileSync(manifest, JSON.stringify({ name: 'troxy-guard', version: '0.0.1' }));
91
+ assert.equal(await refreshTroxyGuardMod(tmpHome, cc), true); // stale: re-copied and re-stamped
92
+ assert.notEqual(JSON.parse(fs.readFileSync(manifest, 'utf8')).version, '0.0.1');
93
+ } finally {
94
+ fs.rmSync(tmpHome, { recursive: true, force: true });
95
+ }
96
+ });