troxy-cli 1.29.46 → 1.29.47

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.46",
3
+ "version": "1.29.47",
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
@@ -105,7 +105,7 @@ export function startInterceptor({
105
105
  routeResolver: (host, method, reqPath) => troxyRouteFor(providers, host, method, reqPath),
106
106
  // Fresh from disk on every request, not cached at startup - a
107
107
  // `troxy rotate-key` takes effect immediately, no daemon restart needed.
108
- getTroxyKey: () => loadConfig()?.apiKey || null,
108
+ getTroxyKey: () => loadConfig()?.apiKey?.trim() || null,
109
109
  getCerts: () => certs,
110
110
  log: _log,
111
111
  });
@@ -104,7 +104,7 @@ export function detectSurface(incomingHeaders) {
104
104
  const _SANDBOX_STATE_FRESHNESS_MS = 2 * 60 * 1000;
105
105
  const _DEFAULT_SANDBOX_STATE_DIR = path.join(os.homedir(), '.troxy', 'sandbox');
106
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
107
+ // { arm, from?, to?, ts } per session. Forwarded so the proxy can credit the saving to
108
108
  // the model the user originally asked for (it only ever sees the rerouted one).
109
109
  const _DEFAULT_MOD_ROUTE_DIR = path.join(os.homedir(), '.troxy', 'mod-route');
110
110
  const _MOD_ROUTE_MAX_AGE_MS = 30 * 60 * 1000;
@@ -112,12 +112,15 @@ const _MOD_ROUTE_MAX_AGE_MS = 30 * 60 * 1000;
112
112
  function _readModRoute(dir, sessionId) {
113
113
  try {
114
114
  const file = sessionArmPath(dir, sessionId);
115
- if (!file) return null;
115
+ if (!file) return { arm: null, from: null, to: null };
116
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;
117
+ // arm is per-session state and never expires; from/to is only valid if fresh.
118
+ const arm = n.arm === 'treated' || n.arm === 'holdout' ? n.arm : null;
119
+ const isFresh = Date.now() - n.ts < _MOD_ROUTE_MAX_AGE_MS;
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 };
119
122
  } catch {
120
- return null;
123
+ return { arm: null, from: null, to: null };
121
124
  }
122
125
  }
123
126
 
@@ -172,10 +175,12 @@ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxState
172
175
  : _readFreshSandboxState(sandboxStateDir);
173
176
  if (sandboxState) headers['x-troxy-tool-sandbox'] = sandboxState;
174
177
  const modRoute = _readModRoute(modRouteDir, incomingHeaders['x-claude-code-session-id']);
175
- if (modRoute) {
178
+ if (modRoute && modRoute.from) {
176
179
  headers['x-troxy-mod-routed-from'] = modRoute.from;
177
180
  headers['x-troxy-mod-routed-to'] = modRoute.to;
178
181
  }
182
+ // The mod is this session's one router: the proxy must not route it, and logs the arm.
183
+ if (modRoute && modRoute.arm) headers['x-troxy-mod-arm'] = modRoute.arm;
179
184
  headers.authorization = `Bearer ${troxyKey}`;
180
185
  return headers;
181
186
  }
@@ -279,13 +284,15 @@ function _relayToTroxy(route, req, res, body, troxyKey, connectTimeoutMs, respon
279
284
  resolve(result);
280
285
  };
281
286
 
