troxy-cli 1.29.66 → 1.30.0
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/LICENSE +17 -0
- package/package.json +2 -2
- package/src/init.js +15 -71
- package/src/interceptor.js +8 -0
- package/src/mods/troxy-guard/hooks/register.js +80 -18
- package/src/proxy.js +8 -3
- package/src/tests/interceptor.test.js +47 -6
- package/src/tests/mods-troxy-guard.test.js +56 -0
- package/src/tests/proxy-wiring.test.js +10 -11
package/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Copyright (c) 2026 Troxy. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software is proprietary. You may install and run it only to use the
|
|
4
|
+
Troxy service under your Troxy account, and only while your account is active.
|
|
5
|
+
|
|
6
|
+
You may not copy, modify, distribute, sublicense, sell, or create derivative
|
|
7
|
+
works from this software or any part of it, or use it to build a competing
|
|
8
|
+
product or service, without prior written permission from Troxy.
|
|
9
|
+
|
|
10
|
+
Versions published before this notice were released under the MIT License;
|
|
11
|
+
that license continues to apply to those versions only.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED. IN NO EVENT SHALL TROXY BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
15
|
+
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
|
16
|
+
|
|
17
|
+
Contact: support@troxy.io
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.0",
|
|
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": {
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"payments",
|
|
45
45
|
"secrets"
|
|
46
46
|
],
|
|
47
|
-
"license": "
|
|
47
|
+
"license": "SEE LICENSE IN LICENSE"
|
|
48
48
|
}
|
package/src/init.js
CHANGED
|
@@ -3,7 +3,7 @@ import os from 'os';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import readline from 'readline';
|
|
5
5
|
import { execSync, execFileSync } from 'child_process';
|
|
6
|
-
import { saveConfig } from './config.js';
|
|
6
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
7
7
|
import { evaluatePayment, api } from './api.js';
|
|
8
8
|
import { INTERCEPTOR_PORT } from './daemon.js';
|
|
9
9
|
import { ensureInterceptionCerts } from './tls-ca.js';
|
|
@@ -201,18 +201,7 @@ export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null,
|
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
if (hasClaude) {
|
|
204
|
-
|
|
205
|
-
// here - found live 2026-09-07 that it silently bills the org's own
|
|
206
|
-
// pay-as-you-go API key for every terminal call, since substituting
|
|
207
|
-
// ANTHROPIC_AUTH_TOKEN with a Troxy token means no real Anthropic
|
|
208
|
-
// credential ever reaches model-proxy. This now reuses the same
|
|
209
|
-
// interception mechanism already live for Desktop (see
|
|
210
|
-
// patchClaudeCodeInterception below) instead: terminal keeps its own
|
|
211
|
-
// real login, Troxy watches the traffic on its way to Anthropic.
|
|
212
|
-
// Desktop-only machines are unaffected by this call (hasDesktop is
|
|
213
|
-
// not part of the gate here) - they still use the existing manual
|
|
214
|
-
// `troxy proxy enable --experimental` path.
|
|
215
|
-
await maybeEnableTroxyInterception(proxyOptIn, hasDesktop);
|
|
204
|
+
await useDirectAnthropic();
|
|
216
205
|
}
|
|
217
206
|
console.log('\n Restart your MCP client to activate Troxy.');
|
|
218
207
|
}
|
|
@@ -589,8 +578,8 @@ export async function runInit({ key, name, proxy, concise } = {}) {
|
|
|
589
578
|
// MOD installation is optional - init succeeds even if it fails
|
|
590
579
|
}
|
|
591
580
|
|
|
592
|
-
console.log('\n
|
|
593
|
-
console.log('
|
|
581
|
+
console.log('\n Troxy is on. Restart Claude Code, then work as usual.');
|
|
582
|
+
console.log(' Your savings → https://dash.troxy.io (Usage and Savings)');
|
|
594
583
|
console.log('\n For more information, visit https://docs.troxy.io\n');
|
|
595
584
|
}
|
|
596
585
|
|
|
@@ -1036,67 +1025,22 @@ export function migrateBaseUrlProxyToInterception(configPath) {
|
|
|
1036
1025
|
return true;
|
|
1037
1026
|
}
|
|
1038
1027
|
|
|
1039
|
-
|
|
1040
|
-
// interception path. See the call site above for why terminal moved off
|
|
1041
|
-
// base-URL substitution. Same proxyOptIn semantics as the retired
|
|
1042
|
-
// maybeEnableClaudeCodeProxy: true = enable without asking, null + TTY =
|
|
1043
|
-
// ask interactively, otherwise skip (a scripted/CI init must never hang
|
|
1044
|
-
// on a prompt).
|
|
1045
|
-
async function maybeEnableTroxyInterception(proxyOptIn, hasDesktop, configPath = claudeCodeSettingsPath()) {
|
|
1046
|
-
// Unconditional, regardless of what's decided below: a machine still on
|
|
1047
|
-
// the old base-URL substitution keys is actively mischarging the org's
|
|
1048
|
-
// API balance right now, and leaving that in place is strictly worse
|
|
1049
|
-
// than falling back to no Troxy visibility for terminal at all.
|
|
1028
|
+
async function useDirectAnthropic(configPath = claudeCodeSettingsPath()) {
|
|
1050
1029
|
const migrated = migrateBaseUrlProxyToInterception(configPath);
|
|
1051
1030
|
if (migrated) {
|
|
1052
1031
|
console.log(' • Removed the old Troxy proxy setup for terminal (was billing your org\'s API key instead of your subscription)');
|
|
1053
1032
|
}
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
if (
|
|
1061
|
-
|
|
1062
|
-
console.log('
|
|
1063
|
-
return;
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
let enable = proxyOptIn === true;
|
|
1067
|
-
if (proxyOptIn === null && process.stdin.isTTY) {
|
|
1068
|
-
console.log("\n Turn on the Troxy engine? Claude Code's requests go through Troxy and");
|
|
1069
|
-
console.log(' are made lighter, so your usage goes further. Your normal Claude login');
|
|
1070
|
-
console.log(' (subscription or API key) still');
|
|
1071
|
-
console.log(' pays - Troxy never substitutes its own credential. Troxy does see your');
|
|
1072
|
-
console.log(' real credential in transit, forwarded upstream to actually make the');
|
|
1073
|
-
console.log(' call to Anthropic on your behalf.');
|
|
1074
|
-
console.log(' How: Troxy generates a certificate authority ON THIS MACHINE');
|
|
1075
|
-
console.log(' (~/.troxy/tls/). The private key never leaves this computer and is');
|
|
1076
|
-
console.log(' never sent to Troxy. It is locked to api.anthropic.com only and is');
|
|
1077
|
-
console.log(' NOT installed in your system keychain, only referenced by Claude Code.');
|
|
1078
|
-
if (hasDesktop) {
|
|
1079
|
-
console.log(' This also covers the Claude desktop app\'s Code tab (same setting).');
|
|
1080
|
-
}
|
|
1081
|
-
console.log(' Remove any time with: troxy proxy disable');
|
|
1082
|
-
const answer = await prompt(' Enable? (y/N): ');
|
|
1083
|
-
enable = /^y(es)?$/i.test(answer);
|
|
1084
|
-
}
|
|
1085
|
-
if (!enable) return;
|
|
1086
|
-
|
|
1087
|
-
try {
|
|
1088
|
-
const troxyDir = path.join(os.homedir(), '.troxy');
|
|
1089
|
-
ensureInterceptionCerts(troxyDir, { hostname: os.hostname(), leafDnsNames: interceptHostsFor(enabledProviders()) });
|
|
1090
|
-
patchClaudeCodeInterception(configPath);
|
|
1091
|
-
console.log(` • Claude Code model proxy (cost optimization) ✓`);
|
|
1092
|
-
const bound = await probePort(INTERCEPTOR_PORT);
|
|
1093
|
-
if (!bound) {
|
|
1094
|
-
console.log(` ⚠ Nothing is listening on 127.0.0.1:${INTERCEPTOR_PORT} yet - restart the`);
|
|
1095
|
-
console.log(' background service (`troxy restart`) so Claude Code can connect.');
|
|
1096
|
-
}
|
|
1097
|
-
} catch (err) {
|
|
1098
|
-
console.log(` • Claude Code model proxy ✗ (${err.message})`);
|
|
1033
|
+
// A Claude subscription's login token must never leave this machine: Anthropic's
|
|
1034
|
+
// Claude Code terms forbid a third party from intermediating it. So Claude Code
|
|
1035
|
+
// talks to Anthropic directly; the troxy-guard mod does the routing and reports
|
|
1036
|
+
// request metadata (reportUsage) for the dashboard. Older installs sent traffic
|
|
1037
|
+
// through the local interceptor to Troxy's proxy: undo that here, on init and on
|
|
1038
|
+
// every `troxy update`. API-key users can still opt in: `troxy proxy enable`.
|
|
1039
|
+
if (interceptionIsConfigured(configPath)) {
|
|
1040
|
+
unpatchClaudeCodeInterception(configPath);
|
|
1041
|
+
console.log(' • Claude Code now talks to Anthropic directly, your Claude login stays on this machine ✓');
|
|
1099
1042
|
}
|
|
1043
|
+
saveConfig({ ...(loadConfig() || {}), reportUsage: true });
|
|
1100
1044
|
}
|
|
1101
1045
|
|
|
1102
1046
|
// Detects Claude Code the same way the daemon detects OpenClaw: by whether
|
package/src/interceptor.js
CHANGED
|
@@ -361,6 +361,14 @@ async function _handleInterceptedRequest(req, res, { routeResolver, getTroxyKey,
|
|
|
361
361
|
}
|
|
362
362
|
|
|
363
363
|
const route = routeResolver(targetHost, req.method, req.url);
|
|
364
|
+
// A Claude subscription login (Authorization header) never goes to Troxy's
|
|
365
|
+
// proxy: Anthropic's terms forbid a third party from intermediating it. Only
|
|
366
|
+
// API-key requests (x-api-key) are relayed; everything else goes straight to
|
|
367
|
+
// Anthropic, and the troxy-guard mod reports its metadata instead.
|
|
368
|
+
if (route && req.headers.authorization) {
|
|
369
|
+
_relayToRealHost(targetHost, targetPort, req, res, body, log, lookup, ca);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
364
372
|
if (route) {
|
|
365
373
|
const troxyKey = getTroxyKey();
|
|
366
374
|
if (troxyKey) {
|
|
@@ -192,25 +192,79 @@ function savingsText(s) {
|
|
|
192
192
|
|
|
193
193
|
// This session's savings from the server, or null when there is no key, no session id or
|
|
194
194
|
// the call fails (the band then keeps what it showed).
|
|
195
|
+
// The Troxy key and setup from ~/.troxy/config.json (TROXY_API_KEY wins for the key).
|
|
196
|
+
async function troxyConfig($) {
|
|
197
|
+
let cfg = {};
|
|
198
|
+
try {
|
|
199
|
+
const home = await $.env.get('HOME');
|
|
200
|
+
if (home) cfg = JSON.parse(await $.fs.read(`${home}/.troxy/config.json`)) || {};
|
|
201
|
+
} catch {}
|
|
202
|
+
return { ...cfg, apiKey: (await $.env.get('TROXY_API_KEY')) || cfg.apiKey };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function troxyFetch($, url, init = {}) {
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
208
|
+
try { return await $.http.fetch(url, { ...init, signal: controller.signal }); }
|
|
209
|
+
finally { clearTimeout(timer); }
|
|
210
|
+
}
|
|
211
|
+
|
|
195
212
|
async function fetchSavings($) {
|
|
196
213
|
const sid = await $.session.id();
|
|
197
214
|
if (!/^[A-Za-z0-9-]{8,64}$/.test(String(sid))) return null;
|
|
198
|
-
|
|
199
|
-
if (!
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
215
|
+
const { apiKey } = await troxyConfig($);
|
|
216
|
+
if (!apiKey) return null;
|
|
217
|
+
const res = await troxyFetch($, `https://api.troxy.io/mcp/session-savings?session_id=${sid}`, { headers: { 'x-troxy-key': apiKey } });
|
|
218
|
+
return res.ok ? JSON.parse(res.text) : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Usage reporting. A Claude subscription's login token must never leave the Mac
|
|
222
|
+
// (Anthropic's Claude Code terms), so in that setup Claude Code talks to Anthropic
|
|
223
|
+
// directly and the mod reports each request's metadata instead: model, token counts,
|
|
224
|
+
// usage-bar readings, routing arm. Never prompt or response text. Only when
|
|
225
|
+
// `troxy init` set reportUsage: through the proxy, the proxy already logs each call.
|
|
226
|
+
const REPORT_URL = 'https://api.troxy.io/mcp/usage-report';
|
|
227
|
+
const REPORT_MAX_ROWS = 200; // the endpoint's cap per report
|
|
228
|
+
const REPORT_KEEP_ROWS = 1000; // kept for the next try while Troxy is unreachable
|
|
229
|
+
let pending = [];
|
|
230
|
+
let compacting = false;
|
|
231
|
+
|
|
232
|
+
function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits }) {
|
|
233
|
+
const rl = {};
|
|
234
|
+
for (const w of rateLimits || []) {
|
|
235
|
+
if (!WINDOW_LABEL[w.kind]) continue;
|
|
236
|
+
const at = typeof w.resetsAt === 'number' ? new Date(w.resetsAt).toISOString() : w.resetsAt;
|
|
237
|
+
rl[w.kind] = { percent_used: w.percentUsed, resets_at: at };
|
|
203
238
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
239
|
+
return {
|
|
240
|
+
session_id: String(sid), model: usage.model, model_requested: requested || usage.model,
|
|
241
|
+
arm: arm || null, switch: arm ? (cold ? 'cold' : 'warm') : null, score: score == null ? null : score,
|
|
242
|
+
compact: Boolean(compact), input_tokens: usage.input_tokens, output_tokens: usage.output_tokens,
|
|
243
|
+
cache_read_tokens: usage.cache_read_input_tokens, cache_creation_tokens: usage.cache_creation_input_tokens,
|
|
244
|
+
rate_limits: rl,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function noteStep($, result, meta) {
|
|
249
|
+
const usage = result && result.usage;
|
|
250
|
+
if (!usage || !usage.model) return;
|
|
251
|
+
let rateLimits = [];
|
|
252
|
+
try { rateLimits = (await $.session.usage()).rateLimits; } catch {}
|
|
253
|
+
pending.push(reportRow({ ...meta, sid: await $.session.id(), usage, compact: compacting, rateLimits }));
|
|
254
|
+
if (pending.length > REPORT_KEEP_ROWS) pending = pending.slice(-REPORT_KEEP_ROWS);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function flushReport($) {
|
|
258
|
+
if (!pending.length) return;
|
|
259
|
+
const { apiKey, reportUsage } = await troxyConfig($);
|
|
260
|
+
if (!reportUsage || !apiKey) { pending = []; return; }
|
|
261
|
+
while (pending.length) {
|
|
262
|
+
const rows = pending.slice(0, REPORT_MAX_ROWS);
|
|
263
|
+
const res = await troxyFetch($, REPORT_URL, {
|
|
264
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'x-troxy-key': apiKey }, body: JSON.stringify({ rows }),
|
|
210
265
|
});
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
clearTimeout(timer);
|
|
266
|
+
if (!res.ok && res.status >= 500) return; // kept for the next turn
|
|
267
|
+
pending = pending.slice(rows.length); // sent, or refused as invalid: never resent
|
|
214
268
|
}
|
|
215
269
|
}
|
|
216
270
|
|
|
@@ -339,8 +393,9 @@ async function idleCompact($, { late = false } = {}) {
|
|
|
339
393
|
const file = modRouteFile($.plugin.root, await $.session.id());
|
|
340
394
|
if (file) { lastNote = null; await $.fs.write(file, JSON.stringify({ compact: true, ts: await $.clock.now() })); }
|
|
341
395
|
let r;
|
|
396
|
+
compacting = true;
|
|
342
397
|
try { r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS }); }
|
|
343
|
-
finally { if (file) await $.fs.write(file, '{}'); }
|
|
398
|
+
finally { compacting = false; if (file) await $.fs.write(file, '{}'); }
|
|
344
399
|
if (r.skip) return;
|
|
345
400
|
const k = n => `${Math.round(n / 1000)}k`;
|
|
346
401
|
await $.state.set(COMPACTED, { at: await $.clock.now(), text: r.tokensBefore && r.tokensAfter
|
|
@@ -451,7 +506,11 @@ function stepModel({ arm, requested, switched, cold, score, warm }) {
|
|
|
451
506
|
|
|
452
507
|
async function* routeStep($, e, next) {
|
|
453
508
|
// Main loop only: a subagent keeps whatever model it was given.
|
|
454
|
-
if (e.agentId)
|
|
509
|
+
if (e.agentId) {
|
|
510
|
+
const result = yield* next(e);
|
|
511
|
+
try { await noteStep($, result, { requested: e.model, arm: await $.store.get(`arm:${await $.session.id()}`), cold: false }); } catch {}
|
|
512
|
+
return result;
|
|
513
|
+
}
|
|
455
514
|
const sid = await $.session.id();
|
|
456
515
|
const now = await $.clock.now();
|
|
457
516
|
// The arm is kept on disk per session: $.state is per process, so every `claude -p` step,
|
|
@@ -487,7 +546,9 @@ async function* routeStep($, e, next) {
|
|
|
487
546
|
}
|
|
488
547
|
try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
|
|
489
548
|
if (model !== want && turn) turn.ratio = PRICE[tierOf(want)] / PRICE[tierOf(model)];
|
|
490
|
-
|
|
549
|
+
const result = yield* next(model === e.model ? e : { ...e, model });
|
|
550
|
+
try { await noteStep($, result, { requested: want, arm, cold: isCold, score: decision ? decision.score : null }); } catch {} // a missed row only thins the dashboard
|
|
551
|
+
return result;
|
|
491
552
|
}
|
|
492
553
|
|
|
493
554
|
export function register(on) {
|
|
@@ -536,6 +597,7 @@ export function register(on) {
|
|
|
536
597
|
// After the answer, so counting never delays a prompt.
|
|
537
598
|
on('turn.complete', async ($, e, next) => {
|
|
538
599
|
const r = await next(e);
|
|
600
|
+
try { await flushReport($); } catch {} // unsent rows wait for the next turn
|
|
539
601
|
let savings = null;
|
|
540
602
|
try {
|
|
541
603
|
savings = await fetchSavings($);
|
|
@@ -561,4 +623,4 @@ export function register(on) {
|
|
|
561
623
|
}
|
|
562
624
|
|
|
563
625
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
564
|
-
export const _internal = { askedFor, rememberAsked, asked, armCompact, cancelCompact, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, withKept, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel, lastSent };
|
|
626
|
+
export const _internal = { reportRow, noteStep, flushReport, pendingRows: () => pending, troxyConfig, askedFor, rememberAsked, asked, armCompact, cancelCompact, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, withKept, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel, lastSent };
|
package/src/proxy.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { ensureInterceptionCerts, certIsValid, certExpiresWithin } from './tls-ca.js';
|
|
23
23
|
import { enabledProviders, interceptHostsFor } from './providers.js';
|
|
24
24
|
import { INTERCEPTOR_PORT } from './daemon.js';
|
|
25
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
25
26
|
|
|
26
27
|
const TROXY_DIR = path.join(os.homedir(), '.troxy');
|
|
27
28
|
|
|
@@ -115,9 +116,10 @@ export async function runProxyEnable(flags = {}) {
|
|
|
115
116
|
any other site - and it is never installed in your system keychain, only
|
|
116
117
|
referenced by Claude Code itself.
|
|
117
118
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
For Claude API keys only. A Claude subscription login (Pro, Max) is never
|
|
120
|
+
sent to Troxy: Anthropic's terms forbid it, so those requests always go
|
|
121
|
+
straight to Anthropic, and \`troxy init\` sets that up by default. With a
|
|
122
|
+
subscription, leave this off. --experimental is required as an explicit
|
|
121
123
|
acknowledgment.
|
|
122
124
|
|
|
123
125
|
Remove any time with: troxy proxy disable
|
|
@@ -157,6 +159,8 @@ export async function runProxyEnable(flags = {}) {
|
|
|
157
159
|
process.stdout.write(' Writing ~/.claude/settings.json... ');
|
|
158
160
|
try {
|
|
159
161
|
patchClaudeCodeInterception(settingsPath);
|
|
162
|
+
// The proxy logs these calls itself; the mod reporting them too would count each twice.
|
|
163
|
+
saveConfig({ ...(loadConfig() || {}), reportUsage: false });
|
|
160
164
|
console.log('✓');
|
|
161
165
|
} catch (err) {
|
|
162
166
|
console.log('✗');
|
|
@@ -190,6 +194,7 @@ export function runProxyDisable() {
|
|
|
190
194
|
process.stdout.write('\n Removing HTTPS_PROXY / NODE_EXTRA_CA_CERTS from settings.json... ');
|
|
191
195
|
try {
|
|
192
196
|
unpatchClaudeCodeInterception(settingsPath);
|
|
197
|
+
saveConfig({ ...(loadConfig() || {}), reportUsage: true }); // the mod reports again
|
|
193
198
|
console.log('✓\n');
|
|
194
199
|
} catch (err) {
|
|
195
200
|
console.log('✗');
|
|
@@ -383,7 +383,7 @@ describe('createInterceptor - integration, real sockets', () => {
|
|
|
383
383
|
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
384
384
|
servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
385
385
|
method: 'POST', path: '/v1/messages?beta=true',
|
|
386
|
-
headers: {
|
|
386
|
+
headers: { 'x-api-key': 'sk-ant-api03-realkey', 'content-type': 'application/json' },
|
|
387
387
|
body: '{"model":"claude-sonnet-5"}',
|
|
388
388
|
});
|
|
389
389
|
|
|
@@ -398,7 +398,7 @@ describe('createInterceptor - integration, real sockets', () => {
|
|
|
398
398
|
// ran" without checking the dashboard for a decision_source row.
|
|
399
399
|
`expected a "routed ... to troxy" confirmation log line, got: ${JSON.stringify(logLines)}`,
|
|
400
400
|
);
|
|
401
|
-
assert.equal(troxyRequests[0].headers['x-
|
|
401
|
+
assert.equal(troxyRequests[0].headers['x-api-key'], 'sk-ant-api03-realkey');
|
|
402
402
|
assert.equal(troxyRequests[0].body, '{"model":"claude-sonnet-5"}');
|
|
403
403
|
});
|
|
404
404
|
|
|
@@ -440,6 +440,47 @@ describe('createInterceptor - integration, real sockets', () => {
|
|
|
440
440
|
'the client\'s own credential must reach the real host unmodified');
|
|
441
441
|
});
|
|
442
442
|
|
|
443
|
+
it('a Claude subscription login (Authorization) on a routed path never reaches Troxy - ' +
|
|
444
|
+
'it goes straight to the real host, untouched (Anthropic terms: no third party ' +
|
|
445
|
+
'may intermediate a subscription token)', async () => {
|
|
446
|
+
const troxyHits = [];
|
|
447
|
+
const troxyServer = await startStubHttpServer((req, res) => { troxyHits.push(req.url); res.end('{}'); });
|
|
448
|
+
openServers.push(troxyServer);
|
|
449
|
+
const ca = generateCA('test-host');
|
|
450
|
+
const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
|
|
451
|
+
|
|
452
|
+
const realRequests = [];
|
|
453
|
+
const realServer = await startStubTlsServer(leaf.certPem, leaf.keyPem, (req, res) => {
|
|
454
|
+
realRequests.push({ url: req.url, headers: req.headers });
|
|
455
|
+
res.writeHead(200, {});
|
|
456
|
+
res.end('real-anthropic-response');
|
|
457
|
+
});
|
|
458
|
+
openServers.push(realServer);
|
|
459
|
+
|
|
460
|
+
const interceptor = createInterceptor({
|
|
461
|
+
interceptHosts: ['api.anthropic.com'],
|
|
462
|
+
routeResolver: () => ({ upstream: `http://127.0.0.1:${troxyServer.address().port}/v1/messages` }),
|
|
463
|
+
getTroxyKey: () => 'txy-test-key',
|
|
464
|
+
getCerts: () => ({ leafCertPem: leaf.certPem, leafKeyPem: leaf.keyPem }),
|
|
465
|
+
lookup: LOOPBACK_LOOKUP,
|
|
466
|
+
ca: [ca.certPem], // the interceptor's OWN outbound trust for reaching the stub "real host"
|
|
467
|
+
});
|
|
468
|
+
await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
|
|
469
|
+
openServers.push(interceptor);
|
|
470
|
+
|
|
471
|
+
const rawSocket = await connectThroughProxy(interceptor.address().port, 'api.anthropic.com', realServer.address().port);
|
|
472
|
+
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
473
|
+
servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
474
|
+
method: 'GET', path: '/api/oauth/account/settings',
|
|
475
|
+
headers: { authorization: 'Bearer sk-ant-oat01-realtoken' },
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
assert.equal(result.statusCode, 200);
|
|
479
|
+
assert.equal(result.body, 'real-anthropic-response');
|
|
480
|
+
assert.equal(realRequests[0].headers.authorization, 'Bearer sk-ant-oat01-realtoken',
|
|
481
|
+
'the client\'s own credential must reach the real host unmodified');
|
|
482
|
+
assert.equal(troxyHits.length, 0, 'a subscription login must never be sent to Troxy');
|
|
483
|
+
});
|
|
443
484
|
it('a host NOT on the intercept allowlist is a blind tunnel - the client\'s TLS ' +
|
|
444
485
|
'terminates at the REAL destination\'s own certificate, never at Troxy\'s', async () => {
|
|
445
486
|
// A completely unrelated CA/cert pair, standing in for a real site the
|
|
@@ -512,7 +553,7 @@ describe('createInterceptor - integration, real sockets', () => {
|
|
|
512
553
|
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
513
554
|
servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
514
555
|
method: 'POST', path: '/v1/messages',
|
|
515
|
-
headers: {
|
|
556
|
+
headers: { 'x-api-key': 'sk-ant-api03-realkey' }, body: '{}',
|
|
516
557
|
});
|
|
517
558
|
|
|
518
559
|
assert.equal(result.statusCode, 200);
|
|
@@ -553,7 +594,7 @@ describe('createInterceptor - integration, real sockets', () => {
|
|
|
553
594
|
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
554
595
|
servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
555
596
|
method: 'POST', path: '/v1/messages',
|
|
556
|
-
headers: {
|
|
597
|
+
headers: { 'x-api-key': 'sk-ant-api03-realkey' }, body: '{}',
|
|
557
598
|
});
|
|
558
599
|
|
|
559
600
|
assert.equal(result.statusCode, 200);
|
|
@@ -609,7 +650,7 @@ describe('createInterceptor - integration, real sockets', () => {
|
|
|
609
650
|
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
610
651
|
servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
611
652
|
method: 'POST', path: '/v1/messages',
|
|
612
|
-
headers: {
|
|
653
|
+
headers: { 'x-api-key': 'sk-ant-api03-x' }, body: '{}',
|
|
613
654
|
});
|
|
614
655
|
assert.equal(result.statusCode, 200);
|
|
615
656
|
}
|
|
@@ -670,7 +711,7 @@ describe('createInterceptor - integration, real sockets', () => {
|
|
|
670
711
|
const result = await httpsOverConnectedSocket(rawSocket, {
|
|
671
712
|
servername: 'api.anthropic.com', ca: [ca.certPem],
|
|
672
713
|
method: 'POST', path: '/v1/messages',
|
|
673
|
-
headers: {
|
|
714
|
+
headers: { 'x-api-key': 'sk-ant-api03-realkey' }, body: '{}',
|
|
674
715
|
});
|
|
675
716
|
|
|
676
717
|
assert.equal(result.statusCode, 200);
|
|
@@ -346,3 +346,59 @@ test('askedFor: a resumed session that reopens on the routed model is mapped bac
|
|
|
346
346
|
assert.equal(await askedFor($, 's1', sonnet, true), sonnet); // the user's own /model wins
|
|
347
347
|
assert.equal(await askedFor($, 's2', haiku, false), haiku); // never routed: Haiku is the pick
|
|
348
348
|
});
|
|
349
|
+
|
|
350
|
+
test('usage report: each request is queued with model, tokens, bar and arm, then sent at turn end', async () => {
|
|
351
|
+
const { MODELS: M, routeStep, scorePrompt, flushReport, pendingRows } = _internal;
|
|
352
|
+
const posts = [];
|
|
353
|
+
const $ = { ...routingApi(),
|
|
354
|
+
session: { id: async () => 'report-sess-1', usage: async () => ({ context: { tokens: 1000 },
|
|
355
|
+
rateLimits: [{ kind: 'five_hour', percentUsed: 42, resetsAt: '2026-10-20T10:00:00Z' }, { kind: 'other', percentUsed: 1 }] }) },
|
|
356
|
+
env: { get: async (k) => (k === 'HOME' ? '/home/x' : undefined) },
|
|
357
|
+
fs: { write: async () => {}, read: async () => JSON.stringify({ apiKey: 'txy-test', reportUsage: true }) },
|
|
358
|
+
http: { fetch: async (url, init) => { posts.push({ url, init }); return { ok: true, status: 200 }; } } };
|
|
359
|
+
await scorePrompt($, { text: 'reply only ok', origin: { kind: 'composer' } }, async (e) => e);
|
|
360
|
+
const usage = { model: M.sonnet, input_tokens: 5, output_tokens: 7, cache_read_input_tokens: 900, cache_creation_input_tokens: 30 };
|
|
361
|
+
const gen = routeStep($, { model: M.opus }, async function* () { return { usage }; });
|
|
362
|
+
let step; do { step = await gen.next(); } while (!step.done);
|
|
363
|
+
assert.deepEqual(step.value, { usage }); // the result is passed on unchanged
|
|
364
|
+
const row = pendingRows().at(-1);
|
|
365
|
+
assert.equal(row.model, M.sonnet); assert.equal(row.model_requested, M.opus);
|
|
366
|
+
assert.equal(row.arm, 'treated'); assert.equal(row.switch, 'cold'); assert.equal(row.cache_read_tokens, 900);
|
|
367
|
+
assert.deepEqual(row.rate_limits, { five_hour: { percent_used: 42, resets_at: '2026-10-20T10:00:00Z' } });
|
|
368
|
+
assert.equal(JSON.stringify(row).includes('reply only ok'), false); // never prompt text
|
|
369
|
+
await flushReport($);
|
|
370
|
+
assert.equal(posts.length, 1);
|
|
371
|
+
assert.equal(posts[0].url, 'https://api.troxy.io/mcp/usage-report');
|
|
372
|
+
assert.equal(posts[0].init.headers['x-troxy-key'], 'txy-test');
|
|
373
|
+
assert.equal(JSON.parse(posts[0].init.body).rows.at(-1).session_id, 'report-sess-1');
|
|
374
|
+
assert.equal(pendingRows().length, 0);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('usage report: nothing is sent unless init turned reporting on (the proxy logs those calls)', async () => {
|
|
378
|
+
const { noteStep, flushReport, pendingRows } = _internal;
|
|
379
|
+
const posts = [];
|
|
380
|
+
const $ = { ...routingApi(),
|
|
381
|
+
env: { get: async () => '/home/x' },
|
|
382
|
+
fs: { read: async () => JSON.stringify({ apiKey: 'txy-test' }) },
|
|
383
|
+
http: { fetch: async () => { posts.push(1); return { ok: true }; } } };
|
|
384
|
+
await noteStep($, { usage: { model: 'claude-sonnet-5-5', input_tokens: 1 } }, { requested: 'claude-sonnet-5-5' });
|
|
385
|
+
assert.equal(pendingRows().length, 1);
|
|
386
|
+
await flushReport($);
|
|
387
|
+
assert.equal(posts.length, 0);
|
|
388
|
+
assert.equal(pendingRows().length, 0);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('usage report: a server error keeps the rows for the next turn', async () => {
|
|
392
|
+
const { noteStep, flushReport, pendingRows } = _internal;
|
|
393
|
+
let status = 503;
|
|
394
|
+
const $ = { ...routingApi(),
|
|
395
|
+
env: { get: async () => '/home/x' },
|
|
396
|
+
fs: { read: async () => JSON.stringify({ apiKey: 'txy-test', reportUsage: true }) },
|
|
397
|
+
http: { fetch: async () => ({ ok: status < 400, status }) } };
|
|
398
|
+
await noteStep($, { usage: { model: 'claude-sonnet-5-5', input_tokens: 1 } }, {});
|
|
399
|
+
await flushReport($);
|
|
400
|
+
assert.equal(pendingRows().length, 1);
|
|
401
|
+
status = 200;
|
|
402
|
+
await flushReport($);
|
|
403
|
+
assert.equal(pendingRows().length, 0);
|
|
404
|
+
});
|
|
@@ -58,23 +58,22 @@ describe('reprovisionKeyConsumers: desktop detection gate', () => {
|
|
|
58
58
|
assert.equal(_guardCondition('patchClaudeCodeConfig(claudeCodeConfigPath(), key)'), 'hasClaude');
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
it('calls
|
|
62
|
-
assert.equal(_guardCondition('await
|
|
61
|
+
it('calls useDirectAnthropic (Claude Code talks to Anthropic directly) gated on hasClaude alone', () => {
|
|
62
|
+
assert.equal(_guardCondition('await useDirectAnthropic();'), 'hasClaude');
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
it('no longer calls maybeEnableClaudeCodeProxy from reprovisionKeyConsumers - terminal fully switched to interception', () => {
|
|
66
66
|
assert.ok(!initSrc.includes('await maybeEnableClaudeCodeProxy'));
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
it('
|
|
70
|
-
const fnStart = initSrc.indexOf('async function
|
|
71
|
-
assert.ok(fnStart !== -1, '
|
|
72
|
-
const fnBody = initSrc.slice(fnStart, fnStart
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
assert.ok(
|
|
76
|
-
assert.ok(
|
|
77
|
-
assert.ok(migrateIdx < optInIdx, 'migration must run unconditionally, before the enable/proxyOptIn decision');
|
|
69
|
+
it('useDirectAnthropic migrates old setups, removes interception and turns mod reporting on - never adds interception', () => {
|
|
70
|
+
const fnStart = initSrc.indexOf('async function useDirectAnthropic');
|
|
71
|
+
assert.ok(fnStart !== -1, 'useDirectAnthropic not found');
|
|
72
|
+
const fnBody = initSrc.slice(fnStart, initSrc.indexOf('\n}\n', fnStart));
|
|
73
|
+
assert.ok(fnBody.includes('migrateBaseUrlProxyToInterception('));
|
|
74
|
+
assert.ok(fnBody.includes('unpatchClaudeCodeInterception(configPath)'));
|
|
75
|
+
assert.ok(fnBody.includes('reportUsage: true'));
|
|
76
|
+
assert.ok(!/[^n]patchClaudeCodeInterception\(/.test(fnBody), 'must never route Claude Code through the interceptor');
|
|
78
77
|
});
|
|
79
78
|
});
|
|
80
79
|
|