troxy-cli 1.8.3 → 1.8.5
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/bin/troxy.js +20 -26
- package/package.json +1 -1
- package/src/account.js +2 -2
- package/src/api.js +1 -7
- package/src/approvals.js +1 -2
- package/src/auth.js +24 -7
- package/src/init.js +65 -58
- package/src/mcp-server.js +70 -1
- package/src/tests/auth.test.js +27 -0
- package/src/cards.js +0 -107
package/bin/troxy.js
CHANGED
|
@@ -8,7 +8,6 @@ import { loadConfig } from '../src/config.js';
|
|
|
8
8
|
import { runPolicies } from '../src/policies.js';
|
|
9
9
|
import { runMcps } from '../src/mcps.js';
|
|
10
10
|
import { runActivity } from '../src/activity.js';
|
|
11
|
-
import { runCards } from '../src/cards.js';
|
|
12
11
|
import { runSettings } from '../src/settings.js';
|
|
13
12
|
import { runChatBudget } from '../src/chat-budget.js';
|
|
14
13
|
import { runApprovals } from '../src/approvals.js';
|
|
@@ -107,13 +106,15 @@ switch (command) {
|
|
|
107
106
|
troxy rotate-key
|
|
108
107
|
troxy rotate-key --name "My Agent Key"
|
|
109
108
|
|
|
110
|
-
|
|
109
|
+
Automatically re-points MCP clients and the background service at the new key.
|
|
111
110
|
`);
|
|
112
111
|
process.exit(0);
|
|
113
112
|
}
|
|
114
113
|
const jwt = requireJwt();
|
|
115
114
|
const { loadConfig, saveConfig } = await import('../src/config.js');
|
|
116
|
-
const
|
|
115
|
+
const { reprovisionKeyConsumers } = await import('../src/init.js');
|
|
116
|
+
const existing = loadConfig() || {};
|
|
117
|
+
const oldKey = existing.apiKey;
|
|
117
118
|
const oldPrefix = oldKey ? oldKey.substring(0, 11) : null;
|
|
118
119
|
const name = flags.name || null; // keep existing name if not specified
|
|
119
120
|
|
|
@@ -123,11 +124,18 @@ switch (command) {
|
|
|
123
124
|
const newPrefix = result.prefix;
|
|
124
125
|
console.log('✓');
|
|
125
126
|
|
|
126
|
-
saveConfig({
|
|
127
|
+
// Merge, don't overwrite — saveConfig({apiKey}) alone used to drop agentName/notify.
|
|
128
|
+
saveConfig({ ...existing, apiKey: newKey });
|
|
127
129
|
console.log(' Saved to ~/.troxy/config.json ✓');
|
|
128
130
|
|
|
131
|
+
// Re-point every real consumer (MCP client configs, the background service) at the new
|
|
132
|
+
// key BEFORE revoking the old one — otherwise every consumer, which holds the key via
|
|
133
|
+
// ENV rather than config.json, keeps presenting the now-revoked key and payment control
|
|
134
|
+
// goes down until the user thinks to re-run init. (H6)
|
|
135
|
+
await reprovisionKeyConsumers(newKey, existing.agentName || 'my-agent');
|
|
136
|
+
|
|
129
137
|
if (oldPrefix) {
|
|
130
|
-
process.stdout.write(
|
|
138
|
+
process.stdout.write(`\n Revoking old key ${oldPrefix}... `);
|
|
131
139
|
const { tokens = [] } = await api.listTokens(jwt);
|
|
132
140
|
const old = tokens.find(t => t.prefix === oldPrefix);
|
|
133
141
|
if (old) { await api.revokeToken(jwt, old.id); console.log('✓'); }
|
|
@@ -142,8 +150,7 @@ switch (command) {
|
|
|
142
150
|
New key (shown once, save it now):
|
|
143
151
|
${newKey}
|
|
144
152
|
|
|
145
|
-
~/.troxy/config.json updated ✓
|
|
146
|
-
Run troxy restart to apply the new key.
|
|
153
|
+
~/.troxy/config.json and all connected MCP clients / the background service updated ✓
|
|
147
154
|
`);
|
|
148
155
|
break;
|
|
149
156
|
}
|
|
@@ -187,11 +194,10 @@ switch (command) {
|
|
|
187
194
|
--amount <n> Payment amount in USD
|
|
188
195
|
|
|
189
196
|
Optional:
|
|
190
|
-
--card <alias> Card alias (default: "Work")
|
|
191
197
|
--category <cat> Merchant category (e.g. travel, software, food)
|
|
192
198
|
--no-confirm Don't auto-confirm, leaves the row PENDING
|
|
193
199
|
--fail Confirm as a failed charge instead of success
|
|
194
|
-
(simulates a declined
|
|
200
|
+
(simulates a declined charge)
|
|
195
201
|
|
|
196
202
|
Examples:
|
|
197
203
|
troxy pay --merchant "Amazon" --amount 50
|
|
@@ -205,14 +211,13 @@ switch (command) {
|
|
|
205
211
|
if (!apiKey) { console.error(' No API key. Run: troxy init --key txy-...\n'); process.exit(1); }
|
|
206
212
|
const merchant = flags.merchant;
|
|
207
213
|
const amount = parseFloat(flags.amount);
|
|
208
|
-
const card = flags.card || 'Work';
|
|
209
214
|
const category = flags.category;
|
|
210
215
|
const noConfirm = !!flags['no-confirm'];
|
|
211
216
|
const failCharge = !!flags.fail;
|
|
212
217
|
if (!merchant) { console.error(' --merchant is required\n'); process.exit(1); }
|
|
213
218
|
if (isNaN(amount)){ console.error(' --amount is required\n'); process.exit(1); }
|
|
214
219
|
const agentName = loadConfig()?.agentName || 'troxy-cli';
|
|
215
|
-
const body = {
|
|
220
|
+
const body = { merchant_name: merchant, amount, agent: agentName, source: 'troxy-cli' };
|
|
216
221
|
if (category) body.merchant_category = category;
|
|
217
222
|
const result = await api.evaluate(body, apiKey);
|
|
218
223
|
const ICON = { ALLOW: '✓', BLOCK: '✗', ESCALATE: '⏳', NOTIFY: '~' };
|
|
@@ -232,7 +237,7 @@ switch (command) {
|
|
|
232
237
|
await api.confirmPayment(result.audit_id, {
|
|
233
238
|
status,
|
|
234
239
|
provider: 'troxy-cli',
|
|
235
|
-
...(failCharge ? { reason: 'simulated
|
|
240
|
+
...(failCharge ? { reason: 'simulated declined charge (--fail)' } : {}),
|
|
236
241
|
}, apiKey);
|
|
237
242
|
console.log(` confirmed: charge ${status}`);
|
|
238
243
|
} catch (e) {
|
|
@@ -256,10 +261,6 @@ switch (command) {
|
|
|
256
261
|
await runActivity(flags);
|
|
257
262
|
break;
|
|
258
263
|
|
|
259
|
-
case 'cards':
|
|
260
|
-
await runCards(positional, flags);
|
|
261
|
-
break;
|
|
262
|
-
|
|
263
264
|
case 'settings':
|
|
264
265
|
await runSettings(positional, flags);
|
|
265
266
|
break;
|
|
@@ -328,10 +329,9 @@ switch (command) {
|
|
|
328
329
|
if (!sub || sub === 'policies') { await runPolicies(['list'], flags); break; }
|
|
329
330
|
if (sub === 'mcps') { await runMcps(['list'], flags); break; }
|
|
330
331
|
if (sub === 'activity') { await runActivity(flags); break; }
|
|
331
|
-
if (sub === 'cards') { await runCards(['list'], flags); break; }
|
|
332
332
|
if (sub === 'approvals') { await runApprovals(['list'], flags); break; }
|
|
333
333
|
if (sub === 'secrets') { await runSecrets(['list'], flags); break; }
|
|
334
|
-
console.error(` Unknown resource: ${sub}. Try: policies, mcps, activity,
|
|
334
|
+
console.error(` Unknown resource: ${sub}. Try: policies, mcps, activity, approvals, secrets\n`);
|
|
335
335
|
process.exit(1);
|
|
336
336
|
|
|
337
337
|
// ── Status ────────────────────────────────────────────────────
|
|
@@ -543,12 +543,6 @@ switch (command) {
|
|
|
543
543
|
troxy policies resume --name "X"
|
|
544
544
|
troxy policies delete --name "X"
|
|
545
545
|
|
|
546
|
-
Cards
|
|
547
|
-
troxy cards list
|
|
548
|
-
troxy cards create --name "Work" --last4 1234 --budget 2000
|
|
549
|
-
troxy cards update --name "Work" --budget 3000
|
|
550
|
-
troxy cards delete --name "Work"
|
|
551
|
-
|
|
552
546
|
Approvals (ESCALATE holds)
|
|
553
547
|
troxy approvals list
|
|
554
548
|
troxy approvals approve --id <id>
|
|
@@ -574,8 +568,8 @@ switch (command) {
|
|
|
574
568
|
troxy insights [--period 7]
|
|
575
569
|
|
|
576
570
|
Simulate
|
|
577
|
-
troxy pay --merchant "Amazon" --amount 50
|
|
578
|
-
troxy pay --merchant "Google" --amount 300 --
|
|
571
|
+
troxy pay --merchant "Amazon" --amount 50
|
|
572
|
+
troxy pay --merchant "Google" --amount 300 --category software
|
|
579
573
|
`);
|
|
580
574
|
process.exit(command ? 1 : 0);
|
|
581
575
|
}
|
package/package.json
CHANGED
package/src/account.js
CHANGED
|
@@ -8,7 +8,7 @@ function prompt(question) {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
const HELP = {
|
|
11
|
-
delete: ` troxy account delete\n\n PERMANENTLY deletes your Troxy account: all activity, policies
|
|
11
|
+
delete: ` troxy account delete\n\n PERMANENTLY deletes your Troxy account: all activity, policies,\n API keys, and the account itself. This cannot be undone. You'll be asked\n to type your account email to confirm.\n`,
|
|
12
12
|
'clear-data': ` troxy account clear-data\n\n Deletes all activity, policies, and pending approvals. Keeps your account\n and API keys intact, so your MCP connections keep working.\n`,
|
|
13
13
|
};
|
|
14
14
|
|
|
@@ -24,7 +24,7 @@ export async function runAccount([sub, ...args], flags) {
|
|
|
24
24
|
case 'delete': {
|
|
25
25
|
const s = await api.getSettings(jwt);
|
|
26
26
|
console.log(`\n This PERMANENTLY deletes your Troxy account (${s.email}):`);
|
|
27
|
-
console.log(' all activity, policies,
|
|
27
|
+
console.log(' all activity, policies, and API keys. This cannot be undone.\n');
|
|
28
28
|
const typed = await prompt(` Type your account email to confirm: `);
|
|
29
29
|
if (typed.trim().toLowerCase() !== s.email.toLowerCase()) {
|
|
30
30
|
console.log('\n Email did not match. Cancelled.\n');
|
package/src/api.js
CHANGED
|
@@ -31,12 +31,6 @@ export const api = {
|
|
|
31
31
|
cliAuthorize: (jwt, session_id) => request('POST', '/auth/cli/authorize', { jwt, body: { session_id } }),
|
|
32
32
|
cliExchange: (session_id, code) => request('POST', '/auth/cli/exchange', { body: { session_id, code } }),
|
|
33
33
|
|
|
34
|
-
// Cards
|
|
35
|
-
listCards: (jwt) => request('GET', '/cards', { jwt }),
|
|
36
|
-
createCard: (jwt, b) => request('POST', '/cards', { jwt, body: b }),
|
|
37
|
-
updateCard: (jwt, id, b) => request('PUT', `/cards/${id}`, { jwt, body: b }),
|
|
38
|
-
deleteCard: (jwt, id) => request('DELETE', `/cards/${id}`, { jwt }),
|
|
39
|
-
|
|
40
34
|
// Policies
|
|
41
35
|
listPolicies: (jwt) => request('GET', '/dashboard/policies', { jwt }),
|
|
42
36
|
createPolicy: (jwt, b) => request('POST', '/dashboard/policies', { jwt, body: b }),
|
|
@@ -77,6 +71,7 @@ export const api = {
|
|
|
77
71
|
|
|
78
72
|
// Evaluate + confirm (agent API key)
|
|
79
73
|
evaluate: (body, apiKey) => request('POST', '/evaluate', { apiKey, body }),
|
|
74
|
+
evaluateEmail: (body, apiKey) => request('POST', '/evaluate/email', { apiKey, body }),
|
|
80
75
|
confirmPayment: (auditId, body, apiKey) => request('POST', `/payments/${auditId}/confirm`, { apiKey, body }),
|
|
81
76
|
waitApprovalStatus: (token) => request('GET', `/approvals/${encodeURIComponent(token)}/wait`),
|
|
82
77
|
|
|
@@ -97,7 +92,6 @@ export const api = {
|
|
|
97
92
|
agentStatus: (jwt, tokenPrefix) => request('GET', `/agent/status${tokenPrefix ? `?token_prefix=${encodeURIComponent(tokenPrefix)}` : ''}`, { jwt }),
|
|
98
93
|
agentPolicies: (jwt, tokenPrefix) => request('GET', `/agent/policies${tokenPrefix ? `?token_prefix=${encodeURIComponent(tokenPrefix)}` : ''}`, { jwt }),
|
|
99
94
|
agentMcps: (jwt, tokenPrefix) => request('GET', `/agent/mcps${tokenPrefix ? `?token_prefix=${encodeURIComponent(tokenPrefix)}` : ''}`, { jwt }),
|
|
100
|
-
agentCards: (jwt) => request('GET', '/agent/cards', { jwt }),
|
|
101
95
|
agentActivity: (jwt, limit, mine, tokenPrefix) => request('GET', `/agent/activity?limit=${limit || 20}${mine ? `&mine=true&token_prefix=${encodeURIComponent(tokenPrefix || '')}` : ''}`, { jwt }),
|
|
102
96
|
agentInsights: (jwt, period) => request('GET', `/agent/insights?period=${period || 30}`, { jwt }),
|
|
103
97
|
};
|
package/src/approvals.js
CHANGED
|
@@ -31,13 +31,12 @@ export async function runApprovals([sub, ...args], flags) {
|
|
|
31
31
|
if (!approvals.length) { console.log('\n No pending approvals.\n'); return; }
|
|
32
32
|
console.log();
|
|
33
33
|
table(
|
|
34
|
-
['ID', 'Merchant', 'Amount', 'Agent', '
|
|
34
|
+
['ID', 'Merchant', 'Amount', 'Agent', 'Expires'],
|
|
35
35
|
approvals.map(a => [
|
|
36
36
|
a.id.slice(0, 8),
|
|
37
37
|
a.merchant_name,
|
|
38
38
|
`$${Number(a.amount).toFixed(2)} ${a.currency}`,
|
|
39
39
|
a.agent_name,
|
|
40
|
-
a.card_name,
|
|
41
40
|
new Date(a.expires_at).toLocaleString(),
|
|
42
41
|
]),
|
|
43
42
|
);
|
package/src/auth.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import readline from 'readline';
|
|
5
|
-
import {
|
|
5
|
+
import { execFile } from 'child_process';
|
|
6
6
|
import { api } from './api.js';
|
|
7
7
|
|
|
8
8
|
const SESSION_FILE = path.join(os.homedir(), '.troxy', 'session.json');
|
|
@@ -16,8 +16,13 @@ export function loadSession() {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function saveSession(data) {
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// The session file holds a 12h full-authority JWT — lock it down like the API-key
|
|
20
|
+
// config (0600), not the default ~0644. writeFileSync's mode is ignored when the file
|
|
21
|
+
// already exists, so chmod explicitly after writing. (H5)
|
|
22
|
+
const dir = path.dirname(SESSION_FILE);
|
|
23
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
24
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
25
|
+
try { fs.chmodSync(SESSION_FILE, 0o600); } catch {}
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export function clearSession() {
|
|
@@ -67,15 +72,27 @@ function loadConfig() {
|
|
|
67
72
|
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
// The login URL comes from the (unauthenticated) api.cliStart response, so treat it as
|
|
76
|
+
// untrusted. Only https URLs are openable; even so we pass the URL as an argv element via
|
|
77
|
+
// execFile (no shell) below, so metacharacters can never reach a shell. Exported for tests.
|
|
78
|
+
export function isOpenableUrl(url) {
|
|
79
|
+
return typeof url === 'string' && /^https:\/\//i.test(url);
|
|
80
|
+
}
|
|
81
|
+
|
|
70
82
|
function _openBrowser(url) {
|
|
71
83
|
const isHeadless = process.platform === 'linux'
|
|
72
84
|
&& !process.env.DISPLAY
|
|
73
85
|
&& !process.env.WAYLAND_DISPLAY;
|
|
74
86
|
if (isHeadless) return;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
87
|
+
if (!isOpenableUrl(url)) return; // (H4)
|
|
88
|
+
if (process.platform === 'darwin') {
|
|
89
|
+
execFile('open', [url], () => {});
|
|
90
|
+
} else if (process.platform === 'win32') {
|
|
91
|
+
// rundll32 opens a URL with no shell parsing of the argument.
|
|
92
|
+
execFile('rundll32', ['url.dll,FileProtocolHandler', url], () => {});
|
|
93
|
+
} else {
|
|
94
|
+
execFile('xdg-open', [url], () => {});
|
|
95
|
+
}
|
|
79
96
|
}
|
|
80
97
|
|
|
81
98
|
/** Device-code login flow — opens browser, user copies code back to CLI. */
|
package/src/init.js
CHANGED
|
@@ -6,6 +6,67 @@ import { execSync, execFileSync } from 'child_process';
|
|
|
6
6
|
import { saveConfig } from './config.js';
|
|
7
7
|
import { evaluatePayment, api } from './api.js';
|
|
8
8
|
|
|
9
|
+
// Re-point every consumer of the API key (MCP client configs, the background service /
|
|
10
|
+
// systemd env file / launchd plist, OpenClaw) at `key`. Shared by runInit and rotate-key
|
|
11
|
+
// (H6): rotate-key used to write the new key only to config.json and then revoke the old
|
|
12
|
+
// one immediately, so every real consumer — which holds the key via ENV, not config.json —
|
|
13
|
+
// kept presenting the now-revoked key until the user manually re-ran init. Non-interactive
|
|
14
|
+
// (no prompts), so it's safe to call from rotate-key with the existing agent name.
|
|
15
|
+
export async function reprovisionKeyConsumers(key, agentName) {
|
|
16
|
+
const platform = process.platform;
|
|
17
|
+
const detected = MCP_CLIENTS.filter(c => {
|
|
18
|
+
const p = c.path[platform] ?? c.path.linux;
|
|
19
|
+
return fs.existsSync(p);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
let hasOpenClaw = false;
|
|
23
|
+
try {
|
|
24
|
+
execSync('openclaw --version', { stdio: 'ignore' });
|
|
25
|
+
hasOpenClaw = true;
|
|
26
|
+
} catch {}
|
|
27
|
+
|
|
28
|
+
if (detected.length === 0 && !hasOpenClaw) {
|
|
29
|
+
console.log('\n No MCP clients detected (Claude Desktop, Cursor, Windsurf, OpenClaw).');
|
|
30
|
+
console.log(' Troxy MCP server config:\n');
|
|
31
|
+
console.log(JSON.stringify(mcpEntry(key), null, 4));
|
|
32
|
+
console.log('\n Add the above to your MCP client\'s config under "mcpServers".\n');
|
|
33
|
+
} else {
|
|
34
|
+
console.log('\n MCP clients found:');
|
|
35
|
+
for (const client of detected) {
|
|
36
|
+
const configPath = client.path[platform] ?? client.path.linux;
|
|
37
|
+
try {
|
|
38
|
+
if (client.type === 'zed') patchZedConfig(configPath, key);
|
|
39
|
+
else if (client.type === 'continue') patchContinueConfig(configPath, key);
|
|
40
|
+
else patchMcpConfig(configPath, key);
|
|
41
|
+
console.log(` • ${client.name} ✓`);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.log(` • ${client.name} ✗ (${err.message})`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (hasOpenClaw) {
|
|
47
|
+
try {
|
|
48
|
+
const entry = JSON.stringify({ command: 'npx', args: ['troxy-cli', 'mcp'], env: { TROXY_API_KEY: key } });
|
|
49
|
+
// execFileSync passes args directly to the process (no shell), so the
|
|
50
|
+
// key can't break out of a quoted string and inject shell commands.
|
|
51
|
+
execFileSync('openclaw', ['mcp', 'set', 'troxy', entry], { stdio: 'ignore' });
|
|
52
|
+
console.log(` • OpenClaw ✓`);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.log(` • OpenClaw ✗ (${err.message})`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
console.log('\n Restart your MCP client to activate Troxy.');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log('\n Setting up background service...');
|
|
61
|
+
try {
|
|
62
|
+
installService(key, agentName);
|
|
63
|
+
console.log(' Background service installed ✓');
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.log(` Background service ✗ (${err.message})`);
|
|
66
|
+
console.log(' You can start it manually with: troxy daemon &');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
9
70
|
function prompt(question) {
|
|
10
71
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
11
72
|
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
|
|
@@ -69,11 +130,11 @@ export async function runInit({ key } = {}) {
|
|
|
69
130
|
|
|
70
131
|
console.log('\n Troxy: AI payment control\n');
|
|
71
132
|
|
|
72
|
-
// Validate the key by hitting /evaluate (404
|
|
133
|
+
// Validate the key by hitting /evaluate (404 = key is valid, TypeError = network failure)
|
|
73
134
|
process.stdout.write(' Validating API key... ');
|
|
74
135
|
try {
|
|
75
136
|
await evaluatePayment(
|
|
76
|
-
{ agent: 'troxy-init',
|
|
137
|
+
{ agent: 'troxy-init', amount: 0 },
|
|
77
138
|
key,
|
|
78
139
|
);
|
|
79
140
|
console.log('✓');
|
|
@@ -90,7 +151,7 @@ export async function runInit({ key } = {}) {
|
|
|
90
151
|
console.error('\n Error: Invalid or revoked API key.\n');
|
|
91
152
|
process.exit(1);
|
|
92
153
|
}
|
|
93
|
-
// 404
|
|
154
|
+
// 404 or any other API error = key is valid, API is up
|
|
94
155
|
console.log('✓');
|
|
95
156
|
}
|
|
96
157
|
|
|
@@ -112,61 +173,7 @@ export async function runInit({ key } = {}) {
|
|
|
112
173
|
// Non-fatal: the daemon's own heartbeat will retry this later.
|
|
113
174
|
}
|
|
114
175
|
|
|
115
|
-
|
|
116
|
-
const platform = process.platform;
|
|
117
|
-
const detected = MCP_CLIENTS.filter(c => {
|
|
118
|
-
const p = c.path[platform] ?? c.path.linux;
|
|
119
|
-
return fs.existsSync(p);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// Detect OpenClaw via CLI
|
|
123
|
-
let hasOpenClaw = false;
|
|
124
|
-
try {
|
|
125
|
-
execSync('openclaw --version', { stdio: 'ignore' });
|
|
126
|
-
hasOpenClaw = true;
|
|
127
|
-
} catch {}
|
|
128
|
-
|
|
129
|
-
if (detected.length === 0 && !hasOpenClaw) {
|
|
130
|
-
console.log('\n No MCP clients detected (Claude Desktop, Cursor, Windsurf, OpenClaw).');
|
|
131
|
-
console.log(' Troxy MCP server config:\n');
|
|
132
|
-
console.log(JSON.stringify(mcpEntry(key), null, 4));
|
|
133
|
-
console.log('\n Add the above to your MCP client\'s config under "mcpServers".\n');
|
|
134
|
-
} else {
|
|
135
|
-
console.log('\n MCP clients found:');
|
|
136
|
-
for (const client of detected) {
|
|
137
|
-
const configPath = client.path[platform] ?? client.path.linux;
|
|
138
|
-
try {
|
|
139
|
-
if (client.type === 'zed') patchZedConfig(configPath, key);
|
|
140
|
-
else if (client.type === 'continue') patchContinueConfig(configPath, key);
|
|
141
|
-
else patchMcpConfig(configPath, key);
|
|
142
|
-
console.log(` • ${client.name} ✓`);
|
|
143
|
-
} catch (err) {
|
|
144
|
-
console.log(` • ${client.name} ✗ (${err.message})`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
if (hasOpenClaw) {
|
|
148
|
-
try {
|
|
149
|
-
const entry = JSON.stringify({ command: 'npx', args: ['troxy-cli', 'mcp'], env: { TROXY_API_KEY: key } });
|
|
150
|
-
// execFileSync passes args directly to the process (no shell), so the
|
|
151
|
-
// key can't break out of a quoted string and inject shell commands.
|
|
152
|
-
execFileSync('openclaw', ['mcp', 'set', 'troxy', entry], { stdio: 'ignore' });
|
|
153
|
-
console.log(` • OpenClaw ✓`);
|
|
154
|
-
} catch (err) {
|
|
155
|
-
console.log(` • OpenClaw ✗ (${err.message})`);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
console.log('\n Restart your MCP client to activate Troxy.');
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Install background service so MCP server survives reboots
|
|
162
|
-
console.log('\n Setting up background service...');
|
|
163
|
-
try {
|
|
164
|
-
installService(key, agentName);
|
|
165
|
-
console.log(' Background service installed ✓');
|
|
166
|
-
} catch (err) {
|
|
167
|
-
console.log(` Background service ✗ (${err.message})`);
|
|
168
|
-
console.log(' You can start it manually with: troxy daemon &');
|
|
169
|
-
}
|
|
176
|
+
await reprovisionKeyConsumers(key, agentName);
|
|
170
177
|
|
|
171
178
|
console.log('\n Your payments are now protected.');
|
|
172
179
|
console.log(' Dashboard → https://dash.troxy.io');
|
package/src/mcp-server.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { loadConfig } from './config.js';
|
|
8
8
|
import { evaluatePayment, api } from './api.js';
|
|
9
9
|
const confirmPayment = (auditId, body, apiKey) => api.confirmPayment(auditId, body, apiKey);
|
|
10
|
+
const evaluateEmail = (body, apiKey) => api.evaluateEmail(body, apiKey);
|
|
10
11
|
|
|
11
12
|
export async function runMcp() {
|
|
12
13
|
const config = loadConfig();
|
|
@@ -38,7 +39,8 @@ export async function runMcp() {
|
|
|
38
39
|
'If the decision is ESCALATE, the response includes an approval_token. Immediately call wait_for_payment_approval with that token; it polls automatically until the payment is approved or declined (up to 10 minutes). Once it returns approved, call evaluate_payment again with the same payment details PLUS the approval_token field. The second call will return ALLOW without re-escalating. ' +
|
|
39
40
|
'If the decision is NOTIFY, proceed but the user will be notified. ' +
|
|
40
41
|
'When calling evaluate_payment, use the merchant\'s domain name as merchant_name when available (e.g. "amazon.com", "indigobloom.co.il"); this ensures consistent matching against policies. ' +
|
|
41
|
-
'After every ALLOW or NOTIFY decision, attempt the payment, then call confirm_payment with the audit_id and the outcome (success/failed/cancelled). Always include a reason when the charge fails.'
|
|
42
|
+
'After every ALLOW or NOTIFY decision, attempt the payment, then call confirm_payment with the audit_id and the outcome (success/failed/cancelled). Always include a reason when the charge fails. ' +
|
|
43
|
+
'You MUST also call the evaluate_email tool before sending any email on the user\'s behalf. Never send an email without first receiving an ALLOW or NOTIFY decision. If BLOCK, do not send it. If ESCALATE, call wait_for_payment_approval with the returned approval_token (same wait mechanism payments use), then call evaluate_email again with the approval_token once approved.',
|
|
42
44
|
},
|
|
43
45
|
{ capabilities: { tools: {} } },
|
|
44
46
|
);
|
|
@@ -133,6 +135,34 @@ export async function runMcp() {
|
|
|
133
135
|
},
|
|
134
136
|
},
|
|
135
137
|
},
|
|
138
|
+
{
|
|
139
|
+
name: 'evaluate_email',
|
|
140
|
+
description:
|
|
141
|
+
'Evaluate whether sending an email should be allowed, blocked, or escalated ' +
|
|
142
|
+
'based on your Troxy policies. Call this before sending any email on the user\'s behalf.',
|
|
143
|
+
inputSchema: {
|
|
144
|
+
type: 'object',
|
|
145
|
+
required: ['recipient'],
|
|
146
|
+
properties: {
|
|
147
|
+
recipient: {
|
|
148
|
+
type: 'string',
|
|
149
|
+
description: 'Email address of the recipient',
|
|
150
|
+
},
|
|
151
|
+
subject: {
|
|
152
|
+
type: 'string',
|
|
153
|
+
description: 'Subject line of the email (optional)',
|
|
154
|
+
},
|
|
155
|
+
agent: {
|
|
156
|
+
type: 'string',
|
|
157
|
+
description: 'Name of the agent sending the email (optional)',
|
|
158
|
+
},
|
|
159
|
+
approval_token: {
|
|
160
|
+
type: 'string',
|
|
161
|
+
description: 'Approval token from a previous ESCALATE response. Include this to proceed after the user has approved.',
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
136
166
|
],
|
|
137
167
|
}));
|
|
138
168
|
|
|
@@ -203,6 +233,45 @@ export async function runMcp() {
|
|
|
203
233
|
}
|
|
204
234
|
}
|
|
205
235
|
|
|
236
|
+
if (toolName === 'evaluate_email') {
|
|
237
|
+
if (agentName && !args.agent) args.agent = agentName;
|
|
238
|
+
let result;
|
|
239
|
+
try {
|
|
240
|
+
result = await evaluateEmail(args, apiKey);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return {
|
|
243
|
+
content: [{ type: 'text', text: `Troxy error: ${err.message}` }],
|
|
244
|
+
isError: true,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
if (result.error) {
|
|
248
|
+
return {
|
|
249
|
+
content: [{ type: 'text', text: `Troxy error: ${result.error}` }],
|
|
250
|
+
isError: true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const { decision, reason, audit_id, approval_token } = result;
|
|
254
|
+
let emailText;
|
|
255
|
+
switch (decision) {
|
|
256
|
+
case 'ALLOW':
|
|
257
|
+
case 'NOTIFY':
|
|
258
|
+
emailText = `✓ Email approved.${reason ? ` ${reason}` : ''} You may send it now. (audit: ${audit_id})`;
|
|
259
|
+
break;
|
|
260
|
+
case 'BLOCK':
|
|
261
|
+
emailText = `✗ Email blocked.${reason ? ` ${reason}` : ''} Do not send it. (audit: ${audit_id})`;
|
|
262
|
+
break;
|
|
263
|
+
case 'ESCALATE':
|
|
264
|
+
emailText = `⏳ Email requires human approval; a request has been sent to the account owner.\n\nApproval token: ${approval_token}\n\nNow call wait_for_payment_approval(approval_token="${approval_token}") to automatically detect approval, then call evaluate_email again with the same recipient/subject PLUS this approval_token. Do not send until it returns approved.`;
|
|
265
|
+
break;
|
|
266
|
+
default:
|
|
267
|
+
emailText = JSON.stringify(result);
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
content: [{ type: 'text', text: emailText }],
|
|
271
|
+
isError: decision === 'BLOCK',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
206
275
|
if (toolName !== 'evaluate_payment') {
|
|
207
276
|
throw new Error(`Unknown tool: ${toolName}`);
|
|
208
277
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
|
|
4
|
+
import { isOpenableUrl } from '../auth.js';
|
|
5
|
+
|
|
6
|
+
test('isOpenableUrl accepts plain https and rejects everything else (H4)', () => {
|
|
7
|
+
assert.equal(isOpenableUrl('https://dash.troxy.io/cli?code=abc'), true);
|
|
8
|
+
|
|
9
|
+
// Non-https schemes and non-strings are rejected outright, so a server-controlled
|
|
10
|
+
// login URL can never be handed to a shell. (Even an https URL is only ever passed as a
|
|
11
|
+
// single argv element to execFile, never through a shell.)
|
|
12
|
+
for (const bad of [
|
|
13
|
+
'http://x',
|
|
14
|
+
'file:///etc/passwd',
|
|
15
|
+
'javascript:alert(1)',
|
|
16
|
+
'$(curl evil.example)',
|
|
17
|
+
'`id`',
|
|
18
|
+
'; touch /tmp/pwned',
|
|
19
|
+
'ftp://x',
|
|
20
|
+
'',
|
|
21
|
+
null,
|
|
22
|
+
undefined,
|
|
23
|
+
42,
|
|
24
|
+
]) {
|
|
25
|
+
assert.equal(isOpenableUrl(bad), false, `should reject: ${String(bad)}`);
|
|
26
|
+
}
|
|
27
|
+
});
|
package/src/cards.js
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { api } from './api.js';
|
|
2
|
-
import { requireJwt } from './auth.js';
|
|
3
|
-
import { table } from './print.js';
|
|
4
|
-
|
|
5
|
-
const HELP = {
|
|
6
|
-
list: ` troxy cards list\n\n Lists all cards (budget/spend aliases) on your account.\n`,
|
|
7
|
-
create: ` troxy cards create --name <name> --last4 <4 digits> [options]\n\n Creates a new card alias. Login required.\n\n Required:\n --name Card alias name (e.g. "Work")\n --last4 Last 4 digits of the card\n\n Optional:\n --type Card type label (default: virtual)\n --budget <n> Monthly budget amount\n --reset-day <n> Day of month the budget resets (default: 1)\n --default-action ALLOW, BLOCK, ESCALATE, or NOTIFY (default: ALLOW)\n --notes <text> Free text notes\n\n Example:\n troxy cards create --name "Work" --last4 1234 --budget 2000\n`,
|
|
8
|
-
update: ` troxy cards update --name <name> [options]\n\n Updates an existing card. Login required.\n\n Options:\n --new-name <name> Rename the card\n --budget <n> Set the monthly budget\n --clear-budget Remove the monthly budget\n --reset-day <n> Day of month the budget resets\n --status <status> active or paused\n --default-action ALLOW, BLOCK, ESCALATE, or NOTIFY\n --notes <text> Free text notes\n\n Example:\n troxy cards update --name "Work" --budget 3000\n`,
|
|
9
|
-
delete: ` troxy cards delete --name <name>\n\n Permanently deletes a card. Login required.\n`,
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export async function runCards([sub, ...args], flags) {
|
|
13
|
-
if (flags.help || flags.h) {
|
|
14
|
-
console.log('\n' + (HELP[sub] || ` troxy cards <subcommand> [options]\n\n Subcommands:\n list List all cards\n create Create a new card\n update Update an existing card\n delete Delete a card\n\n Run 'troxy cards <subcommand> --help' for subcommand help.\n`));
|
|
15
|
-
process.exit(0);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const jwt = requireJwt();
|
|
19
|
-
|
|
20
|
-
switch (sub || 'list') {
|
|
21
|
-
case 'list': {
|
|
22
|
-
const { cards = [] } = await api.listCards(jwt);
|
|
23
|
-
if (!cards.length) { console.log('\n No cards yet.\n'); return; }
|
|
24
|
-
console.log();
|
|
25
|
-
table(
|
|
26
|
-
['Name', 'Last 4', 'Type', 'Budget', 'Used', 'Reset day', 'Status', 'Default action'],
|
|
27
|
-
cards.map(c => [
|
|
28
|
-
c.name,
|
|
29
|
-
c.last4,
|
|
30
|
-
c.type,
|
|
31
|
-
c.budget != null ? `$${Number(c.budget).toFixed(2)}` : '—',
|
|
32
|
-
c.budget != null ? `$${Number(c.budget_used).toFixed(2)}` : '—',
|
|
33
|
-
c.reset_day,
|
|
34
|
-
c.status,
|
|
35
|
-
c.default_action,
|
|
36
|
-
]),
|
|
37
|
-
);
|
|
38
|
-
break;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
case 'create': {
|
|
42
|
-
const name = flags.name;
|
|
43
|
-
const last4 = String(flags.last4 ?? '');
|
|
44
|
-
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
45
|
-
if (!/^\d{4}$/.test(last4)) { console.error(' --last4 must be exactly 4 digits\n'); process.exit(1); }
|
|
46
|
-
const action = flags['default-action'] ? flags['default-action'].toUpperCase() : undefined;
|
|
47
|
-
if (action && !['ALLOW', 'BLOCK', 'ESCALATE', 'NOTIFY'].includes(action)) {
|
|
48
|
-
console.error(' --default-action must be ALLOW, BLOCK, ESCALATE, or NOTIFY\n'); process.exit(1);
|
|
49
|
-
}
|
|
50
|
-
const body = {
|
|
51
|
-
name, last4,
|
|
52
|
-
type: flags.type || undefined,
|
|
53
|
-
budget: flags.budget != null ? parseFloat(flags.budget) : undefined,
|
|
54
|
-
reset_day: flags['reset-day'] != null ? parseInt(flags['reset-day'], 10) : undefined,
|
|
55
|
-
default_action: action,
|
|
56
|
-
notes: flags.notes || undefined,
|
|
57
|
-
};
|
|
58
|
-
const card = await api.createCard(jwt, body);
|
|
59
|
-
console.log(`\n Card "${card.name}" created ✓ (•••• ${card.last4})\n`);
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
case 'update': {
|
|
64
|
-
const name = flags.name;
|
|
65
|
-
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
66
|
-
const { cards = [] } = await api.listCards(jwt);
|
|
67
|
-
const card = cards.find(c => c.name.toLowerCase() === name.toLowerCase());
|
|
68
|
-
if (!card) { console.error(` Card "${name}" not found\n`); process.exit(1); }
|
|
69
|
-
|
|
70
|
-
const body = {};
|
|
71
|
-
if (flags['new-name']) body.name = flags['new-name'];
|
|
72
|
-
if (flags['clear-budget']) body.budget = null;
|
|
73
|
-
else if (flags.budget != null) body.budget = parseFloat(flags.budget);
|
|
74
|
-
if (flags['reset-day'] != null) body.reset_day = parseInt(flags['reset-day'], 10);
|
|
75
|
-
if (flags.status) body.status = flags.status;
|
|
76
|
-
if (flags['default-action']) {
|
|
77
|
-
const action = flags['default-action'].toUpperCase();
|
|
78
|
-
if (!['ALLOW', 'BLOCK', 'ESCALATE', 'NOTIFY'].includes(action)) {
|
|
79
|
-
console.error(' --default-action must be ALLOW, BLOCK, ESCALATE, or NOTIFY\n'); process.exit(1);
|
|
80
|
-
}
|
|
81
|
-
body.default_action = action;
|
|
82
|
-
}
|
|
83
|
-
if (flags.notes != null) body.notes = flags.notes;
|
|
84
|
-
if (Object.keys(body).length === 0) { console.error(' Nothing to update, pass at least one option\n'); process.exit(1); }
|
|
85
|
-
|
|
86
|
-
await api.updateCard(jwt, card.id, body);
|
|
87
|
-
console.log(`\n Card "${name}" updated ✓\n`);
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
case 'delete': {
|
|
92
|
-
const name = flags.name;
|
|
93
|
-
if (!name) { console.error(' --name is required\n'); process.exit(1); }
|
|
94
|
-
const { cards = [] } = await api.listCards(jwt);
|
|
95
|
-
const card = cards.find(c => c.name.toLowerCase() === name.toLowerCase());
|
|
96
|
-
if (!card) { console.error(` Card "${name}" not found\n`); process.exit(1); }
|
|
97
|
-
await api.deleteCard(jwt, card.id);
|
|
98
|
-
console.log(`\n Card "${name}" deleted ✓\n`);
|
|
99
|
-
break;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
default:
|
|
103
|
-
console.error(` Unknown subcommand: ${sub}`);
|
|
104
|
-
console.error(' Usage: troxy cards [list|create|update|delete]\n');
|
|
105
|
-
process.exit(1);
|
|
106
|
-
}
|
|
107
|
-
}
|