282
- const upstreamReq = _clientForProtocol(route.upstream).request({
283
- method: req.method,
284
- hostname: url.hostname,
285
- port: url.port || (url.protocol === 'http:' ? 80 : 443),
286
- path: url.pathname + search,
287
- headers: buildTroxyForwardHeaders(req.headers, troxyKey),
288
- }, (upstreamRes) => {
287
+ let upstreamReq;
288
+ try {
289
+ upstreamReq = _clientForProtocol(route.upstream).request({
290
+ method: req.method,
291
+ hostname: url.hostname,
292
+ port: url.port || (url.protocol === 'http:' ? 80 : 443),
293
+ path: url.pathname + search,
294
+ headers: buildTroxyForwardHeaders(req.headers, troxyKey),
295
+ }, (upstreamRes) => {
289
296
  if (settled) return; // already failed open on a timeout; drop this late arrival
290
297
  settled = true;
291
298
  clearTimeout(connectTimer);
@@ -328,6 +335,11 @@ function _relayToTroxy(route, req, res, body, troxyKey, connectTimeoutMs, respon
328
335
 
329
336
  upstreamReq.on('error', () => settle(false));
330
337
  upstreamReq.end(body);
338
+ } catch (err) {
339
+ // Invalid header value (e.g. a trailing newline in the key) throws synchronously: fail open.
340
+ log('troxy request rejected before sending: %s', err.message);
341
+ settle(false);
342
+ }
331
343
  });
332
344
  }
333
345
 
@@ -149,6 +149,20 @@ describe('buildTroxyForwardHeaders', () => {
149
149
  note(dir, { from: 'a', to: 'b', ts: Date.now() });
150
150
  assert.equal(build(dir, '../etc/passwd')['x-troxy-mod-routed-from'], undefined);
151
151
  });
152
+
153
+ it('tags the arm on every call, with or without a reroute', () => {
154
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-modroute-'));
155
+ note(dir, { arm: 'holdout', ts: Date.now() });
156
+ let h = build(dir);
157
+ assert.equal(h['x-troxy-mod-arm'], 'holdout');
158
+ assert.equal(h['x-troxy-mod-routed-from'], undefined);
159
+ note(dir, { arm: 'treated', from: 'claude-opus-5-5', to: 'claude-haiku-4-5-20251001', ts: Date.now() });
160
+ h = build(dir);
161
+ assert.equal(h['x-troxy-mod-arm'], 'treated');
162
+ assert.equal(h['x-troxy-mod-routed-to'], 'claude-haiku-4-5-20251001');
163
+ note(dir, { arm: 'bogus', ts: Date.now() });
164
+ assert.equal(build(dir)['x-troxy-mod-arm'], undefined);
165
+ });
152
166
  });
153
167
 
154
168
  it('omits x-troxy-tool-sandbox header when no state file exists', () => {
@@ -702,3 +716,58 @@ describe('buildTroxyForwardHeaders session arm', () => {
702
716
  assert.equal('x-troxy-tool-sandbox' in headers, false);
703
717
  });
704
718
  });
719
+
720
+ describe('interceptor fails open on invalid header values', () => {
721
+ const openServers = [];
722
+ after(async () => {
723
+ openServers.forEach(s => s.close());
724
+ });
725
+
726
+ it('handles API key with trailing newline gracefully without crashing', async () => {
727
+ // Simulates daemon.js config loading a key with a trailing newline,
728
+ // before the trim fix. The interceptor must fail open, not crash.
729
+ const targetServer = net.createServer((socket) => {
730
+ socket.write('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK');
731
+ socket.end();
732
+ });
733
+ await new Promise(r => targetServer.listen(0, '127.0.0.1', r));
734
+ openServers.push(targetServer);
735
+ const targetPort = targetServer.address().port;
736
+
737
+ const interceptor = createInterceptor({
738
+ interceptHosts: ['api.anthropic.com'],
739
+ routeResolver: (host, method, reqPath) => {
740
+ if (host === 'api.anthropic.com') {
741
+ return { method, path: '/v1/messages', upstream: `http://127.0.0.1:${targetPort}` };
742
+ }
743
+ return null;
744
+ },
745
+ // Return a key with trailing newline - simulates the bug before daemon.js trim
746
+ getTroxyKey: () => 'txy-test-key\n',
747
+ getCerts: () => null, // No TLS for this test, it goes straight to HTTP upstream
748
+ log: () => {},
749
+ });
750
+ await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
751
+ openServers.push(interceptor);
752
+
753
+ // Make a request through the proxy to the anthropic host
754
+ // The invalid header should cause a catch in _relayToTroxy, which should
755
+ // settle(false) to fail open, not crash the server.
756
+ const response = await new Promise((resolve, reject) => {
757
+ const req = net.createConnection(
758
+ { host: '127.0.0.1', port: interceptor.address().port },
759
+ () => {
760
+ req.write('GET / HTTP/1.1\r\nHost: api.anthropic.com\r\nConnection: close\r\n\r\n');
761
+ }
762
+ );
763
+ let body = '';
764
+ req.on('data', (chunk) => { body += chunk.toString(); });
765
+ req.on('end', () => resolve(body));
766
+ req.on('error', reject);
767
+ });
768
+
769
+ // Verify we got a response (either direct relay or error response),
770
+ // and the server didn't crash
771
+ assert.ok(response.length > 0, 'should get a response, not crash');
772
+ });
773
+ });