@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.
Files changed (48) hide show
  1. package/dist/agent-definitions/auth.d.ts +17 -1
  2. package/dist/agent-definitions/auth.d.ts.map +1 -1
  3. package/dist/agent-definitions/auth.js +123 -4
  4. package/dist/agent-definitions/auth.js.map +1 -1
  5. package/dist/agent-definitions/integrations.d.ts +2 -14
  6. package/dist/agent-definitions/integrations.d.ts.map +1 -1
  7. package/dist/agent-definitions/integrations.js +64 -19
  8. package/dist/agent-definitions/integrations.js.map +1 -1
  9. package/dist/agent-definitions/remote-registry.d.ts +19 -14
  10. package/dist/agent-definitions/remote-registry.d.ts.map +1 -1
  11. package/dist/agent-definitions/remote-registry.js +219 -381
  12. package/dist/agent-definitions/remote-registry.js.map +1 -1
  13. package/dist/agent-definitions/users.d.ts.map +1 -1
  14. package/dist/agent-definitions/users.js +29 -1
  15. package/dist/agent-definitions/users.js.map +1 -1
  16. package/dist/define.d.ts +6 -4
  17. package/dist/define.d.ts.map +1 -1
  18. package/dist/define.js +82 -3
  19. package/dist/define.js.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/jwt.js +1 -1
  25. package/dist/jwt.js.map +1 -1
  26. package/dist/key-manager.d.ts +76 -0
  27. package/dist/key-manager.d.ts.map +1 -0
  28. package/dist/key-manager.js +156 -0
  29. package/dist/key-manager.js.map +1 -0
  30. package/dist/server.d.ts +39 -0
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/server.js +228 -52
  33. package/dist/server.js.map +1 -1
  34. package/dist/types.d.ts +53 -1
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/agent-definitions/auth.ts +165 -6
  38. package/src/agent-definitions/integrations.ts +59 -28
  39. package/src/agent-definitions/remote-registry.ts +219 -513
  40. package/src/agent-definitions/users.ts +35 -1
  41. package/src/define.ts +98 -6
  42. package/src/index.ts +3 -1
  43. package/src/jwt.ts +1 -1
  44. package/src/key-manager.test.ts +273 -0
  45. package/src/key-manager.ts +257 -0
  46. package/src/server.test.ts +284 -0
  47. package/src/server.ts +334 -60
  48. 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
+ });