troxy-cli 1.29.2 → 1.29.4
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/README.md +6 -0
- package/bin/troxy.js +13 -0
- package/package.json +3 -2
- package/src/auth.js +81 -33
- package/src/daemon.js +116 -3
- package/src/init.js +278 -26
- package/src/interceptor.js +402 -0
- package/src/providers.js +84 -0
- package/src/proxy.js +184 -0
- package/src/tests/auth.test.js +12 -1
- package/src/tests/claude-code-hook.test.js +96 -1
- package/src/tests/claude-code-proxy.test.js +120 -1
- package/src/tests/daemon.test.js +85 -0
- package/src/tests/init-detection.test.js +95 -0
- package/src/tests/install-service.test.js +69 -0
- package/src/tests/interception-config.test.js +203 -0
- package/src/tests/interceptor.test.js +559 -0
- package/src/tests/providers.test.js +78 -0
- package/src/tests/proxy-wiring.test.js +118 -0
- package/src/tests/tls-ca.test.js +222 -0
- package/src/tls-ca.js +208 -0
- package/src/uninstall.js +32 -4
package/README.md
CHANGED
|
@@ -94,6 +94,12 @@ prediction, and the dashboard marks that agent's figures as unverified.
|
|
|
94
94
|
- Paste the code into the terminal
|
|
95
95
|
- Stores the JWT locally for 12 hours
|
|
96
96
|
|
|
97
|
+
Without a real terminal attached (a script, CI, an agent's own shell), the
|
|
98
|
+
masked prompt is skipped in favor of a plain line read from stdin - it never
|
|
99
|
+
crashes for lack of a TTY. There's no flag to pass the code directly: the
|
|
100
|
+
code the browser shows is only valid against this run's own session, so it
|
|
101
|
+
has to come back through this same process, e.g. `troxy login < code.txt`.
|
|
102
|
+
|
|
97
103
|
## Stack
|
|
98
104
|
|
|
99
105
|
- Node.js 18+ (ESM)
|
package/bin/troxy.js
CHANGED
|
@@ -115,6 +115,19 @@ switch (command) {
|
|
|
115
115
|
await runUninstall();
|
|
116
116
|
break;
|
|
117
117
|
|
|
118
|
+
// ── Live Model Policy Enforcement interception (Layer 3, experimental) ─
|
|
119
|
+
// Not yet part of `troxy init` or the default help banner below - see
|
|
120
|
+
// proxy.js's own top-of-file note and the plan doc's rollout order for
|
|
121
|
+
// why this is deliberately opt-in-only and manually run for now.
|
|
122
|
+
case 'proxy': {
|
|
123
|
+
const { runProxyStatus, runProxyEnable, runProxyDisable } = await import('../src/proxy.js');
|
|
124
|
+
if (sub === 'enable') { await runProxyEnable(flags); break; }
|
|
125
|
+
if (sub === 'disable') { runProxyDisable(); break; }
|
|
126
|
+
if (sub === 'status' || !sub) { await runProxyStatus(); break; }
|
|
127
|
+
console.error(` Unknown: troxy proxy ${sub}. Try: status, enable, disable\n`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
118
131
|
// ── Auth ──────────────────────────────────────────────────────
|
|
119
132
|
case 'login':
|
|
120
133
|
await runLogin(flags);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.4",
|
|
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": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"test": "node --test src/tests/*.test.js"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@modelcontextprotocol/sdk": "^1.10.2"
|
|
17
|
+
"@modelcontextprotocol/sdk": "^1.10.2",
|
|
18
|
+
"node-forge": "^1.4.0"
|
|
18
19
|
},
|
|
19
20
|
"engines": {
|
|
20
21
|
"node": ">=18"
|
package/src/auth.js
CHANGED
|
@@ -95,6 +95,44 @@ function _openBrowser(url) {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// Which strategy resolves the login code, given whether stdin is a TTY.
|
|
99
|
+
// Exported so this decision is unit-testable without a real terminal: the
|
|
100
|
+
// raw-mode branch itself needs a real TTY and can't be, but which branch
|
|
101
|
+
// gets picked is exactly the logic that used to crash with no TTY at all
|
|
102
|
+
// (setRawMode is not a function on a pipe/non-interactive stdin - found live
|
|
103
|
+
// by another Claude instance running in a non-interactive shell). No --code
|
|
104
|
+
// flag: the code the browser shows is minted for, and only valid against,
|
|
105
|
+
// this specific run's own session_id (auth.py's handle_cli_authorize binds
|
|
106
|
+
// cli_code to session_id server-side, 5-minute TTL) - a flag read before a
|
|
107
|
+
// fresh cliStart() would always redeem a stale session and 401.
|
|
108
|
+
export function _codeInputStrategy(isTTY) {
|
|
109
|
+
return isTTY ? 'raw-tty' : 'line-read';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A plain, non-masked line read for the no-TTY fallback - deliberately not
|
|
113
|
+
// init.js's shared prompt(), which leaves its Promise permanently unresolved
|
|
114
|
+
// if the input stream closes (EOF) before an answer, a real Node readline
|
|
115
|
+
// quirk: init.js's own callers are all isTTY-guarded already, so a live human
|
|
116
|
+
// terminal never hits it, but a no-TTY `troxy login` (a pipe with nothing
|
|
117
|
+
// written to it, or none at all) hits EOF immediately and is exactly this
|
|
118
|
+
// fallback's whole reason to exist - it must resolve either way, so the
|
|
119
|
+
// existing "No code entered" handling below still runs instead of the
|
|
120
|
+
// process just going quiet with no explanation and exit code 0.
|
|
121
|
+
function _readCodeLine(question) {
|
|
122
|
+
return new Promise(resolve => {
|
|
123
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
124
|
+
let answered = false;
|
|
125
|
+
rl.question(question, ans => {
|
|
126
|
+
answered = true;
|
|
127
|
+
rl.close();
|
|
128
|
+
resolve(ans.trim());
|
|
129
|
+
});
|
|
130
|
+
rl.on('close', () => {
|
|
131
|
+
if (!answered) resolve('');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
98
136
|
/** Device-code login flow — opens browser, user copies code back to CLI. */
|
|
99
137
|
export async function runLogin() {
|
|
100
138
|
// 1. Start a CLI auth session
|
|
@@ -118,41 +156,51 @@ export async function runLogin() {
|
|
|
118
156
|
_openBrowser(session.url);
|
|
119
157
|
}
|
|
120
158
|
|
|
121
|
-
// 3.
|
|
122
|
-
// one bullet per char so the terminal doesn't look
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
process.stdout.write('\
|
|
159
|
+
// 3. Resolve the code shown in the browser. A real TTY gets the masked
|
|
160
|
+
// raw-mode prompt (one bullet per char so the terminal doesn't look
|
|
161
|
+
// frozen); anything else (a script, CI, an agent's own shell) falls
|
|
162
|
+
// back to a plain line read (_readCodeLine below) - process.stdin.
|
|
163
|
+
// setRawMode is not a function at all without a real TTY, so it must
|
|
164
|
+
// never run unguarded (it used to, and crashed uncaught).
|
|
165
|
+
const strategy = _codeInputStrategy(process.stdin.isTTY);
|
|
166
|
+
let code;
|
|
167
|
+
if (strategy === 'line-read') {
|
|
168
|
+
code = await _readCodeLine(' Paste the code from your browser: ');
|
|
169
|
+
} else {
|
|
170
|
+
code = await new Promise(resolve => {
|
|
171
|
+
const rl = readline.createInterface({ input: process.stdin, output: null });
|
|
172
|
+
process.stdout.write(' Paste the code from your browser: ');
|
|
173
|
+
let buf = '';
|
|
174
|
+
process.stdin.setRawMode(true);
|
|
175
|
+
process.stdin.resume();
|
|
176
|
+
process.stdin.setEncoding('utf8');
|
|
177
|
+
const onData = chunk => {
|
|
178
|
+
for (const ch of chunk) {
|
|
179
|
+
if (ch === '\r' || ch === '\n') {
|
|
180
|
+
process.stdin.setRawMode(false);
|
|
181
|
+
process.stdin.pause();
|
|
182
|
+
process.stdin.removeListener('data', onData);
|
|
183
|
+
rl.close();
|
|
184
|
+
process.stdout.write('\n');
|
|
185
|
+
resolve(buf.trim());
|
|
186
|
+
return;
|
|
187
|
+
} else if (ch === '\u0003') { // Ctrl-C
|
|
188
|
+
process.stdout.write('\n');
|
|
189
|
+
process.exit(0);
|
|
190
|
+
} else if (ch === '\u007f' || ch === '\b') { // backspace
|
|
191
|
+
if (buf.length > 0) {
|
|
192
|
+
buf = buf.slice(0, -1);
|
|
193
|
+
process.stdout.write('\b \b');
|
|
194
|
+
}
|
|
195
|
+
} else if (ch >= ' ') {
|
|
196
|
+
buf += ch;
|
|
197
|
+
process.stdout.write('•');
|
|
147
198
|
}
|
|
148
|
-
} else if (ch >= ' ') {
|
|
149
|
-
buf += ch;
|
|
150
|
-
process.stdout.write('•');
|
|
151
199
|
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
200
|
+
};
|
|
201
|
+
process.stdin.on('data', onData);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
156
204
|
|
|
157
205
|
if (!code) {
|
|
158
206
|
console.error('\n No code entered. Run troxy login to try again.\n');
|
package/src/daemon.js
CHANGED
|
@@ -1,16 +1,125 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Troxy
|
|
2
|
+
* Troxy background daemon.
|
|
3
3
|
* Runs as a background service (systemd / launchd). Sends a heartbeat to the
|
|
4
4
|
* Troxy API every 60 seconds so the dashboard shows this agent as connected.
|
|
5
|
-
* Does NOT start an MCP stdio server
|
|
6
|
-
* (Claude Desktop, Cursor, etc.) is present.
|
|
5
|
+
* Does NOT start an MCP stdio server - that only makes sense when an MCP
|
|
6
|
+
* client (Claude Desktop, Cursor, etc.) is present.
|
|
7
|
+
*
|
|
8
|
+
* As of Layer 2 of the Live Model Policy Enforcement plan, also hosts the
|
|
9
|
+
* local interceptor (interceptor.js) - the TLS-terminating proxy that lets
|
|
10
|
+
* the Claude Desktop app's Code tab route through Troxy the same way
|
|
11
|
+
* terminal `claude` already does via ANTHROPIC_BASE_URL. This lives here,
|
|
12
|
+
* not as a separate process, specifically so it inherits the supervision
|
|
13
|
+
* this daemon already has (launchd KeepAlive / systemd Restart=always) -
|
|
14
|
+
* see the plan doc's architecture section for why a fully local, always-
|
|
15
|
+
* supervised interceptor is the only design that can actually fail open
|
|
16
|
+
* when Troxy's own service has downtime.
|
|
7
17
|
*/
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { format } from 'node:util';
|
|
8
21
|
import { loadConfig } from './config.js';
|
|
9
22
|
import { api } from './api.js';
|
|
23
|
+
import { createInterceptor } from './interceptor.js';
|
|
24
|
+
import { enabledProviders, interceptHostsFor, troxyRouteFor } from './providers.js';
|
|
25
|
+
import { ensureInterceptionCerts } from './tls-ca.js';
|
|
10
26
|
|
|
11
27
|
const INTERVAL_MS = 60_000;
|
|
12
28
|
|
|
29
|
+
// Fixed, not dynamically chosen - so settings.json's HTTPS_PROXY value
|
|
30
|
+
// stays valid across daemon restarts instead of needing to be rewritten
|
|
31
|
+
// every time. If this port is ever unavailable, the interceptor simply
|
|
32
|
+
// does not start (see startInterceptor's listen 'error' handler) rather
|
|
33
|
+
// than silently picking a different one and leaving settings.json wrong.
|
|
34
|
+
export const INTERCEPTOR_PORT = 48173;
|
|
35
|
+
|
|
36
|
+
// Re-checked once a day, not on a timer that matters much - ensureInterceptionCerts
|
|
37
|
+
// is idempotent when the existing CA/leaf are still healthy, so this is a
|
|
38
|
+
// cheap no-op almost every time it fires; it only does real work on the
|
|
39
|
+
// leaf's ~30-day renewal window or if something on disk got corrupted.
|
|
40
|
+
const CERT_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
function _log(fmt, ...args) {
|
|
43
|
+
process.stderr.write(`[troxy-interceptor] ${format(fmt, ...args)}\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Starts the local interceptor, if any provider is enabled (Phase 1: just
|
|
48
|
+
* 'anthropic'). Never throws - a failure here (bad cert generation, the
|
|
49
|
+
* port already being held by something else) is logged and the daemon
|
|
50
|
+
* keeps running its heartbeat regardless; interception is additive, never
|
|
51
|
+
* a requirement for the rest of what this daemon does.
|
|
52
|
+
*
|
|
53
|
+
* `troxyDir` defaults to the real ~/.troxy but is injectable, matching
|
|
54
|
+
* ensureInterceptionCerts's own pattern - lets a test exercise this
|
|
55
|
+
* without touching whatever the real machine running the test has there.
|
|
56
|
+
* `port` defaults to INTERCEPTOR_PORT, injectable so a test doesn't
|
|
57
|
+
* collide with a real daemon that might already be running on this
|
|
58
|
+
* machine.
|
|
59
|
+
*/
|
|
60
|
+
export function startInterceptor({ troxyDir = path.join(os.homedir(), '.troxy'), port = INTERCEPTOR_PORT } = {}) {
|
|
61
|
+
const providers = enabledProviders();
|
|
62
|
+
const interceptHosts = interceptHostsFor(providers);
|
|
63
|
+
if (interceptHosts.length === 0) return null;
|
|
64
|
+
|
|
65
|
+
let certs = null;
|
|
66
|
+
|
|
67
|
+
function refreshCerts() {
|
|
68
|
+
try {
|
|
69
|
+
const result = ensureInterceptionCerts(troxyDir, {
|
|
70
|
+
hostname: os.hostname(),
|
|
71
|
+
leafDnsNames: interceptHosts,
|
|
72
|
+
});
|
|
73
|
+
certs = { leafCertPem: result.leafCertPem, leafKeyPem: result.leafKeyPem };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
_log('failed to generate interception certificates, tunneling only until this resolves: %s', err.message);
|
|
76
|
+
certs = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
refreshCerts();
|
|
80
|
+
const refreshTimer = setInterval(refreshCerts, CERT_REFRESH_INTERVAL_MS);
|
|
81
|
+
refreshTimer.unref(); // must not keep the process alive on its own
|
|
82
|
+
|
|
83
|
+
const interceptor = createInterceptor({
|
|
84
|
+
interceptHosts,
|
|
85
|
+
routeResolver: (host, method, reqPath) => troxyRouteFor(providers, host, method, reqPath),
|
|
86
|
+
// Fresh from disk on every request, not cached at startup - a
|
|
87
|
+
// `troxy rotate-key` takes effect immediately, no daemon restart needed.
|
|
88
|
+
getTroxyKey: () => loadConfig()?.apiKey || null,
|
|
89
|
+
getCerts: () => certs,
|
|
90
|
+
log: _log,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
interceptor.on('error', (err) => {
|
|
94
|
+
if (err.code === 'EADDRINUSE') {
|
|
95
|
+
_log('port %d is already in use by something else - interception is disabled this run. ' +
|
|
96
|
+
'Run `troxy proxy status` to check, or `troxy proxy disable` if this persists.', port);
|
|
97
|
+
} else {
|
|
98
|
+
_log('server error (interception disabled this run): %s', err.message);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
interceptor.listen(port, '127.0.0.1', () => {
|
|
103
|
+
_log('listening on 127.0.0.1:%d for %s', port, interceptHosts.join(', '));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return interceptor;
|
|
107
|
+
}
|
|
108
|
+
|
|
13
109
|
export async function runDaemon() {
|
|
110
|
+
// Installed first, before anything else in this process can throw - a
|
|
111
|
+
// bug anywhere here (the heartbeat, cert generation, a single bad TLS
|
|
112
|
+
// handshake) must never take the whole daemon down. The interceptor's
|
|
113
|
+
// listener dying silently is exactly what broke Claude Code outright
|
|
114
|
+
// during this feature's own manual testing - log and keep running,
|
|
115
|
+
// never exit.
|
|
116
|
+
process.on('uncaughtException', (err) => {
|
|
117
|
+
process.stderr.write(`[troxy-daemon] uncaught exception (continuing): ${err.stack || err.message}\n`);
|
|
118
|
+
});
|
|
119
|
+
process.on('unhandledRejection', (err) => {
|
|
120
|
+
process.stderr.write(`[troxy-daemon] unhandled rejection (continuing): ${err?.stack || err}\n`);
|
|
121
|
+
});
|
|
122
|
+
|
|
14
123
|
const cfg = loadConfig();
|
|
15
124
|
if (!cfg?.apiKey) {
|
|
16
125
|
process.stderr.write('[troxy-daemon] No API key found. Run troxy init first.\n');
|
|
@@ -19,6 +128,10 @@ export async function runDaemon() {
|
|
|
19
128
|
|
|
20
129
|
const { apiKey, agentName } = cfg;
|
|
21
130
|
|
|
131
|
+
// Bound before the first heartbeat - a network hiccup reaching the Troxy
|
|
132
|
+
// API on startup must never leave the interceptor's port unbound.
|
|
133
|
+
startInterceptor();
|
|
134
|
+
|
|
22
135
|
// `force` pushes the config name as the authoritative display name. We only
|
|
23
136
|
// do this on the FIRST beat after (re)start, so a `troxy restart` overrides
|
|
24
137
|
// the dashboard, while regular beats never clobber a dashboard rename.
|