@plosson/agentio 0.7.2 → 0.7.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/package.json +1 -1
- package/src/commands/config-import.test.ts +330 -0
- package/src/commands/config.ts +21 -2
- package/src/commands/mcp.ts +5 -0
- package/src/commands/server-tokens.test.ts +269 -0
- package/src/commands/server.ts +514 -0
- package/src/commands/teleport.test.ts +1462 -0
- package/src/commands/teleport.ts +882 -0
- package/src/index.ts +2 -0
- package/src/mcp/server.test.ts +89 -0
- package/src/mcp/server.ts +51 -30
- package/src/server/daemon.test.ts +637 -0
- package/src/server/daemon.ts +177 -0
- package/src/server/dockerfile-gen.test.ts +218 -0
- package/src/server/dockerfile-gen.ts +108 -0
- package/src/server/dockerfile-teleport.test.ts +184 -0
- package/src/server/http.test.ts +256 -0
- package/src/server/http.ts +54 -0
- package/src/server/mcp-adversarial.test.ts +643 -0
- package/src/server/mcp-e2e.test.ts +397 -0
- package/src/server/mcp-http.test.ts +364 -0
- package/src/server/mcp-http.ts +339 -0
- package/src/server/oauth-e2e.test.ts +466 -0
- package/src/server/oauth-store.test.ts +423 -0
- package/src/server/oauth-store.ts +216 -0
- package/src/server/oauth.test.ts +1502 -0
- package/src/server/oauth.ts +800 -0
- package/src/server/siteio-runner.test.ts +766 -0
- package/src/server/siteio-runner.ts +352 -0
- package/src/server/test-helpers.ts +201 -0
- package/src/types/config.ts +3 -0
- package/src/types/server.ts +61 -0
package/package.json
CHANGED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Subprocess tests for `agentio config import` — specifically the fix
|
|
8
|
+
* that makes import preserve top-level config fields the export blob
|
|
9
|
+
* doesn't contain (server, gateway).
|
|
10
|
+
*
|
|
11
|
+
* Why subprocess: config import touches the real config-manager and
|
|
12
|
+
* credential store (file-backed, tied to homedir() at module load).
|
|
13
|
+
* Each test runs in an isolated `mkdtemp` HOME so the tests never
|
|
14
|
+
* touch the developer's real ~/.config/agentio.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
let tempHome = '';
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
tempHome = await mkdtemp(join(tmpdir(), 'agentio-config-import-test-'));
|
|
21
|
+
await mkdir(join(tempHome, '.config', 'agentio'), {
|
|
22
|
+
recursive: true,
|
|
23
|
+
mode: 0o700,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
if (tempHome) {
|
|
29
|
+
await rm(tempHome, { recursive: true, force: true }).catch(() => {});
|
|
30
|
+
tempHome = '';
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function writeConfig(content: unknown): Promise<void> {
|
|
35
|
+
await writeFile(
|
|
36
|
+
join(tempHome, '.config', 'agentio', 'config.json'),
|
|
37
|
+
JSON.stringify(content, null, 2),
|
|
38
|
+
{ mode: 0o600 }
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readConfig(): Promise<Record<string, unknown>> {
|
|
43
|
+
const raw = await readFile(
|
|
44
|
+
join(tempHome, '.config', 'agentio', 'config.json'),
|
|
45
|
+
'utf8'
|
|
46
|
+
);
|
|
47
|
+
return JSON.parse(raw);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract the set of profile names for a given service from a config,
|
|
52
|
+
* tolerating both string ("p1") and object ({name: "p1"}) shapes.
|
|
53
|
+
* Profiles can be either form per the ProfileValue type.
|
|
54
|
+
*/
|
|
55
|
+
function profileNames(
|
|
56
|
+
config: Record<string, unknown>,
|
|
57
|
+
service: string
|
|
58
|
+
): string[] {
|
|
59
|
+
const profiles = (config.profiles as Record<string, unknown[]>)?.[service];
|
|
60
|
+
if (!Array.isArray(profiles)) return [];
|
|
61
|
+
return profiles.map((p) =>
|
|
62
|
+
typeof p === 'string' ? p : (p as { name: string }).name
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runCli(
|
|
67
|
+
args: string[],
|
|
68
|
+
extraEnv: Record<string, string> = {}
|
|
69
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
70
|
+
const proc = Bun.spawn(['bun', 'run', 'src/index.ts', ...args], {
|
|
71
|
+
stdout: 'pipe',
|
|
72
|
+
stderr: 'pipe',
|
|
73
|
+
env: { ...process.env, HOME: tempHome, ...extraEnv },
|
|
74
|
+
});
|
|
75
|
+
const exitCode = await proc.exited;
|
|
76
|
+
const stdout = await new Response(proc.stdout).text();
|
|
77
|
+
const stderr = await new Response(proc.stderr).text();
|
|
78
|
+
return { exitCode, stdout, stderr };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Helper: take a config snapshot (which becomes the "exported state"),
|
|
83
|
+
* call `config export --all`, parse the AGENTIO_KEY + AGENTIO_CONFIG
|
|
84
|
+
* vars from stdout. Returns those for use with a follow-up import.
|
|
85
|
+
*/
|
|
86
|
+
async function exportCurrentConfig(): Promise<{
|
|
87
|
+
key: string;
|
|
88
|
+
blob: string;
|
|
89
|
+
}> {
|
|
90
|
+
const res = await runCli(['config', 'export', '--all']);
|
|
91
|
+
if (res.exitCode !== 0) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`export failed: exit ${res.exitCode}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const keyMatch = res.stdout.match(/AGENTIO_KEY=(\S+)/);
|
|
97
|
+
const configMatch = res.stdout.match(/AGENTIO_CONFIG=(\S+)/);
|
|
98
|
+
if (!keyMatch || !configMatch) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`could not parse export output:\nstdout: ${res.stdout}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return { key: keyMatch[1], blob: configMatch[1] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface ServerStateLike {
|
|
107
|
+
apiKey: string;
|
|
108
|
+
tokens: Array<Record<string, unknown>>;
|
|
109
|
+
clients: Array<Record<string, unknown>>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const SAMPLE_SERVER: ServerStateLike = {
|
|
113
|
+
apiKey: 'srv_preserved_key_for_test_xxxx',
|
|
114
|
+
tokens: [
|
|
115
|
+
{
|
|
116
|
+
token: 'preserved-bearer-token-value',
|
|
117
|
+
clientId: 'cli_preserved_x',
|
|
118
|
+
scope: 'mcp',
|
|
119
|
+
issuedAt: 1700000000000,
|
|
120
|
+
// Far in the future so findToken doesn't treat it as expired.
|
|
121
|
+
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
clients: [
|
|
125
|
+
{
|
|
126
|
+
clientId: 'cli_preserved_x',
|
|
127
|
+
clientName: 'Preserved Test Client',
|
|
128
|
+
redirectUris: ['http://localhost/cb'],
|
|
129
|
+
createdAt: 1700000000000,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const SAMPLE_GATEWAY = {
|
|
135
|
+
apiKey: 'gw_preserved_gateway_key',
|
|
136
|
+
server: { port: 7890, host: '0.0.0.0' },
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/* ------------------------------------------------------------------ */
|
|
140
|
+
/* replace mode preserves config.server */
|
|
141
|
+
/* ------------------------------------------------------------------ */
|
|
142
|
+
|
|
143
|
+
describe('config import (replace mode) — preserves config.server', () => {
|
|
144
|
+
test('preserves apiKey, tokens, and clients across import', async () => {
|
|
145
|
+
// 1. Seed: existing config with profiles + server.*
|
|
146
|
+
await writeConfig({
|
|
147
|
+
profiles: { gmail: [{ name: 'work' }] },
|
|
148
|
+
server: SAMPLE_SERVER,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// 2. Snapshot the current state via export.
|
|
152
|
+
const { key, blob } = await exportCurrentConfig();
|
|
153
|
+
|
|
154
|
+
// 3. Mutate the saved config (different profiles) but keep server.*
|
|
155
|
+
// so we can assert the IMPORT preserves the still-current
|
|
156
|
+
// server, not just whatever was at export time.
|
|
157
|
+
await writeConfig({
|
|
158
|
+
profiles: { gchat: [{ name: 'irrelevant' }] },
|
|
159
|
+
server: SAMPLE_SERVER,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// 4. Run import — replace mode (no --merge).
|
|
163
|
+
const importRes = await runCli(['config', 'import'], {
|
|
164
|
+
AGENTIO_KEY: key,
|
|
165
|
+
AGENTIO_CONFIG: blob,
|
|
166
|
+
});
|
|
167
|
+
expect(importRes.exitCode).toBe(0);
|
|
168
|
+
|
|
169
|
+
// 5. Assert: profiles came from the export, server.* preserved.
|
|
170
|
+
const final = await readConfig();
|
|
171
|
+
expect(profileNames(final, 'gmail')).toEqual(['work']);
|
|
172
|
+
expect(profileNames(final, 'gchat')).toEqual([]);
|
|
173
|
+
|
|
174
|
+
const server = final.server as Record<string, unknown>;
|
|
175
|
+
expect(server).toBeDefined();
|
|
176
|
+
expect(server.apiKey).toBe(SAMPLE_SERVER.apiKey);
|
|
177
|
+
expect(server.tokens).toEqual(SAMPLE_SERVER.tokens);
|
|
178
|
+
expect(server.clients).toEqual(SAMPLE_SERVER.clients);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('preserves config.gateway across import', async () => {
|
|
182
|
+
await writeConfig({
|
|
183
|
+
profiles: { rss: [{ name: 'feeds' }] },
|
|
184
|
+
gateway: SAMPLE_GATEWAY,
|
|
185
|
+
});
|
|
186
|
+
const { key, blob } = await exportCurrentConfig();
|
|
187
|
+
|
|
188
|
+
await writeConfig({
|
|
189
|
+
profiles: {},
|
|
190
|
+
gateway: SAMPLE_GATEWAY,
|
|
191
|
+
});
|
|
192
|
+
const importRes = await runCli(['config', 'import'], {
|
|
193
|
+
AGENTIO_KEY: key,
|
|
194
|
+
AGENTIO_CONFIG: blob,
|
|
195
|
+
});
|
|
196
|
+
expect(importRes.exitCode).toBe(0);
|
|
197
|
+
|
|
198
|
+
const final = await readConfig();
|
|
199
|
+
expect(final.gateway).toEqual(SAMPLE_GATEWAY);
|
|
200
|
+
expect(profileNames(final, 'rss')).toEqual(['feeds']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('preserves both server and gateway in the same config', async () => {
|
|
204
|
+
await writeConfig({
|
|
205
|
+
profiles: { gmail: [{ name: 'p1' }] },
|
|
206
|
+
server: SAMPLE_SERVER,
|
|
207
|
+
gateway: SAMPLE_GATEWAY,
|
|
208
|
+
});
|
|
209
|
+
const { key, blob } = await exportCurrentConfig();
|
|
210
|
+
|
|
211
|
+
await writeConfig({
|
|
212
|
+
profiles: {},
|
|
213
|
+
server: SAMPLE_SERVER,
|
|
214
|
+
gateway: SAMPLE_GATEWAY,
|
|
215
|
+
});
|
|
216
|
+
const importRes = await runCli(['config', 'import'], {
|
|
217
|
+
AGENTIO_KEY: key,
|
|
218
|
+
AGENTIO_CONFIG: blob,
|
|
219
|
+
});
|
|
220
|
+
expect(importRes.exitCode).toBe(0);
|
|
221
|
+
|
|
222
|
+
const final = await readConfig();
|
|
223
|
+
expect(final.server).toEqual(SAMPLE_SERVER);
|
|
224
|
+
expect(final.gateway).toEqual(SAMPLE_GATEWAY);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('replace still REPLACES profiles (not merge)', async () => {
|
|
228
|
+
await writeConfig({
|
|
229
|
+
profiles: { gmail: [{ name: 'p1' }] },
|
|
230
|
+
server: SAMPLE_SERVER,
|
|
231
|
+
});
|
|
232
|
+
const { key, blob } = await exportCurrentConfig();
|
|
233
|
+
|
|
234
|
+
// Change current profiles to something completely different — the
|
|
235
|
+
// import should overwrite this with the exported {gmail:[p1]}, NOT
|
|
236
|
+
// accumulate.
|
|
237
|
+
await writeConfig({
|
|
238
|
+
profiles: { jira: [{ name: 'tickets' }] },
|
|
239
|
+
server: SAMPLE_SERVER,
|
|
240
|
+
});
|
|
241
|
+
const importRes = await runCli(['config', 'import'], {
|
|
242
|
+
AGENTIO_KEY: key,
|
|
243
|
+
AGENTIO_CONFIG: blob,
|
|
244
|
+
});
|
|
245
|
+
expect(importRes.exitCode).toBe(0);
|
|
246
|
+
|
|
247
|
+
const final = await readConfig();
|
|
248
|
+
expect(profileNames(final, 'gmail')).toEqual(['p1']);
|
|
249
|
+
// jira is gone — replace, not merge.
|
|
250
|
+
expect(profileNames(final, 'jira')).toEqual([]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('replace replaces env vars too', async () => {
|
|
254
|
+
await writeConfig({
|
|
255
|
+
profiles: { gmail: [{ name: 'p1' }] },
|
|
256
|
+
env: { OLD_VAR: 'old' },
|
|
257
|
+
server: SAMPLE_SERVER,
|
|
258
|
+
});
|
|
259
|
+
const { key, blob } = await exportCurrentConfig();
|
|
260
|
+
|
|
261
|
+
// Mutate to new env, then import the snapshot — env should revert
|
|
262
|
+
// to the snapshot's env.
|
|
263
|
+
await writeConfig({
|
|
264
|
+
profiles: {},
|
|
265
|
+
env: { NEW_VAR: 'new' },
|
|
266
|
+
server: SAMPLE_SERVER,
|
|
267
|
+
});
|
|
268
|
+
const importRes = await runCli(['config', 'import'], {
|
|
269
|
+
AGENTIO_KEY: key,
|
|
270
|
+
AGENTIO_CONFIG: blob,
|
|
271
|
+
});
|
|
272
|
+
expect(importRes.exitCode).toBe(0);
|
|
273
|
+
|
|
274
|
+
const final = await readConfig();
|
|
275
|
+
const env = final.env as Record<string, unknown>;
|
|
276
|
+
expect(env).toBeDefined();
|
|
277
|
+
expect(env.OLD_VAR).toBe('old');
|
|
278
|
+
expect(env.NEW_VAR).toBeUndefined();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/* ------------------------------------------------------------------ */
|
|
283
|
+
/* merge mode keeps working */
|
|
284
|
+
/* ------------------------------------------------------------------ */
|
|
285
|
+
|
|
286
|
+
describe('config import (merge mode) — preserves server + adds profiles', () => {
|
|
287
|
+
test('--merge adds new profiles without removing existing ones', async () => {
|
|
288
|
+
await writeConfig({
|
|
289
|
+
profiles: { gmail: [{ name: 'p1' }] },
|
|
290
|
+
server: SAMPLE_SERVER,
|
|
291
|
+
});
|
|
292
|
+
const { key, blob } = await exportCurrentConfig();
|
|
293
|
+
|
|
294
|
+
await writeConfig({
|
|
295
|
+
profiles: { jira: [{ name: 'tickets' }] },
|
|
296
|
+
server: SAMPLE_SERVER,
|
|
297
|
+
});
|
|
298
|
+
const importRes = await runCli(['config', 'import', '--merge'], {
|
|
299
|
+
AGENTIO_KEY: key,
|
|
300
|
+
AGENTIO_CONFIG: blob,
|
|
301
|
+
});
|
|
302
|
+
expect(importRes.exitCode).toBe(0);
|
|
303
|
+
|
|
304
|
+
const final = await readConfig();
|
|
305
|
+
// Both original (jira) and imported (gmail) profiles present.
|
|
306
|
+
expect(profileNames(final, 'jira')).toContain('tickets');
|
|
307
|
+
expect(profileNames(final, 'gmail')).toContain('p1');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('--merge preserves config.server (matching replace behavior)', async () => {
|
|
311
|
+
await writeConfig({
|
|
312
|
+
profiles: { gmail: [{ name: 'p1' }] },
|
|
313
|
+
server: SAMPLE_SERVER,
|
|
314
|
+
});
|
|
315
|
+
const { key, blob } = await exportCurrentConfig();
|
|
316
|
+
|
|
317
|
+
await writeConfig({
|
|
318
|
+
profiles: {},
|
|
319
|
+
server: SAMPLE_SERVER,
|
|
320
|
+
});
|
|
321
|
+
const importRes = await runCli(['config', 'import', '--merge'], {
|
|
322
|
+
AGENTIO_KEY: key,
|
|
323
|
+
AGENTIO_CONFIG: blob,
|
|
324
|
+
});
|
|
325
|
+
expect(importRes.exitCode).toBe(0);
|
|
326
|
+
|
|
327
|
+
const final = await readConfig();
|
|
328
|
+
expect(final.server).toEqual(SAMPLE_SERVER);
|
|
329
|
+
});
|
|
330
|
+
});
|
package/src/commands/config.ts
CHANGED
|
@@ -342,8 +342,27 @@ export function registerConfigCommands(program: Command): void {
|
|
|
342
342
|
await setAllCredentials(currentCredentials);
|
|
343
343
|
console.log('Configuration merged successfully');
|
|
344
344
|
} else {
|
|
345
|
-
// Replace
|
|
346
|
-
|
|
345
|
+
// Replace profiles + env from the export, but PRESERVE any
|
|
346
|
+
// other top-level fields (config.server, config.gateway, …).
|
|
347
|
+
// The export blob only contains `{profiles, env}` by
|
|
348
|
+
// construction; everything else in the existing config is
|
|
349
|
+
// per-machine state (server.apiKey, server.tokens,
|
|
350
|
+
// gateway.apiKey, …) that the import has no business
|
|
351
|
+
// destroying.
|
|
352
|
+
//
|
|
353
|
+
// This matters in particular for the teleport flow: a remote
|
|
354
|
+
// container's `agentio config import` on restart would
|
|
355
|
+
// otherwise wipe `config.server.tokens`, invalidating any
|
|
356
|
+
// bearer Claude Desktop / Claude Code had been using.
|
|
357
|
+
const currentConfig = await loadConfig();
|
|
358
|
+
const newConfig: Config = {
|
|
359
|
+
...currentConfig,
|
|
360
|
+
profiles: exportData.config.profiles,
|
|
361
|
+
...(exportData.config.env !== undefined
|
|
362
|
+
? { env: exportData.config.env }
|
|
363
|
+
: {}),
|
|
364
|
+
};
|
|
365
|
+
await saveConfig(newConfig);
|
|
347
366
|
await setAllCredentials(exportData.credentials);
|
|
348
367
|
console.log('Configuration imported successfully');
|
|
349
368
|
}
|
package/src/commands/mcp.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
startMcpServer,
|
|
11
11
|
type ServiceProfilePair,
|
|
12
12
|
} from '../mcp/server';
|
|
13
|
+
import { registerTeleportCommand } from './teleport';
|
|
13
14
|
|
|
14
15
|
const MCP_JSON = '.mcp.json';
|
|
15
16
|
|
|
@@ -144,4 +145,8 @@ export function registerMcpCommands(program: Command): void {
|
|
|
144
145
|
handleError(error);
|
|
145
146
|
}
|
|
146
147
|
});
|
|
148
|
+
|
|
149
|
+
// teleport subcommand — `agentio mcp teleport <name>`. Lives here
|
|
150
|
+
// (not at the top level) so all MCP-related commands are grouped.
|
|
151
|
+
registerTeleportCommand(mcp);
|
|
147
152
|
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile, mkdir } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Subprocess tests for `agentio server tokens list|revoke|clear`. They
|
|
8
|
+
* seed config.json directly with a known set of tokens so we can avoid
|
|
9
|
+
* spinning up a real OAuth flow per test.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
let tempHome = '';
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
tempHome = await mkdtemp(join(tmpdir(), 'agentio-tokens-test-'));
|
|
16
|
+
await mkdir(join(tempHome, '.config', 'agentio'), {
|
|
17
|
+
recursive: true,
|
|
18
|
+
mode: 0o700,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
if (tempHome) {
|
|
24
|
+
await rm(tempHome, { recursive: true, force: true }).catch(() => {});
|
|
25
|
+
tempHome = '';
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
interface Token {
|
|
30
|
+
token: string;
|
|
31
|
+
clientId: string;
|
|
32
|
+
scope: string;
|
|
33
|
+
issuedAt: number;
|
|
34
|
+
expiresAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function seedTokens(tokens: Token[]): Promise<void> {
|
|
38
|
+
const cfg = {
|
|
39
|
+
profiles: {},
|
|
40
|
+
server: {
|
|
41
|
+
apiKey: 'srv_test_key_for_seeded_tests',
|
|
42
|
+
tokens,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
await writeFile(
|
|
46
|
+
join(tempHome, '.config', 'agentio', 'config.json'),
|
|
47
|
+
JSON.stringify(cfg, null, 2),
|
|
48
|
+
{ mode: 0o600 }
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readTokensFromConfig(): Promise<Token[]> {
|
|
53
|
+
const raw = await readFile(
|
|
54
|
+
join(tempHome, '.config', 'agentio', 'config.json'),
|
|
55
|
+
'utf8'
|
|
56
|
+
);
|
|
57
|
+
const cfg = JSON.parse(raw);
|
|
58
|
+
return cfg.server?.tokens ?? [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runCli(
|
|
62
|
+
args: string[]
|
|
63
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
64
|
+
const proc = Bun.spawn(['bun', 'run', 'src/index.ts', ...args], {
|
|
65
|
+
stdout: 'pipe',
|
|
66
|
+
stderr: 'pipe',
|
|
67
|
+
env: { ...process.env, HOME: tempHome },
|
|
68
|
+
});
|
|
69
|
+
const exitCode = await proc.exited;
|
|
70
|
+
const stdout = await new Response(proc.stdout).text();
|
|
71
|
+
const stderr = await new Response(proc.stderr).text();
|
|
72
|
+
return { exitCode, stdout, stderr };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Generated lazily so the timestamps are always current — `expiresAt` is
|
|
76
|
+
// relative to "now" at the moment the test runs, not a hardcoded epoch.
|
|
77
|
+
function makeSampleTokens(): Token[] {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const day = 24 * 60 * 60 * 1000;
|
|
80
|
+
return [
|
|
81
|
+
{
|
|
82
|
+
token: 'aaaaaaaaaaaa1111111111111111111111111111111',
|
|
83
|
+
clientId: 'cli_first',
|
|
84
|
+
scope: 'gchat:default',
|
|
85
|
+
issuedAt: now,
|
|
86
|
+
expiresAt: now + 30 * day,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
token: 'bbbbbbbbbbbb2222222222222222222222222222222',
|
|
90
|
+
clientId: 'cli_second',
|
|
91
|
+
scope: 'gmail:work,slack:team',
|
|
92
|
+
issuedAt: now,
|
|
93
|
+
expiresAt: now + 30 * day,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
token: 'cccccccccccc3333333333333333333333333333333',
|
|
97
|
+
clientId: 'cli_third',
|
|
98
|
+
scope: '',
|
|
99
|
+
issuedAt: now - 60 * day,
|
|
100
|
+
// Already expired (issued 60 days ago, expired 30 days ago).
|
|
101
|
+
expiresAt: now - 30 * day,
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ------------------------------------------------------------------ */
|
|
107
|
+
/* tokens list */
|
|
108
|
+
/* ------------------------------------------------------------------ */
|
|
109
|
+
|
|
110
|
+
describe('agentio server tokens list', () => {
|
|
111
|
+
test('reports "no tokens" when none are issued', async () => {
|
|
112
|
+
await seedTokens([]);
|
|
113
|
+
const { exitCode, stdout } = await runCli(['server', 'tokens', 'list']);
|
|
114
|
+
expect(exitCode).toBe(0);
|
|
115
|
+
expect(stdout).toContain('No tokens issued yet');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('reports each token with id prefix, client, scope, dates', async () => {
|
|
119
|
+
await seedTokens(makeSampleTokens());
|
|
120
|
+
const { exitCode, stdout } = await runCli(['server', 'tokens', 'list']);
|
|
121
|
+
expect(exitCode).toBe(0);
|
|
122
|
+
expect(stdout).toContain('3 token(s) issued');
|
|
123
|
+
expect(stdout).toContain('aaaaaaaaaaaa');
|
|
124
|
+
expect(stdout).toContain('bbbbbbbbbbbb');
|
|
125
|
+
expect(stdout).toContain('cccccccccccc');
|
|
126
|
+
expect(stdout).toContain('cli_first');
|
|
127
|
+
expect(stdout).toContain('cli_second');
|
|
128
|
+
expect(stdout).toContain('cli_third');
|
|
129
|
+
expect(stdout).toContain('gchat:default');
|
|
130
|
+
expect(stdout).toContain('gmail:work,slack:team');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('marks expired tokens with (EXPIRED)', async () => {
|
|
134
|
+
await seedTokens(makeSampleTokens());
|
|
135
|
+
const { stdout } = await runCli(['server', 'tokens', 'list']);
|
|
136
|
+
// Only the third sample token is expired.
|
|
137
|
+
const lines = stdout.split('\n');
|
|
138
|
+
const thirdLine = lines.find((l) => l.includes('cccccccccccc'));
|
|
139
|
+
expect(thirdLine).toBeDefined();
|
|
140
|
+
expect(thirdLine).toContain('EXPIRED');
|
|
141
|
+
const firstLine = lines.find((l) => l.includes('aaaaaaaaaaaa'));
|
|
142
|
+
expect(firstLine).not.toContain('EXPIRED');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/* ------------------------------------------------------------------ */
|
|
147
|
+
/* tokens revoke */
|
|
148
|
+
/* ------------------------------------------------------------------ */
|
|
149
|
+
|
|
150
|
+
describe('agentio server tokens revoke', () => {
|
|
151
|
+
test('revokes a token by 12-char prefix and persists', async () => {
|
|
152
|
+
await seedTokens(makeSampleTokens());
|
|
153
|
+
const { exitCode, stdout } = await runCli([
|
|
154
|
+
'server',
|
|
155
|
+
'tokens',
|
|
156
|
+
'revoke',
|
|
157
|
+
'aaaaaaaaaaaa',
|
|
158
|
+
]);
|
|
159
|
+
expect(exitCode).toBe(0);
|
|
160
|
+
expect(stdout).toContain('Revoked token aaaaaaaaaaaa');
|
|
161
|
+
const remaining = await readTokensFromConfig();
|
|
162
|
+
expect(remaining).toHaveLength(2);
|
|
163
|
+
expect(remaining.map((t) => t.clientId)).toEqual([
|
|
164
|
+
'cli_second',
|
|
165
|
+
'cli_third',
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('revokes a token by full opaque value', async () => {
|
|
170
|
+
const samples = makeSampleTokens();
|
|
171
|
+
await seedTokens(samples);
|
|
172
|
+
const full = samples[1].token;
|
|
173
|
+
const { exitCode } = await runCli(['server', 'tokens', 'revoke', full]);
|
|
174
|
+
expect(exitCode).toBe(0);
|
|
175
|
+
const remaining = await readTokensFromConfig();
|
|
176
|
+
expect(remaining).toHaveLength(2);
|
|
177
|
+
expect(remaining.map((t) => t.clientId)).toEqual([
|
|
178
|
+
'cli_first',
|
|
179
|
+
'cli_third',
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('non-matching id → non-zero exit, NOT_FOUND error', async () => {
|
|
184
|
+
await seedTokens(makeSampleTokens());
|
|
185
|
+
const { exitCode, stderr } = await runCli([
|
|
186
|
+
'server',
|
|
187
|
+
'tokens',
|
|
188
|
+
'revoke',
|
|
189
|
+
'nope_does_not_exist',
|
|
190
|
+
]);
|
|
191
|
+
expect(exitCode).not.toBe(0);
|
|
192
|
+
expect(stderr).toContain('No token found matching');
|
|
193
|
+
// Persisted state must not have changed.
|
|
194
|
+
const remaining = await readTokensFromConfig();
|
|
195
|
+
expect(remaining).toHaveLength(3);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('ambiguous prefix → non-zero exit, INVALID_PARAMS error', async () => {
|
|
199
|
+
// Both seeded tokens that start with the same letter would collide.
|
|
200
|
+
const samples = makeSampleTokens();
|
|
201
|
+
await seedTokens([
|
|
202
|
+
{ ...samples[0], token: 'shared_prefix_111111111111111111111111111111' },
|
|
203
|
+
{ ...samples[1], token: 'shared_prefix_222222222222222222222222222222' },
|
|
204
|
+
]);
|
|
205
|
+
const { exitCode, stderr } = await runCli([
|
|
206
|
+
'server',
|
|
207
|
+
'tokens',
|
|
208
|
+
'revoke',
|
|
209
|
+
'shared_prefix',
|
|
210
|
+
]);
|
|
211
|
+
expect(exitCode).not.toBe(0);
|
|
212
|
+
expect(stderr).toContain('Ambiguous prefix');
|
|
213
|
+
const remaining = await readTokensFromConfig();
|
|
214
|
+
expect(remaining).toHaveLength(2);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('preserves apiKey and other server fields when revoking', async () => {
|
|
218
|
+
await seedTokens(makeSampleTokens());
|
|
219
|
+
await runCli(['server', 'tokens', 'revoke', 'aaaaaaaaaaaa']);
|
|
220
|
+
const raw = await readFile(
|
|
221
|
+
join(tempHome, '.config', 'agentio', 'config.json'),
|
|
222
|
+
'utf8'
|
|
223
|
+
);
|
|
224
|
+
const cfg = JSON.parse(raw);
|
|
225
|
+
expect(cfg.server.apiKey).toBe('srv_test_key_for_seeded_tests');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/* ------------------------------------------------------------------ */
|
|
230
|
+
/* tokens clear */
|
|
231
|
+
/* ------------------------------------------------------------------ */
|
|
232
|
+
|
|
233
|
+
describe('agentio server tokens clear', () => {
|
|
234
|
+
test('removes all tokens and reports the count', async () => {
|
|
235
|
+
await seedTokens(makeSampleTokens());
|
|
236
|
+
const { exitCode, stdout } = await runCli([
|
|
237
|
+
'server',
|
|
238
|
+
'tokens',
|
|
239
|
+
'clear',
|
|
240
|
+
]);
|
|
241
|
+
expect(exitCode).toBe(0);
|
|
242
|
+
expect(stdout).toContain('Cleared 3 token(s)');
|
|
243
|
+
const remaining = await readTokensFromConfig();
|
|
244
|
+
expect(remaining).toHaveLength(0);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('clear on an empty list reports 0 tokens', async () => {
|
|
248
|
+
await seedTokens([]);
|
|
249
|
+
const { exitCode, stdout } = await runCli([
|
|
250
|
+
'server',
|
|
251
|
+
'tokens',
|
|
252
|
+
'clear',
|
|
253
|
+
]);
|
|
254
|
+
expect(exitCode).toBe(0);
|
|
255
|
+
expect(stdout).toContain('Cleared 0 token(s)');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('clear preserves apiKey and other server fields', async () => {
|
|
259
|
+
await seedTokens(makeSampleTokens());
|
|
260
|
+
await runCli(['server', 'tokens', 'clear']);
|
|
261
|
+
const raw = await readFile(
|
|
262
|
+
join(tempHome, '.config', 'agentio', 'config.json'),
|
|
263
|
+
'utf8'
|
|
264
|
+
);
|
|
265
|
+
const cfg = JSON.parse(raw);
|
|
266
|
+
expect(cfg.server.apiKey).toBe('srv_test_key_for_seeded_tests');
|
|
267
|
+
expect(cfg.server.tokens).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
});
|