troxy-cli 1.12.0 → 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.12.0",
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": {
@@ -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
+ });