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 +22 -5
- package/package.json +1 -1
- package/src/tests/rotate-key.test.js +80 -0
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
@@ -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
|
+
});
|