troxy-cli 1.11.1 → 1.13.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/bin/troxy.js CHANGED
@@ -116,10 +116,28 @@ switch (command) {
116
116
  const existing = loadConfig() || {};
117
117
  const oldKey = existing.apiKey;
118
118
  const oldPrefix = oldKey ? oldKey.substring(0, 11) : null;
119
- const name = flags.name || null; // keep existing name if not specified
119
+ // Find the key being replaced BEFORE creating its successor: we need both
120
+ // its agent and its name.
121
+ const { tokens: existingTokens = [] } = await api.listTokens(jwt);
122
+ const currentToken = oldPrefix ? existingTokens.find(t => t.prefix === oldPrefix) : null;
123
+
124
+ // Rotation must keep the agent. Troxy issues an agent id when a key is
125
+ // created, and a key with no agent_id gets a brand NEW agent, which is
126
+ // right for `troxy init` and exactly wrong here: rotating would orphan
127
+ // every decision, policy and recommendation the agent had built up. That
128
+ // is the one case this whole identity model exists to serve.
129
+ const agentId = currentToken && currentToken.agent_id ? currentToken.agent_id : null;
130
+
131
+ // Keep the name too. Defaulting to 'Rotated key' renamed the agent on
132
+ // every rotation, so even before agent ids existed you lost your history
133
+ // and ended up with an agent literally called "Rotated key".
134
+ const name = flags.name || (currentToken && currentToken.name) || null;
120
135
 
121
136
  process.stdout.write('\n Creating new key... ');
122
- const result = await api.createToken(jwt, { name: name || 'Rotated key' });
137
+ const result = await api.createToken(jwt, {
138
+ name,
139
+ ...(agentId ? { agent_id: agentId } : {}),
140
+ });
123
141
  const newKey = result.key;
124
142
  const newPrefix = result.prefix;
125
143
  console.log('✓');
@@ -136,9 +154,7 @@ switch (command) {
136
154
 
137
155
  if (oldPrefix) {
138
156
  process.stdout.write(`\n Revoking old key ${oldPrefix}... `);
139
- const { tokens = [] } = await api.listTokens(jwt);
140
- const old = tokens.find(t => t.prefix === oldPrefix);
141
- if (old) { await api.revokeToken(jwt, old.id); console.log('✓'); }
157
+ if (currentToken) { await api.revokeToken(jwt, currentToken.id); console.log('✓'); }
142
158
  else console.log('(already revoked)');
143
159
  }
144
160
 
@@ -146,6 +162,7 @@ switch (command) {
146
162
  Key rotated:
147
163
  Old: ${oldPrefix ? oldPrefix + '... (revoked)' : '(none)'}
148
164
  New: ${newPrefix}...
165
+ Agent: ${agentId ? `${name || 'unchanged'} (history and policies kept)` : 'new agent (no previous key found to carry over)'}
149
166
 
150
167
  New key (shown once, save it now):
151
168
  ${newKey}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.11.1",
3
+ "version": "1.13.0",
4
4
  "description": "Control layer for AI agents: check payments, emails, logins and destructive actions against your policies",
5
5
  "homepage": "https://troxy.io",
6
6
  "bugs": {
package/src/api.js CHANGED
@@ -75,6 +75,7 @@ export const api = {
75
75
  evaluateLogin: (body, apiKey) => request('POST', '/evaluate/login', { apiKey, body }),
76
76
  evaluateAction: (body, apiKey) => request('POST', '/evaluate/action', { apiKey, body }),
77
77
  selfRevoke: (apiKey) => request('POST', '/mcp/self-revoke', { apiKey }),
78
+ revokeAllOthers: (apiKey) => request('POST', '/mcp/revoke-all-others', { apiKey }),
78
79
  confirmPayment: (auditId, body, apiKey) => request('POST', `/payments/${auditId}/confirm`, { apiKey, body }),
79
80
  waitApprovalStatus: (token) => request('GET', `/approvals/${encodeURIComponent(token)}/wait`),
80
81
 
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `troxy rotate-key` must keep the agent.
3
+ *
4
+ * Troxy issues an agent id when a key is created, and a request with no
5
+ * agent_id deliberately gets a brand NEW agent. That is right for `troxy init`
6
+ * and exactly wrong for rotation: it orphans every decision, policy and
7
+ * recommendation the agent had accumulated, which is the one case the identity
8
+ * model exists to serve.
9
+ *
10
+ * It also has to keep the name. Defaulting to 'Rotated key' renamed the agent
11
+ * on every rotation, so even before agent ids existed you lost your history and
12
+ * ended up with an agent called "Rotated key".
13
+ *
14
+ * These assert on the request body the command builds, because that body is
15
+ * the entire contract with the server.
16
+ */
17
+ import { test } from 'node:test';
18
+ import assert from 'node:assert';
19
+ import { readFileSync } from 'node:fs';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { dirname, join } from 'node:path';
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const SRC = readFileSync(join(__dirname, '../../bin/troxy.js'), 'utf8');
25
+
26
+ // The rotate-key case, isolated from the rest of the dispatcher.
27
+ const ROTATE = SRC.slice(
28
+ SRC.indexOf("case 'rotate-key'"),
29
+ SRC.indexOf("case 'mcp'", SRC.indexOf("case 'rotate-key'")),
30
+ );
31
+
32
+ // Comments stripped, so a test that bans a string does not trip over the
33
+ // comment explaining why the string is banned.
34
+ const ROTATE_CODE = ROTATE
35
+ .replace(/\/\*[\s\S]*?\*\//g, '')
36
+ .replace(/^\s*\/\/.*$/gm, '');
37
+
38
+ test('rotate-key sends the existing agent_id so the agent survives', () => {
39
+ assert.ok(
40
+ /agent_id:\s*agentId/.test(ROTATE),
41
+ 'rotate-key must pass agent_id; without it the server mints a new agent and the old history is orphaned',
42
+ );
43
+ assert.ok(
44
+ /currentToken\s*&&\s*currentToken\.agent_id/.test(ROTATE),
45
+ 'agent_id must come from the key being replaced, looked up by its prefix',
46
+ );
47
+ });
48
+
49
+ test('rotate-key no longer renames the agent to "Rotated key"', () => {
50
+ assert.ok(
51
+ !/['"]Rotated key['"]/.test(ROTATE_CODE),
52
+ 'defaulting the name to "Rotated key" renames the agent on every rotation',
53
+ );
54
+ assert.ok(
55
+ /flags\.name\s*\|\|\s*\(currentToken && currentToken\.name\)/.test(ROTATE),
56
+ 'rotation must keep the current name unless --name overrides it',
57
+ );
58
+ });
59
+
60
+ test('rotate-key looks the token up once, before creating its replacement', () => {
61
+ const listCalls = (ROTATE.match(/api\.listTokens\(/g) || []).length;
62
+ assert.strictEqual(
63
+ listCalls, 1,
64
+ `expected exactly one listTokens call, found ${listCalls}: the lookup has to happen before createToken so the agent id is known, and repeating it is a second round-trip for data already held`,
65
+ );
66
+ assert.ok(
67
+ ROTATE.indexOf('api.listTokens(') < ROTATE.indexOf('api.createToken('),
68
+ 'the token must be resolved before the new key is created',
69
+ );
70
+ });
71
+
72
+ test('rotate-key still revokes the old key after re-pointing consumers', () => {
73
+ const reprovision = ROTATE.indexOf('reprovisionKeyConsumers(');
74
+ const revoke = ROTATE.indexOf('api.revokeToken(');
75
+ assert.ok(reprovision > -1 && revoke > -1, 'both steps must still be present');
76
+ assert.ok(
77
+ reprovision < revoke,
78
+ 'consumers are re-pointed before the old key is revoked, otherwise every consumer keeps presenting a revoked key and control goes down',
79
+ );
80
+ });
package/src/uninstall.js CHANGED
@@ -87,12 +87,15 @@ function removeMcpEntries() {
87
87
  export async function runUninstall() {
88
88
  console.log('\n Troxy: Uninstall\n');
89
89
 
90
- const answer = await prompt(' This will remove Troxy from this machine. Continue? (y/N): ');
90
+ const answer = await prompt(' This will remove Troxy from this machine and revoke ALL your agent keys. Continue? (y/N): ');
91
91
  if (answer.toLowerCase() !== 'y') {
92
92
  console.log('\n Cancelled.\n');
93
93
  process.exit(0);
94
94
  }
95
95
 
96
+ const cfg = loadConfig();
97
+ const apiKey = cfg?.apiKey;
98
+
96
99
  // 1. Stop and remove background service
97
100
  process.stdout.write(' Stopping background service... ');
98
101
  const removed = removeService();
@@ -108,24 +111,45 @@ export async function runUninstall() {
108
111
  console.log('none found');
109
112
  }
110
113
 
111
- // 3. Revoke the API key. /mcp/self-revoke only lets a key revoke the exact
112
- // token it authenticates asit can't touch or list any other token — so
113
- // this doesn't need a login/JWT, the key on disk is enough.
114
- const cfg = loadConfig();
115
- if (cfg?.apiKey) {
116
- const prefix = cfg.apiKey.slice(0, 12) + '...';
117
- process.stdout.write(` Revoking API key ${prefix}... `);
114
+ // 3. Revoke all OTHER agent keys (cloud agents, other machines) using the
115
+ // local API key for auth no troxy login needed. The key proves identity
116
+ // to the server, and /mcp/revoke-all-others kills every other active token
117
+ // for the same user. We do this BEFORE self-revoke so the key is still valid
118
+ // to make the call.
119
+ if (apiKey) {
120
+ process.stdout.write(' Revoking other agents... ');
121
+ try {
122
+ const result = await api.revokeAllOthers(apiKey);
123
+ if (result.count > 0) {
124
+ console.log(`✓ (${result.count} agent${result.count > 1 ? 's' : ''})`);
125
+ for (const a of result.revoked) {
126
+ const conn = a.connection_type || 'local';
127
+ console.log(` • ${a.name} (${conn})`);
128
+ }
129
+ } else {
130
+ console.log('✓ (none found)');
131
+ }
132
+ } catch (err) {
133
+ console.log('✗');
134
+ console.log(` Couldn't revoke other agents (${err.message}).`);
135
+ console.log(' Go to dash.troxy.io → API Keys to revoke them manually.\n');
136
+ }
137
+
138
+ // 4. Revoke THIS machine's API key (self-revoke). Done after revoke-all-others
139
+ // because self-revoke kills the key we're using to authenticate.
140
+ const prefix = apiKey.slice(0, 12) + '...';
141
+ process.stdout.write(` Revoking local API key ${prefix}... `);
118
142
  try {
119
- await api.selfRevoke(cfg.apiKey);
143
+ await api.selfRevoke(apiKey);
120
144
  console.log('✓');
121
145
  } catch (err) {
122
146
  console.log('✗');
123
147
  console.log(` Couldn't revoke automatically (${err.message}).`);
124
- console.log(' Go to dash.troxy.io → Settings → API Keys to revoke it.\n');
148
+ console.log(' Go to dash.troxy.io → API Keys to revoke it.\n');
125
149
  }
126
150
  }
127
151
 
128
- // 4. Delete ~/.troxy config
152
+ // 5. Delete ~/.troxy config
129
153
  process.stdout.write(' Removing config (~/.troxy)... ');
130
154
  const configDir = path.join(os.homedir(), '.troxy');
131
155
  if (fs.existsSync(configDir)) {
@@ -135,7 +159,7 @@ export async function runUninstall() {
135
159
  console.log('not found');
136
160
  }
137
161
 
138
- // 5. Remove npm package
162
+ // 6. Remove npm package
139
163
  process.stdout.write(' Uninstalling troxy CLI... ');
140
164
  try {
141
165
  execSync('npm uninstall -g troxy-cli 2>/dev/null || npm uninstall -g troxy 2>/dev/null', { stdio: 'pipe' });
@@ -144,5 +168,5 @@ export async function runUninstall() {
144
168
  console.log('skipped (run manually: sudo npm uninstall -g troxy-cli)');
145
169
  }
146
170
 
147
- console.log('\n Troxy removed. Your payments are no longer protected by Troxy.\n');
171
+ console.log('\n Troxy removed. All agent keys revoked. Your AI agents are no longer protected by Troxy.\n');
148
172
  }