@slashfi/agents-sdk 0.11.2 → 0.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/dist/agent-definitions/auth.d.ts +17 -1
- package/dist/agent-definitions/auth.d.ts.map +1 -1
- package/dist/agent-definitions/auth.js +123 -4
- package/dist/agent-definitions/auth.js.map +1 -1
- package/dist/agent-definitions/integrations.d.ts +2 -14
- package/dist/agent-definitions/integrations.d.ts.map +1 -1
- package/dist/agent-definitions/integrations.js +64 -19
- package/dist/agent-definitions/integrations.js.map +1 -1
- package/dist/agent-definitions/remote-registry.d.ts +19 -14
- package/dist/agent-definitions/remote-registry.d.ts.map +1 -1
- package/dist/agent-definitions/remote-registry.js +219 -381
- package/dist/agent-definitions/remote-registry.js.map +1 -1
- package/dist/agent-definitions/users.d.ts.map +1 -1
- package/dist/agent-definitions/users.js +29 -1
- package/dist/agent-definitions/users.js.map +1 -1
- package/dist/define.d.ts +6 -4
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +82 -3
- package/dist/define.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/jwt.js +1 -1
- package/dist/jwt.js.map +1 -1
- package/dist/key-manager.d.ts +76 -0
- package/dist/key-manager.d.ts.map +1 -0
- package/dist/key-manager.js +156 -0
- package/dist/key-manager.js.map +1 -0
- package/dist/server.d.ts +39 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +228 -52
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +53 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/agent-definitions/auth.ts +165 -6
- package/src/agent-definitions/integrations.ts +59 -28
- package/src/agent-definitions/remote-registry.ts +219 -513
- package/src/agent-definitions/users.ts +35 -1
- package/src/define.ts +98 -6
- package/src/index.ts +3 -1
- package/src/jwt.ts +1 -1
- package/src/key-manager.test.ts +273 -0
- package/src/key-manager.ts +257 -0
- package/src/server.test.ts +284 -0
- package/src/server.ts +334 -60
- package/src/types.ts +44 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWKS Key Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages ES256 signing keys with automatic rotation and revocation.
|
|
5
|
+
* Store-agnostic — provide a KeyStore implementation for your DB.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Automatic key rotation on a configurable schedule
|
|
9
|
+
* - Key lifecycle: active → deprecated → revoked → cleaned up
|
|
10
|
+
* - Multi-instance safe: checks DB before rotating (another instance may have already rotated)
|
|
11
|
+
* - Periodic background checks (configurable interval)
|
|
12
|
+
* - Exposes JWKS for /.well-known/jwks.json
|
|
13
|
+
* - Signs JWTs with the active key
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
generateKeyPair,
|
|
18
|
+
exportJWK,
|
|
19
|
+
importJWK,
|
|
20
|
+
SignJWT,
|
|
21
|
+
type JWK,
|
|
22
|
+
} from "jose";
|
|
23
|
+
|
|
24
|
+
// ── Types ──
|
|
25
|
+
|
|
26
|
+
export type KeyStatus = "active" | "deprecated" | "revoked";
|
|
27
|
+
|
|
28
|
+
export interface StoredKey {
|
|
29
|
+
kid: string;
|
|
30
|
+
alg: string;
|
|
31
|
+
status: KeyStatus;
|
|
32
|
+
publicJwk: JWK;
|
|
33
|
+
privateJwk: JWK;
|
|
34
|
+
createdAt: Date;
|
|
35
|
+
expiresAt: Date;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface CachedKey extends StoredKey {
|
|
39
|
+
privateKey: CryptoKey;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pluggable store interface for key persistence.
|
|
44
|
+
* Implement this for your database (CockroachDB, Postgres, SQLite, etc.)
|
|
45
|
+
*/
|
|
46
|
+
export interface KeyStore {
|
|
47
|
+
/** Load all non-revoked keys */
|
|
48
|
+
loadKeys(): Promise<StoredKey[]>;
|
|
49
|
+
/** Insert a new key */
|
|
50
|
+
insertKey(key: StoredKey): Promise<void>;
|
|
51
|
+
/** Set all active keys to deprecated */
|
|
52
|
+
deprecateAllActive(): Promise<void>;
|
|
53
|
+
/** Delete expired keys, return count deleted */
|
|
54
|
+
cleanupExpired(): Promise<number>;
|
|
55
|
+
/**
|
|
56
|
+
* Run operations atomically. Implementations with transaction support
|
|
57
|
+
* should wrap the callback in a DB transaction to ensure rotate() is
|
|
58
|
+
* atomic (load + deprecate + insert + cleanup all succeed or all fail).
|
|
59
|
+
* For stores without real tx support, pass a no-op wrapper that runs fn sequentially.
|
|
60
|
+
*/
|
|
61
|
+
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface KeyManager {
|
|
65
|
+
/** Get the JWKS for /.well-known/jwks.json */
|
|
66
|
+
getJwks(): { keys: JWK[] };
|
|
67
|
+
/** Sign a JWT with the active key */
|
|
68
|
+
signJwt(claims: Record<string, unknown>): Promise<string>;
|
|
69
|
+
/** Force a key rotation */
|
|
70
|
+
rotate(): Promise<void>;
|
|
71
|
+
/** Stop the background rotation check */
|
|
72
|
+
stop(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface KeyManagerOptions {
|
|
76
|
+
/** Key store implementation */
|
|
77
|
+
store: KeyStore;
|
|
78
|
+
/** Issuer URL for the iss claim */
|
|
79
|
+
issuer: string;
|
|
80
|
+
/** How often to check if rotation is needed (default: 5 min) */
|
|
81
|
+
checkIntervalMs?: number;
|
|
82
|
+
/** Max age of an active key before rotation (default: 1 hour) */
|
|
83
|
+
rotationThresholdMs?: number;
|
|
84
|
+
/** How long deprecated keys stay in JWKS for verification (default: 2 hours) */
|
|
85
|
+
keyLifetimeMs?: number;
|
|
86
|
+
/** Token TTL in seconds (default: 300 = 5 min) */
|
|
87
|
+
tokenTtlSeconds?: number;
|
|
88
|
+
/** Enable background key rotation (default: true). Set to false on read-only replicas. */
|
|
89
|
+
enableRotation?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Constants ──
|
|
93
|
+
|
|
94
|
+
const FIVE_MINUTES = 5 * 60 * 1000;
|
|
95
|
+
const ONE_HOUR = 60 * 60 * 1000;
|
|
96
|
+
const TWO_HOURS = 2 * ONE_HOUR;
|
|
97
|
+
const ALG = "ES256";
|
|
98
|
+
|
|
99
|
+
// ── Key generation ──
|
|
100
|
+
|
|
101
|
+
function generateKid(): string {
|
|
102
|
+
return `key-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function generateNewKey(keyLifetimeMs: number): Promise<StoredKey> {
|
|
106
|
+
const { privateKey, publicKey } = await generateKeyPair(ALG, { extractable: true });
|
|
107
|
+
const kid = generateKid();
|
|
108
|
+
|
|
109
|
+
const publicJwk = await exportJWK(publicKey);
|
|
110
|
+
publicJwk.kid = kid;
|
|
111
|
+
publicJwk.alg = ALG;
|
|
112
|
+
publicJwk.use = "sig";
|
|
113
|
+
|
|
114
|
+
const privateJwk = await exportJWK(privateKey);
|
|
115
|
+
privateJwk.kid = kid;
|
|
116
|
+
privateJwk.alg = ALG;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
kid,
|
|
120
|
+
alg: ALG,
|
|
121
|
+
status: "active",
|
|
122
|
+
publicJwk,
|
|
123
|
+
privateJwk,
|
|
124
|
+
createdAt: new Date(),
|
|
125
|
+
expiresAt: new Date(Date.now() + keyLifetimeMs),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function toCachedKey(stored: StoredKey): Promise<CachedKey> {
|
|
130
|
+
const privateKey = await importJWK(stored.privateJwk, ALG) as CryptoKey;
|
|
131
|
+
return { ...stored, privateKey };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Key Manager ──
|
|
135
|
+
|
|
136
|
+
export async function createKeyManager(opts: KeyManagerOptions): Promise<KeyManager> {
|
|
137
|
+
const {
|
|
138
|
+
store,
|
|
139
|
+
issuer,
|
|
140
|
+
checkIntervalMs = FIVE_MINUTES,
|
|
141
|
+
rotationThresholdMs = ONE_HOUR,
|
|
142
|
+
keyLifetimeMs = TWO_HOURS,
|
|
143
|
+
tokenTtlSeconds = 300,
|
|
144
|
+
enableRotation = true,
|
|
145
|
+
} = opts;
|
|
146
|
+
|
|
147
|
+
let keys: CachedKey[] = [];
|
|
148
|
+
|
|
149
|
+
/** Generate a new key, deprecate old ones, cleanup expired, refresh cache — all in one transaction */
|
|
150
|
+
async function rotate(): Promise<void> {
|
|
151
|
+
await store.transaction(async () => {
|
|
152
|
+
const newKey = await generateNewKey(keyLifetimeMs);
|
|
153
|
+
await store.deprecateAllActive();
|
|
154
|
+
await store.insertKey(newKey);
|
|
155
|
+
await store.cleanupExpired();
|
|
156
|
+
const updated = await store.loadKeys();
|
|
157
|
+
keys = await Promise.all(updated.map(toCachedKey));
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Check if rotation is needed and rotate if so — all within a single transaction */
|
|
162
|
+
async function checkAndRotate(): Promise<void> {
|
|
163
|
+
// Quick check against cache — no DB/store hit if key is fresh
|
|
164
|
+
const cached = keys.find((k) => k.status === "active");
|
|
165
|
+
if (cached) {
|
|
166
|
+
const age = Date.now() - cached.createdAt.getTime();
|
|
167
|
+
if (age < rotationThresholdMs) return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Cache says stale (or empty) — take a lock via transaction to check + rotate atomically
|
|
171
|
+
await store.transaction(async () => {
|
|
172
|
+
const stored = await store.loadKeys();
|
|
173
|
+
const active = stored.find((k) => k.status === "active");
|
|
174
|
+
|
|
175
|
+
if (active) {
|
|
176
|
+
const age = Date.now() - active.createdAt.getTime();
|
|
177
|
+
if (age < rotationThresholdMs) {
|
|
178
|
+
// Another instance already rotated — just update our cache
|
|
179
|
+
keys = await Promise.all(stored.map(toCachedKey));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Still stale (or no active key) — rotate within this tx
|
|
185
|
+
const newKey = await generateNewKey(keyLifetimeMs);
|
|
186
|
+
await store.deprecateAllActive();
|
|
187
|
+
await store.insertKey(newKey);
|
|
188
|
+
await store.cleanupExpired();
|
|
189
|
+
|
|
190
|
+
// Refresh cache inside the tx for a consistent read
|
|
191
|
+
const updated = await store.loadKeys();
|
|
192
|
+
keys = await Promise.all(updated.map(toCachedKey));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Initial load + ensure we have at least one key
|
|
197
|
+
// Initial load from store
|
|
198
|
+
const stored = await store.loadKeys();
|
|
199
|
+
keys = await Promise.all(stored.map(toCachedKey));
|
|
200
|
+
if (!keys.some((k) => k.status === "active")) {
|
|
201
|
+
if (enableRotation) {
|
|
202
|
+
await rotate();
|
|
203
|
+
} else {
|
|
204
|
+
// Read-only mode: generate a key in memory only (no store writes)
|
|
205
|
+
// This ensures signJwt works even without rotation enabled
|
|
206
|
+
const newKey = await generateNewKey(keyLifetimeMs);
|
|
207
|
+
keys.push(await toCachedKey(newKey));
|
|
208
|
+
}
|
|
209
|
+
} else if (enableRotation) {
|
|
210
|
+
await checkAndRotate();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Periodic background check (only if rotation enabled)
|
|
214
|
+
const interval = enableRotation
|
|
215
|
+
? setInterval(async () => {
|
|
216
|
+
try {
|
|
217
|
+
await checkAndRotate();
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error("[key-manager] Check/rotation failed:", err);
|
|
220
|
+
}
|
|
221
|
+
}, checkIntervalMs)
|
|
222
|
+
: null;
|
|
223
|
+
|
|
224
|
+
function getActiveKey(): CachedKey {
|
|
225
|
+
const active = keys.find((k) => k.status === "active");
|
|
226
|
+
if (!active) throw new Error("[key-manager] No active signing key");
|
|
227
|
+
return active;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
getJwks(): { keys: JWK[] } {
|
|
232
|
+
return {
|
|
233
|
+
keys: keys
|
|
234
|
+
.filter((k) => k.status !== "revoked")
|
|
235
|
+
.map((k) => k.publicJwk),
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
async signJwt(claims: Record<string, unknown>): Promise<string> {
|
|
240
|
+
const key = getActiveKey();
|
|
241
|
+
return new SignJWT({ ...claims } as any)
|
|
242
|
+
.setProtectedHeader({ alg: ALG, kid: key.kid })
|
|
243
|
+
.setIssuer(issuer)
|
|
244
|
+
.setIssuedAt()
|
|
245
|
+
.setExpirationTime(`${tokenTtlSeconds}s`)
|
|
246
|
+
.sign(key.privateKey);
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async rotate(): Promise<void> {
|
|
250
|
+
await rotate();
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
stop(): void {
|
|
254
|
+
if (interval) clearInterval(interval);
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
createAgentServer,
|
|
4
|
+
createAgentRegistry,
|
|
5
|
+
detectAuth,
|
|
6
|
+
resolveAuth,
|
|
7
|
+
canSeeAgent,
|
|
8
|
+
} from './index';
|
|
9
|
+
import type { AgentDefinition, TrustedIssuer, AgentServer } from './index';
|
|
10
|
+
import { generateKeyPair, exportJWK, SignJWT } from 'jose';
|
|
11
|
+
|
|
12
|
+
// ─── Helpers ─────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function makeAgent(
|
|
15
|
+
path: string,
|
|
16
|
+
opts: Partial<AgentDefinition> = {},
|
|
17
|
+
): AgentDefinition {
|
|
18
|
+
return {
|
|
19
|
+
path,
|
|
20
|
+
entrypoint: 'test',
|
|
21
|
+
tools: [],
|
|
22
|
+
visibility: 'internal',
|
|
23
|
+
config: { name: path.split('/').pop(), supportedActions: ['load'] },
|
|
24
|
+
...opts,
|
|
25
|
+
} as AgentDefinition;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function mcpCall(
|
|
29
|
+
port: number,
|
|
30
|
+
toolName: string,
|
|
31
|
+
args: Record<string, unknown>,
|
|
32
|
+
authToken?: string,
|
|
33
|
+
) {
|
|
34
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
35
|
+
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
|
36
|
+
|
|
37
|
+
const res = await fetch(`http://localhost:${port}`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers,
|
|
40
|
+
body: JSON.stringify({
|
|
41
|
+
jsonrpc: '2.0',
|
|
42
|
+
id: Date.now(),
|
|
43
|
+
method: 'tools/call',
|
|
44
|
+
params: { name: toolName, arguments: args },
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
return res.json() as Promise<any>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseResult(rpc: any): any {
|
|
51
|
+
const text = rpc.result?.content?.[0]?.text;
|
|
52
|
+
return text ? JSON.parse(text) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── E2E: Full server auth flow ──────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// These tests spin up a real createAgentServer, send actual HTTP
|
|
58
|
+
// requests, and verify the complete path:
|
|
59
|
+
// HTTP request → auth resolution → handleToolCall → registry.call → access check
|
|
60
|
+
//
|
|
61
|
+
// This is what actually broke today: authConfig was null → resolveAuth
|
|
62
|
+
// was skipped → trusted issuer tokens were ignored.
|
|
63
|
+
|
|
64
|
+
describe('E2E: createAgentServer with trusted issuers', () => {
|
|
65
|
+
let privateKey: CryptoKey;
|
|
66
|
+
let publicJwk: any;
|
|
67
|
+
let jwksHttpServer: ReturnType<typeof Bun.serve>;
|
|
68
|
+
let server: AgentServer;
|
|
69
|
+
const JWKS_PORT = 19880;
|
|
70
|
+
const SDK_PORT = 19881;
|
|
71
|
+
const ISSUER_URL = `http://localhost:${JWKS_PORT}`;
|
|
72
|
+
const KID = 'test-e2e-key';
|
|
73
|
+
|
|
74
|
+
beforeAll(async () => {
|
|
75
|
+
// 1. Generate ES256 keypair and serve JWKS
|
|
76
|
+
const keyPair = await generateKeyPair('ES256', { extractable: true });
|
|
77
|
+
privateKey = keyPair.privateKey;
|
|
78
|
+
publicJwk = await exportJWK(keyPair.publicKey);
|
|
79
|
+
publicJwk.kid = KID;
|
|
80
|
+
publicJwk.alg = 'ES256';
|
|
81
|
+
publicJwk.use = 'sig';
|
|
82
|
+
|
|
83
|
+
jwksHttpServer = Bun.serve({
|
|
84
|
+
port: JWKS_PORT,
|
|
85
|
+
fetch(req) {
|
|
86
|
+
const url = new URL(req.url);
|
|
87
|
+
if (url.pathname === '/.well-known/jwks.json') {
|
|
88
|
+
return new Response(JSON.stringify({ keys: [publicJwk] }), {
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return new Response('Not found', { status: 404 });
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// 2. Create registry with internal + public agents
|
|
97
|
+
const registry = createAgentRegistry();
|
|
98
|
+
registry.register(makeAgent('/agents/@clock', { visibility: 'internal' }));
|
|
99
|
+
registry.register(makeAgent('/agents/public-bot', { visibility: 'public' }));
|
|
100
|
+
|
|
101
|
+
// 3. Create server with trusted issuer — NO @auth agent registered
|
|
102
|
+
// This is the exact scenario that was broken.
|
|
103
|
+
server = createAgentServer(registry, {
|
|
104
|
+
port: SDK_PORT,
|
|
105
|
+
trustedIssuers: [{
|
|
106
|
+
issuer: ISSUER_URL,
|
|
107
|
+
scopes: ['agents:admin'],
|
|
108
|
+
}],
|
|
109
|
+
});
|
|
110
|
+
await server.start();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterAll(() => {
|
|
114
|
+
server?.stop?.();
|
|
115
|
+
jwksHttpServer?.stop();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
async function signToken(claims: Record<string, unknown> = {}): Promise<string> {
|
|
119
|
+
return new SignJWT({ sub: 'atlas-api', ...claims } as any)
|
|
120
|
+
.setProtectedHeader({ alg: 'ES256', kid: KID })
|
|
121
|
+
.setIssuer(ISSUER_URL)
|
|
122
|
+
.setIssuedAt()
|
|
123
|
+
.setExpirationTime('5m')
|
|
124
|
+
.sign(privateKey);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Core auth flow tests ───────────────────────────────────
|
|
128
|
+
|
|
129
|
+
test('system token → can load internal agent', async () => {
|
|
130
|
+
const token = await signToken();
|
|
131
|
+
const rpc = await mcpCall(SDK_PORT, 'call_agent', {
|
|
132
|
+
request: { action: 'load', path: '/agents/@clock' },
|
|
133
|
+
}, token);
|
|
134
|
+
|
|
135
|
+
const result = parseResult(rpc);
|
|
136
|
+
expect(result.success).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('no token → access denied for internal agent', async () => {
|
|
140
|
+
const rpc = await mcpCall(SDK_PORT, 'call_agent', {
|
|
141
|
+
request: { action: 'load', path: '/agents/@clock' },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const result = parseResult(rpc);
|
|
145
|
+
expect(result.success).toBe(false);
|
|
146
|
+
expect(result.code).toBe('ACCESS_DENIED');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('no token → public agent still accessible', async () => {
|
|
150
|
+
const rpc = await mcpCall(SDK_PORT, 'call_agent', {
|
|
151
|
+
request: { action: 'load', path: '/agents/public-bot' },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result = parseResult(rpc);
|
|
155
|
+
expect(result.success).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('garbage token → access denied', async () => {
|
|
159
|
+
const rpc = await mcpCall(SDK_PORT, 'call_agent', {
|
|
160
|
+
request: { action: 'load', path: '/agents/@clock' },
|
|
161
|
+
}, 'not.a.valid.jwt');
|
|
162
|
+
|
|
163
|
+
const result = parseResult(rpc);
|
|
164
|
+
expect(result.success).toBe(false);
|
|
165
|
+
expect(result.code).toBe('ACCESS_DENIED');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('token with wrong issuer → access denied', async () => {
|
|
169
|
+
// Sign with correct key but wrong iss claim
|
|
170
|
+
const token = await new SignJWT({ sub: 'evil' } as any)
|
|
171
|
+
.setProtectedHeader({ alg: 'ES256', kid: KID })
|
|
172
|
+
.setIssuer('http://evil:9999') // not in trustedIssuers
|
|
173
|
+
.setIssuedAt()
|
|
174
|
+
.setExpirationTime('5m')
|
|
175
|
+
.sign(privateKey);
|
|
176
|
+
|
|
177
|
+
const rpc = await mcpCall(SDK_PORT, 'call_agent', {
|
|
178
|
+
request: { action: 'load', path: '/agents/@clock' },
|
|
179
|
+
}, token);
|
|
180
|
+
|
|
181
|
+
const result = parseResult(rpc);
|
|
182
|
+
expect(result.success).toBe(false);
|
|
183
|
+
expect(result.code).toBe('ACCESS_DENIED');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ─── Visibility in list_agents ─────────────────────────────
|
|
187
|
+
|
|
188
|
+
test('list_agents without token → only public agents', async () => {
|
|
189
|
+
const rpc = await mcpCall(SDK_PORT, 'list_agents', {});
|
|
190
|
+
const result = parseResult(rpc);
|
|
191
|
+
expect(result.success).toBe(true);
|
|
192
|
+
|
|
193
|
+
const paths = result.agents.map((a: any) => a.path);
|
|
194
|
+
expect(paths).toContain('/agents/public-bot');
|
|
195
|
+
expect(paths).not.toContain('/agents/@clock');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('list_agents with system token → all agents visible', async () => {
|
|
199
|
+
const token = await signToken();
|
|
200
|
+
const rpc = await mcpCall(SDK_PORT, 'list_agents', {}, token);
|
|
201
|
+
const result = parseResult(rpc);
|
|
202
|
+
expect(result.success).toBe(true);
|
|
203
|
+
|
|
204
|
+
const paths = result.agents.map((a: any) => a.path);
|
|
205
|
+
expect(paths).toContain('/agents/public-bot');
|
|
206
|
+
expect(paths).toContain('/agents/@clock');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ─── Scopes: limited issuer ────────────────────────────────
|
|
210
|
+
|
|
211
|
+
test('issuer with limited scopes → resolves as agent, not system', async () => {
|
|
212
|
+
// Create a separate server with limited-scope issuer
|
|
213
|
+
const limitedRegistry = createAgentRegistry();
|
|
214
|
+
limitedRegistry.register(makeAgent('/agents/@private-agent', { visibility: 'private' }));
|
|
215
|
+
limitedRegistry.register(makeAgent('/agents/@internal-agent', { visibility: 'internal' }));
|
|
216
|
+
|
|
217
|
+
const limitedServer = createAgentServer(limitedRegistry, {
|
|
218
|
+
port: 19882,
|
|
219
|
+
trustedIssuers: [{
|
|
220
|
+
issuer: ISSUER_URL,
|
|
221
|
+
scopes: ['agents:read'], // NOT agents:admin or *
|
|
222
|
+
}],
|
|
223
|
+
});
|
|
224
|
+
await limitedServer.start();
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const token = await signToken();
|
|
228
|
+
|
|
229
|
+
// agents:read grants agent-level access (not system)
|
|
230
|
+
// Internal agents are accessible to authenticated agents
|
|
231
|
+
const internalRpc = await mcpCall(19882, 'call_agent', {
|
|
232
|
+
request: { action: 'load', path: '/agents/@internal-agent' },
|
|
233
|
+
}, token);
|
|
234
|
+
expect(parseResult(internalRpc).success).toBe(true);
|
|
235
|
+
|
|
236
|
+
// Private agents should be denied (only self can access)
|
|
237
|
+
const privateRpc = await mcpCall(19882, 'call_agent', {
|
|
238
|
+
request: { action: 'load', path: '/agents/@private-agent' },
|
|
239
|
+
}, token);
|
|
240
|
+
expect(parseResult(privateRpc).success).toBe(false);
|
|
241
|
+
expect(parseResult(privateRpc).code).toBe('ACCESS_DENIED');
|
|
242
|
+
} finally {
|
|
243
|
+
limitedServer?.stop?.();
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ─── Unit: detectAuth ────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
describe('detectAuth', () => {
|
|
251
|
+
test('returns non-null even without @auth agent', () => {
|
|
252
|
+
const registry = createAgentRegistry();
|
|
253
|
+
const config = detectAuth(registry);
|
|
254
|
+
expect(config).toBeDefined();
|
|
255
|
+
expect(config).not.toBeNull();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('returns empty config (no store, no rootKey) without @auth agent', () => {
|
|
259
|
+
const registry = createAgentRegistry();
|
|
260
|
+
const config = detectAuth(registry);
|
|
261
|
+
expect(config.store).toBeUndefined();
|
|
262
|
+
expect(config.rootKey).toBeUndefined();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ─── Unit: canSeeAgent ───────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
describe('canSeeAgent', () => {
|
|
269
|
+
test('system auth can see internal agents', () => {
|
|
270
|
+
const agent = makeAgent('/agents/@clock', { visibility: 'internal' });
|
|
271
|
+
const auth = { callerId: 'api', callerType: 'system' as const, scopes: ['*'], isRoot: true };
|
|
272
|
+
expect(canSeeAgent(agent, auth)).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('null auth cannot see internal agents', () => {
|
|
276
|
+
const agent = makeAgent('/agents/@clock', { visibility: 'internal' });
|
|
277
|
+
expect(canSeeAgent(agent, null)).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('null auth can see public agents', () => {
|
|
281
|
+
const agent = makeAgent('/agents/public', { visibility: 'public' });
|
|
282
|
+
expect(canSeeAgent(agent, null)).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
});
|