@exagent/agent 0.2.1 → 0.3.1

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/src/config.ts CHANGED
@@ -1,4 +1,7 @@
1
- import { readFileSync, existsSync, writeFileSync } from 'node:fs';
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, resolve } from 'node:path';
2
5
  import { z } from 'zod';
3
6
  import type { LLMProvider } from '@exagent/sdk';
4
7
 
@@ -19,7 +22,14 @@ export interface RuntimeConfig {
19
22
  };
20
23
  strategy: {
21
24
  file?: string;
25
+ code?: string;
22
26
  template?: string;
27
+ venues?: string[];
28
+ prompt?: {
29
+ name?: string;
30
+ systemPrompt: string;
31
+ venues?: string[];
32
+ };
23
33
  };
24
34
  trading: {
25
35
  mode: 'live' | 'paper';
@@ -79,15 +89,43 @@ export interface RuntimeConfig {
79
89
  };
80
90
  }
81
91
 
82
- const configSchema = z.object({
92
+ export interface LocalSecretPayload {
93
+ apiToken: string;
94
+ walletPrivateKey?: string;
95
+ llmApiKey?: string;
96
+ }
97
+
98
+ export interface SecureStoreFile {
99
+ version: 1;
100
+ algorithm: 'aes-256-gcm';
101
+ kdf: {
102
+ name: 'scrypt';
103
+ salt: string;
104
+ keyLength: 32;
105
+ cost: number;
106
+ blockSize: number;
107
+ parallelization: number;
108
+ };
109
+ iv: string;
110
+ ciphertext: string;
111
+ authTag: string;
112
+ }
113
+
114
+ export interface LoadConfigOptions {
115
+ getSecretPassword?: () => Promise<string>;
116
+ }
117
+
118
+ const providerEnum = z.enum(['openai', 'anthropic', 'google', 'deepseek', 'mistral', 'groq', 'together', 'ollama']);
119
+
120
+ const runtimeSchema = z.object({
83
121
  agentId: z.string(),
84
122
  apiUrl: z.string().url(),
85
- apiToken: z.string(),
123
+ apiToken: z.string().min(1),
86
124
  wallet: z.object({
87
- privateKey: z.string(),
125
+ privateKey: z.string().regex(/^0x[a-fA-F0-9]{64}$/),
88
126
  }).optional(),
89
127
  llm: z.object({
90
- provider: z.enum(['openai', 'anthropic', 'google', 'deepseek', 'mistral', 'groq', 'together', 'ollama']),
128
+ provider: providerEnum,
91
129
  model: z.string().optional(),
92
130
  apiKey: z.string().optional(),
93
131
  endpoint: z.string().optional(),
@@ -96,7 +134,14 @@ const configSchema = z.object({
96
134
  }),
97
135
  strategy: z.object({
98
136
  file: z.string().optional(),
137
+ code: z.string().optional(),
99
138
  template: z.string().optional(),
139
+ venues: z.array(z.string()).optional(),
140
+ prompt: z.object({
141
+ name: z.string().optional(),
142
+ systemPrompt: z.string().min(1),
143
+ venues: z.array(z.string()).optional(),
144
+ }).optional(),
100
145
  }),
101
146
  trading: z.object({
102
147
  mode: z.enum(['live', 'paper']).default('paper'),
@@ -156,23 +201,174 @@ const configSchema = z.object({
156
201
  }).optional(),
157
202
  });
158
203
 
159
- export function loadConfig(path: string = 'agent-config.json'): RuntimeConfig {
204
+ const configFileSchema = z.object({
205
+ agentId: z.string(),
206
+ apiUrl: z.string().url(),
207
+ apiToken: z.string().min(1).optional(),
208
+ wallet: z.object({
209
+ privateKey: z.string().regex(/^0x[a-fA-F0-9]{64}$/),
210
+ }).optional(),
211
+ llm: z.object({
212
+ provider: providerEnum.optional(),
213
+ model: z.string().optional(),
214
+ apiKey: z.string().optional(),
215
+ endpoint: z.string().optional(),
216
+ temperature: z.number().min(0).max(2).optional(),
217
+ maxTokens: z.number().optional(),
218
+ }).default({}),
219
+ strategy: z.object({
220
+ file: z.string().optional(),
221
+ code: z.string().optional(),
222
+ template: z.string().optional(),
223
+ venues: z.array(z.string()).optional(),
224
+ prompt: z.object({
225
+ name: z.string().optional(),
226
+ systemPrompt: z.string().min(1),
227
+ venues: z.array(z.string()).optional(),
228
+ }).optional(),
229
+ }),
230
+ trading: runtimeSchema.shape.trading,
231
+ venues: runtimeSchema.shape.venues,
232
+ relay: runtimeSchema.shape.relay,
233
+ llmBudget: runtimeSchema.shape.llmBudget,
234
+ rpcOverrides: runtimeSchema.shape.rpcOverrides,
235
+ logging: runtimeSchema.shape.logging,
236
+ secrets: z.object({
237
+ bootstrapToken: z.string().optional(),
238
+ bootstrapExpiresAt: z.string().optional(),
239
+ secureStorePath: z.string().optional(),
240
+ }).optional(),
241
+ });
242
+
243
+ const secureStoreSchema = z.object({
244
+ version: z.literal(1),
245
+ algorithm: z.literal('aes-256-gcm'),
246
+ kdf: z.object({
247
+ name: z.literal('scrypt'),
248
+ salt: z.string(),
249
+ keyLength: z.literal(32),
250
+ cost: z.number().int().positive(),
251
+ blockSize: z.number().int().positive(),
252
+ parallelization: z.number().int().positive(),
253
+ }),
254
+ iv: z.string(),
255
+ ciphertext: z.string(),
256
+ authTag: z.string(),
257
+ });
258
+
259
+ export type RuntimeConfigFile = z.infer<typeof configFileSchema>;
260
+
261
+ const DEFAULT_SCRYPT_COST = 32768;
262
+ const DEFAULT_SCRYPT_BLOCK_SIZE = 8;
263
+ const DEFAULT_SCRYPT_PARALLELIZATION = 1;
264
+
265
+ function expandHomeDir(path: string): string {
266
+ if (!path.startsWith('~/')) return path;
267
+ return resolve(homedir(), path.slice(2));
268
+ }
269
+
270
+ export function getDefaultSecureStorePath(agentId: string): string {
271
+ return resolve(homedir(), '.exagent', 'agents', agentId, 'secrets.json');
272
+ }
273
+
274
+ export function readConfigFile(path: string = 'agent-config.json'): RuntimeConfigFile {
160
275
  if (!existsSync(path)) {
161
276
  throw new Error(`Config file not found: ${path}. Run 'exagent init' first.`);
162
277
  }
163
278
 
164
- const raw = readFileSync(path, 'utf-8');
165
279
  let parsed: unknown;
166
-
167
280
  try {
168
- parsed = JSON.parse(raw);
281
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
169
282
  } catch {
170
283
  throw new Error(`Invalid JSON in ${path}`);
171
284
  }
172
285
 
173
- // Apply environment variable overrides
174
- const config = parsed as Record<string, unknown>;
175
- const llm = (config.llm || {}) as Record<string, unknown>;
286
+ const result = configFileSchema.safeParse(parsed);
287
+ if (!result.success) {
288
+ const issues = result.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n');
289
+ throw new Error(`Invalid config file:\n${issues}`);
290
+ }
291
+
292
+ return result.data;
293
+ }
294
+
295
+ export function writeConfigFile(path: string, config: RuntimeConfigFile): void {
296
+ writeFileSync(path, JSON.stringify(config, null, 2));
297
+ }
298
+
299
+ export function encryptSecretPayload(payload: LocalSecretPayload, password: string): SecureStoreFile {
300
+ const salt = randomBytes(16);
301
+ const iv = randomBytes(12);
302
+ const key = scryptSync(password, salt, 32, {
303
+ N: DEFAULT_SCRYPT_COST,
304
+ r: DEFAULT_SCRYPT_BLOCK_SIZE,
305
+ p: DEFAULT_SCRYPT_PARALLELIZATION,
306
+ });
307
+
308
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
309
+ const plaintext = Buffer.from(JSON.stringify(payload), 'utf8');
310
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
311
+
312
+ return {
313
+ version: 1,
314
+ algorithm: 'aes-256-gcm',
315
+ kdf: {
316
+ name: 'scrypt',
317
+ salt: salt.toString('hex'),
318
+ keyLength: 32,
319
+ cost: DEFAULT_SCRYPT_COST,
320
+ blockSize: DEFAULT_SCRYPT_BLOCK_SIZE,
321
+ parallelization: DEFAULT_SCRYPT_PARALLELIZATION,
322
+ },
323
+ iv: iv.toString('hex'),
324
+ ciphertext: ciphertext.toString('hex'),
325
+ authTag: cipher.getAuthTag().toString('hex'),
326
+ };
327
+ }
328
+
329
+ export function decryptSecretPayload(path: string, password: string): LocalSecretPayload {
330
+ const secureStorePath = expandHomeDir(path);
331
+ if (!existsSync(secureStorePath)) {
332
+ throw new Error(`Encrypted secret store not found: ${secureStorePath}`);
333
+ }
334
+
335
+ let parsed: unknown;
336
+ try {
337
+ parsed = JSON.parse(readFileSync(secureStorePath, 'utf-8'));
338
+ } catch {
339
+ throw new Error(`Invalid JSON in encrypted secret store: ${secureStorePath}`);
340
+ }
341
+
342
+ const result = secureStoreSchema.safeParse(parsed);
343
+ if (!result.success) {
344
+ const issues = result.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n');
345
+ throw new Error(`Invalid encrypted secret store:\n${issues}`);
346
+ }
347
+
348
+ const store = result.data;
349
+ const key = scryptSync(password, Buffer.from(store.kdf.salt, 'hex'), store.kdf.keyLength, {
350
+ N: store.kdf.cost,
351
+ r: store.kdf.blockSize,
352
+ p: store.kdf.parallelization,
353
+ });
354
+
355
+ try {
356
+ const decipher = createDecipheriv(store.algorithm, key, Buffer.from(store.iv, 'hex'));
357
+ decipher.setAuthTag(Buffer.from(store.authTag, 'hex'));
358
+ const plaintext = Buffer.concat([
359
+ decipher.update(Buffer.from(store.ciphertext, 'hex')),
360
+ decipher.final(),
361
+ ]);
362
+ return JSON.parse(plaintext.toString('utf8')) as LocalSecretPayload;
363
+ } catch {
364
+ throw new Error('Unable to decrypt local secret store. Check your password.');
365
+ }
366
+ }
367
+
368
+ export async function loadConfig(path: string = 'agent-config.json', options: LoadConfigOptions = {}): Promise<RuntimeConfig> {
369
+ const parsed = readConfigFile(path);
370
+ const config = structuredClone(parsed) as RuntimeConfigFile & Record<string, unknown>;
371
+ const llm = { ...(config.llm || {}) } as Record<string, unknown>;
176
372
 
177
373
  if (process.env.EXAGENT_LLM_PROVIDER) llm.provider = process.env.EXAGENT_LLM_PROVIDER;
178
374
  if (process.env.EXAGENT_LLM_MODEL) llm.model = process.env.EXAGENT_LLM_MODEL;
@@ -183,9 +379,24 @@ export function loadConfig(path: string = 'agent-config.json'): RuntimeConfig {
183
379
  config.wallet = { privateKey: process.env.EXAGENT_WALLET_PRIVATE_KEY };
184
380
  }
185
381
 
382
+ if ((!config.apiToken || !llm.apiKey || !config.wallet) && parsed.secrets?.secureStorePath) {
383
+ const password = process.env.EXAGENT_SECRET_PASSWORD || await options.getSecretPassword?.();
384
+ if (!password) {
385
+ throw new Error('Encrypted secret store found, but no password was provided.');
386
+ }
387
+
388
+ const secrets = decryptSecretPayload(parsed.secrets.secureStorePath, password);
389
+ if (!config.apiToken) config.apiToken = secrets.apiToken;
390
+ if (!config.wallet && secrets.walletPrivateKey) config.wallet = { privateKey: secrets.walletPrivateKey };
391
+ if (!llm.apiKey && secrets.llmApiKey) llm.apiKey = secrets.llmApiKey;
392
+ }
393
+
394
+ if ((!config.apiToken || !llm.apiKey || !config.wallet) && parsed.secrets?.bootstrapToken && !parsed.secrets?.secureStorePath) {
395
+ throw new Error(`Config ${path} still requires first-time secure setup. Run 'exagent setup --config ${path}' or start the agent interactively.`);
396
+ }
397
+
186
398
  config.llm = llm;
187
399
 
188
- // Apply RPC endpoint overrides from environment
189
400
  const rpcOverrides: Record<string, string> = (config.rpcOverrides as Record<string, string>) || {};
190
401
  if (process.env.EXAGENT_RPC_BASE) rpcOverrides.base = process.env.EXAGENT_RPC_BASE;
191
402
  if (process.env.EXAGENT_RPC_ARBITRUM) rpcOverrides.arbitrum = process.env.EXAGENT_RPC_ARBITRUM;
@@ -195,9 +406,9 @@ export function loadConfig(path: string = 'agent-config.json'): RuntimeConfig {
195
406
  config.rpcOverrides = rpcOverrides;
196
407
  }
197
408
 
198
- const result = configSchema.safeParse(config);
409
+ const result = runtimeSchema.safeParse(config);
199
410
  if (!result.success) {
200
- const issues = result.error.issues.map(i => ` ${i.path.join('.')}: ${i.message}`).join('\n');
411
+ const issues = result.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n');
201
412
  throw new Error(`Invalid config:\n${issues}`);
202
413
  }
203
414
 
@@ -205,18 +416,10 @@ export function loadConfig(path: string = 'agent-config.json'): RuntimeConfig {
205
416
  }
206
417
 
207
418
  export function generateSampleConfig(agentId: string, apiUrl: string): string {
208
- const config = {
419
+ const config: RuntimeConfigFile = {
209
420
  agentId,
210
421
  apiUrl,
211
- apiToken: '<your-jwt-token>',
212
- wallet: {
213
- privateKey: '<your-hex-private-key>',
214
- },
215
- llm: {
216
- provider: 'openai',
217
- model: 'gpt-4o',
218
- apiKey: '<your-api-key>',
219
- },
422
+ llm: {},
220
423
  strategy: {
221
424
  template: 'momentum',
222
425
  },
@@ -266,6 +469,9 @@ export function generateSampleConfig(agentId: string, apiUrl: string): string {
266
469
  heartbeatIntervalMs: 30000,
267
470
  reconnectMaxAttempts: 50,
268
471
  },
472
+ secrets: {
473
+ secureStorePath: getDefaultSecureStorePath(agentId),
474
+ },
269
475
  };
270
476
 
271
477
  return JSON.stringify(config, null, 2);