diff --git a/packages/agents/analysis/src/server.ts b/packages/agents/analysis/src/server.ts index e0bcd66..baed296 100644 --- a/packages/agents/analysis/src/server.ts +++ b/packages/agents/analysis/src/server.ts @@ -7,15 +7,18 @@ import { stellar } from '@stellar/mpp/charge/server'; import { USDC_SAC_TESTNET, STELLAR_TESTNET } from '@stellar/mpp'; import { analyzeWithClaude } from './analyze.js'; import { registerSelf } from './register.js'; +import { validateEnvOrExit } from '@clevercon/common'; + +if (!process.env.VITEST) { + validateEnvOrExit('analysis', { + REGISTRY_URL: { type: 'url' }, + ANALYSIS_AGENT_SECRET_KEY: { type: 'stellarSecret' }, + }); +} const PORT = parseInt(process.env.ANALYSIS_AGENT_PORT || process.env.PORT || '4004'); const SECRET_KEY = process.env.ANALYSIS_AGENT_SECRET_KEY!; -if (!SECRET_KEY) { - console.error('[AnalysisBot] ANALYSIS_AGENT_SECRET_KEY not set'); - process.exit(1); -} - const keypair = Keypair.fromSecret(SECRET_KEY); const PAY_TO = keypair.publicKey(); diff --git a/packages/agents/reporter/src/server.ts b/packages/agents/reporter/src/server.ts index c3e8a7d..e17afa0 100644 --- a/packages/agents/reporter/src/server.ts +++ b/packages/agents/reporter/src/server.ts @@ -7,17 +7,20 @@ import { HTTPFacilitatorClient } from '@x402/core/server'; import { ExactStellarScheme } from '@x402/stellar/exact/server'; import { generateReport } from './report.js'; import { registerSelf } from './register.js'; +import { validateEnvOrExit } from '@clevercon/common'; + +if (!process.env.VITEST) { + validateEnvOrExit('reporter', { + REGISTRY_URL: { type: 'url' }, + REPORT_AGENT_SECRET_KEY: { type: 'stellarSecret' }, + }); +} const PORT = parseInt(process.env.REPORT_AGENT_PORT || process.env.PORT || '4005'); const SECRET_KEY = process.env.REPORT_AGENT_SECRET_KEY!; const FACILITATOR_URL = process.env.X402_FACILITATOR_URL || 'https://www.x402.org/facilitator'; const NETWORK = (process.env.STELLAR_NETWORK || 'stellar:testnet') as `${string}:${string}`; -if (!SECRET_KEY) { - console.error('[ReporterBot] REPORT_AGENT_SECRET_KEY not set'); - process.exit(1); -} - const keypair = Keypair.fromSecret(SECRET_KEY); const PAY_TO = keypair.publicKey(); diff --git a/packages/agents/stellar-oracle/src/server.ts b/packages/agents/stellar-oracle/src/server.ts index 85361f1..d42dfc8 100644 --- a/packages/agents/stellar-oracle/src/server.ts +++ b/packages/agents/stellar-oracle/src/server.ts @@ -15,17 +15,20 @@ import { import { getCacheStats } from './cache.js'; import { getCryptoQuote, getCryptoCandles } from './x402-consumer.js'; import { registerSelf } from './register.js'; +import { validateEnvOrExit } from '@clevercon/common'; + +if (!process.env.VITEST) { + validateEnvOrExit('stellar-oracle', { + REGISTRY_URL: { type: 'url' }, + STELLAR_ORACLE_SECRET_KEY: { type: 'stellarSecret' }, + }); +} const PORT = parseInt(process.env.STELLAR_ORACLE_PORT || process.env.PORT || '4001'); const SECRET_KEY = process.env.STELLAR_ORACLE_SECRET_KEY!; const FACILITATOR_URL = process.env.X402_FACILITATOR_URL || 'https://www.x402.org/facilitator'; const NETWORK = (process.env.STELLAR_NETWORK || 'stellar:testnet') as `${string}:${string}`; -if (!SECRET_KEY) { - console.error('[StellarOracle] STELLAR_ORACLE_SECRET_KEY not set'); - process.exit(1); -} - const keypair = Keypair.fromSecret(SECRET_KEY); const PAY_TO = keypair.publicKey(); diff --git a/packages/agents/web-intel-v2/src/server.ts b/packages/agents/web-intel-v2/src/server.ts index faea417..f259962 100644 --- a/packages/agents/web-intel-v2/src/server.ts +++ b/packages/agents/web-intel-v2/src/server.ts @@ -7,17 +7,20 @@ import { HTTPFacilitatorClient } from '@x402/core/server'; import { ExactStellarScheme } from '@x402/stellar/exact/server'; import { getBlockchainNews } from './news.js'; import { registerSelf } from './register.js'; +import { validateEnvOrExit } from '@clevercon/common'; + +if (!process.env.VITEST) { + validateEnvOrExit('web-intel-v2', { + REGISTRY_URL: { type: 'url' }, + WEB_INTEL_V2_SECRET_KEY: { type: 'stellarSecret' }, + }); +} const PORT = parseInt(process.env.WEB_INTEL_V2_PORT || process.env.PORT || '4003'); const SECRET_KEY = process.env.WEB_INTEL_V2_SECRET_KEY!; const FACILITATOR_URL = process.env.X402_FACILITATOR_URL || 'https://www.x402.org/facilitator'; const NETWORK = (process.env.STELLAR_NETWORK || 'stellar:testnet') as `${string}:${string}`; -if (!SECRET_KEY) { - console.error('[WebIntelligenceV2] WEB_INTEL_V2_SECRET_KEY not set'); - process.exit(1); -} - const keypair = Keypair.fromSecret(SECRET_KEY); const PAY_TO = keypair.publicKey(); diff --git a/packages/agents/web-intel/src/server.ts b/packages/agents/web-intel/src/server.ts index e817ea3..9e5781b 100644 --- a/packages/agents/web-intel/src/server.ts +++ b/packages/agents/web-intel/src/server.ts @@ -9,6 +9,15 @@ import { ExactStellarScheme } from '@x402/stellar/exact/server'; import { getBlockchainNews, getTechNews, getAINews } from './news.js'; import { scrapeUrl } from './scraper.js'; import { registerSelf } from './register.js'; +import { validateEnvOrExit } from '@clevercon/common'; + +if (!process.env.VITEST) { + validateEnvOrExit('web-intel', { + REGISTRY_URL: { type: 'url' }, + WEB_INTEL_SECRET_KEY: { type: 'stellarSecret' }, + ANTHROPIC_API_KEY: { optional: true, description: 'only used for optional Claude summarisation' }, + }); +} const PORT = parseInt(process.env.WEB_INTEL_PORT || process.env.PORT || '4002'); const SECRET_KEY = process.env.WEB_INTEL_SECRET_KEY!; @@ -16,11 +25,6 @@ const FACILITATOR_URL = process.env.X402_FACILITATOR_URL || 'https://www.x402.or const NETWORK = (process.env.STELLAR_NETWORK || 'stellar:testnet') as `${string}:${string}`; const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY; -if (!SECRET_KEY) { - console.error('[WebIntelligence] WEB_INTEL_SECRET_KEY not set'); - process.exit(1); -} - const keypair = Keypair.fromSecret(SECRET_KEY); const PAY_TO = keypair.publicKey(); diff --git a/packages/common/src/env.test.ts b/packages/common/src/env.test.ts new file mode 100644 index 0000000..76a1c33 --- /dev/null +++ b/packages/common/src/env.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest'; +import { requireEnv, EnvValidationError } from './env.js'; + +// A valid 56-char Stellar secret key (S + 55 base32 chars A-Z2-7) used throughout tests. +const VALID_SECRET = 'SCJ4HXIMNDYRBCXE2QHKXCNHMFZKIOZBHGP4M2U7ZUPHD35OSEKTZKXV'; +// A valid 56-char Stellar contract ID (C + 55 base32 chars). +const VALID_CONTRACT = 'CDFLEJ2HFPK3WKFTWB4CKP2JHEYNAUWKXGEJRYW4YMMGDSQSQ7D4LRTE'; + +describe('requireEnv', () => { + // ── Missing required key ──────────────────────────────────────────────────── + + it('throws EnvValidationError naming the key when a single required key is missing', () => { + const source = {}; + expect(() => requireEnv({ MY_VAR: {} }, source)).toThrow(EnvValidationError); + expect(() => requireEnv({ MY_VAR: {} }, source)).toThrow('MY_VAR'); + }); + + // ── Valid shapes return trimmed values ────────────────────────────────────── + + it('accepts a present string value and returns the trimmed result', () => { + const source = { MY_STRING: ' hello world ' }; + const result = requireEnv({ MY_STRING: { type: 'string' } }, source); + expect(result.MY_STRING).toBe('hello world'); + }); + + it('accepts a valid URL and returns the trimmed value', () => { + const source = { MY_URL: ' https://example.com ' }; + const result = requireEnv({ MY_URL: { type: 'url' } }, source); + expect(result.MY_URL).toBe('https://example.com'); + }); + + it('accepts a valid number string and returns the trimmed value', () => { + const source = { MY_NUM: ' 42 ' }; + const result = requireEnv({ MY_NUM: { type: 'number' } }, source); + expect(result.MY_NUM).toBe('42'); + }); + + it('accepts a valid Stellar secret key', () => { + const source = { MY_SECRET: ` ${VALID_SECRET} ` }; + const result = requireEnv({ MY_SECRET: { type: 'stellarSecret' } }, source); + expect(result.MY_SECRET).toBe(VALID_SECRET); + }); + + it('accepts a valid Stellar contract ID', () => { + const source = { MY_CONTRACT: ` ${VALID_CONTRACT} ` }; + const result = requireEnv({ MY_CONTRACT: { type: 'stellarContract' } }, source); + expect(result.MY_CONTRACT).toBe(VALID_CONTRACT); + }); + + // ── Whitespace-only treated as missing ────────────────────────────────────── + + it('treats a whitespace-only value as missing and throws', () => { + const source = { MY_VAR: ' ' }; + const err = (() => { + try { + requireEnv({ MY_VAR: { type: 'string' } }, source); + } catch (e) { + return e; + } + })(); + expect(err).toBeInstanceOf(EnvValidationError); + expect((err as EnvValidationError).issues[0]).toMatch(/MY_VAR/); + }); + + // ── All issues collected in one throw ─────────────────────────────────────── + + it('collects all three missing keys into a single error with three issues', () => { + const source = {}; + let thrown: unknown; + try { + requireEnv({ A: {}, B: {}, C: {} }, source); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(EnvValidationError); + const err = thrown as EnvValidationError; + expect(err.issues).toHaveLength(3); + expect(err.issues.some((i) => i.includes('A'))).toBe(true); + expect(err.issues.some((i) => i.includes('B'))).toBe(true); + expect(err.issues.some((i) => i.includes('C'))).toBe(true); + }); + + // ── Optional keys ─────────────────────────────────────────────────────────── + + it('does not throw when an optional key is missing', () => { + const source = {}; + expect(() => requireEnv({ MY_OPT: { optional: true } }, source)).not.toThrow(); + }); + + // ── LLM_PROVIDER=mock exception pattern ──────────────────────────────────── + + it('passes validation when ANTHROPIC_API_KEY is optional because LLM_PROVIDER=mock', () => { + const source = { LLM_PROVIDER: 'mock' }; + // Simulate the spec built at startup: optional iff LLM_PROVIDER === 'mock' + const spec = { + ANTHROPIC_API_KEY: { optional: source.LLM_PROVIDER === 'mock' }, + }; + expect(() => requireEnv(spec, source)).not.toThrow(); + }); + + it('fails validation when LLM_PROVIDER is not mock and ANTHROPIC_API_KEY is absent', () => { + const source = { LLM_PROVIDER: 'anthropic' }; + const spec = { + ANTHROPIC_API_KEY: { optional: source.LLM_PROVIDER === 'mock' }, + }; + expect(() => requireEnv(spec, source)).toThrow(EnvValidationError); + expect(() => requireEnv(spec, source)).toThrow('ANTHROPIC_API_KEY'); + }); + + it('fails validation when LLM_PROVIDER is unset and ANTHROPIC_API_KEY is absent', () => { + const source = {}; + const spec = { + ANTHROPIC_API_KEY: { optional: source['LLM_PROVIDER' as keyof typeof source] === 'mock' }, + }; + expect(() => requireEnv(spec, source)).toThrow(EnvValidationError); + }); + + // ── Invalid shape values ──────────────────────────────────────────────────── + + it('throws naming the key when a URL value is malformed', () => { + const source = { MY_URL: 'not-a-url' }; + let thrown: unknown; + try { + requireEnv({ MY_URL: { type: 'url' } }, source); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(EnvValidationError); + expect((thrown as EnvValidationError).issues[0]).toMatch(/MY_URL/); + }); + + it('throws naming the key when a number value is non-numeric', () => { + const source = { MY_NUM: 'abc' }; + let thrown: unknown; + try { + requireEnv({ MY_NUM: { type: 'number' } }, source); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(EnvValidationError); + expect((thrown as EnvValidationError).issues[0]).toMatch(/MY_NUM/); + }); + + it('throws naming the key when a stellarSecret value is malformed', () => { + const source = { MY_SECRET: 'not-a-stellar-secret' }; + let thrown: unknown; + try { + requireEnv({ MY_SECRET: { type: 'stellarSecret' } }, source); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(EnvValidationError); + expect((thrown as EnvValidationError).issues[0]).toMatch(/MY_SECRET/); + }); + + it('throws naming the key when a stellarContract value is malformed', () => { + const source = { MY_CONTRACT: 'not-a-stellar-contract' }; + let thrown: unknown; + try { + requireEnv({ MY_CONTRACT: { type: 'stellarContract' } }, source); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(EnvValidationError); + expect((thrown as EnvValidationError).issues[0]).toMatch(/MY_CONTRACT/); + }); +}); diff --git a/packages/common/src/env.ts b/packages/common/src/env.ts new file mode 100644 index 0000000..1462524 --- /dev/null +++ b/packages/common/src/env.ts @@ -0,0 +1,131 @@ +/** + * Fail-fast environment variable validation. + * + * Each service calls `requireEnv(spec)` once at the top of its entrypoint, + * before starting its HTTP server. All violations are collected and thrown + * together as a single `EnvValidationError` so a misconfigured deploy + * reports everything wrong in one shot instead of dying on the first + * `undefined` it happens to touch at runtime. + */ + +export type EnvVarType = 'string' | 'url' | 'number' | 'stellarSecret' | 'stellarContract'; + +export interface EnvVarSpec { + /** Expected shape of the value. Defaults to 'string'. */ + type?: EnvVarType; + /** If true, missing/empty values are allowed and no error is raised. */ + optional?: boolean; + /** Human-readable explanation appended to the error message when invalid. */ + description?: string; +} + +export type EnvSpec = Record; + +export class EnvValidationError extends Error { + readonly issues: string[]; + + constructor(issues: string[]) { + super( + `Environment validation failed with ${issues.length} issue(s):\n` + + issues.map((issue) => ` - ${issue}`).join('\n') + ); + this.name = 'EnvValidationError'; + this.issues = issues; + } +} + +const STELLAR_SECRET_RE = /^S[A-Z2-7]{55}$/; +const STELLAR_CONTRACT_RE = /^C[A-Z2-7]{55}$/; + +function isBlank(value: string | undefined): value is undefined { + return value === undefined || value.trim().length === 0; +} + +function validateShape(key: string, value: string, type: EnvVarType): string | null { + switch (type) { + case 'string': + return null; + case 'url': + try { + new URL(value); + return null; + } catch { + return `${key} must be a valid URL (got "${value}")`; + } + case 'number': + return Number.isFinite(Number(value)) ? null : `${key} must be a number (got "${value}")`; + case 'stellarSecret': + return STELLAR_SECRET_RE.test(value) + ? null + : `${key} must be a valid Stellar secret key starting with "S" (got "${value.slice(0, 4)}...")`; + case 'stellarContract': + return STELLAR_CONTRACT_RE.test(value) + ? null + : `${key} must be a valid Stellar contract ID starting with "C" (got "${value.slice(0, 4)}...")`; + default: + return null; + } +} + +/** + * Validates `process.env` against `spec`, collecting every issue before + * throwing. Returns a trimmed copy of the validated values on success. + * + * Empty and whitespace-only values are treated as missing. + */ +export function requireEnv( + spec: S, + source: NodeJS.ProcessEnv = process.env +): { [K in keyof S]: string } { + const issues: string[] = []; + const result = {} as { [K in keyof S]: string }; + + for (const key of Object.keys(spec) as Array) { + const { type = 'string', optional = false, description } = spec[key]; + const raw = source[key]; + + if (isBlank(raw)) { + if (!optional) { + const suffix = description ? ` (${description})` : ''; + issues.push(`${key} is required but missing or empty${suffix}`); + } + continue; + } + + const trimmed = raw.trim(); + const shapeIssue = validateShape(key, trimmed, type); + if (shapeIssue) { + issues.push(shapeIssue); + continue; + } + + result[key] = trimmed; + } + + if (issues.length > 0) { + throw new EnvValidationError(issues); + } + + return result; +} + +/** + * Runs `requireEnv` and exits the process with code 1 on failure, printing + * an aggregated, human-readable error. Intended for use at the top of each + * service's `server.ts`, before the HTTP listener starts. + */ +export function validateEnvOrExit( + serviceName: string, + spec: S, + source: NodeJS.ProcessEnv = process.env +): { [K in keyof S]: string } { + try { + return requireEnv(spec, source); + } catch (err) { + if (err instanceof EnvValidationError) { + console.error(`[${serviceName}] refusing to start:\n${err.message}`); + process.exit(1); + } + throw err; + } +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index ba5e41e..e9efb5d 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,5 +1,6 @@ export * from './types.js'; export * from './constants.js'; +export * from './env.js'; export * from './logger.js'; export * from './wallet.js'; export * from './write-json-safe.js'; diff --git a/packages/orchestrator/src/server.ts b/packages/orchestrator/src/server.ts index 5e75c3c..f8065c5 100644 --- a/packages/orchestrator/src/server.ts +++ b/packages/orchestrator/src/server.ts @@ -56,9 +56,19 @@ import { getAllTaskResults, } from './task-results.js'; import { getMetrics, seedMetrics, taskStarted, taskCompleted, taskFailed } from './metrics.js'; +import { validateEnvOrExit } from '@clevercon/common'; // ── Config ─────────────────────────────────────────────────────────────────── +if (!process.env.VITEST) { + validateEnvOrExit('orchestrator', { + REGISTRY_URL: { type: 'url' }, + ORCHESTRATOR_SECRET_KEY: { type: 'stellarSecret' }, + ANTHROPIC_API_KEY: { optional: process.env.LLM_PROVIDER === 'mock' }, + AGENT_VAULT_CONTRACT_ID: { optional: true, description: 'vault calls become safe no-ops when unset' }, + }); +} + const PORT = parseInt(process.env.ORCHESTRATOR_PORT || process.env.PORT || '3000'); const REGISTRY_URL = process.env.REGISTRY_URL || 'http://localhost:4000'; const BUDGET_DEFAULT = parseFloat(process.env.DEFAULT_BUDGET || '1.0'); @@ -66,11 +76,6 @@ const SECRET_KEY = process.env.ORCHESTRATOR_SECRET_KEY; // How long to wait for user to approve a plan before auto-approving (ms) const APPROVAL_TIMEOUT_MS = parseInt(process.env.PLAN_APPROVAL_TIMEOUT_MS || '60000'); -if (!SECRET_KEY && !process.env.VITEST) { - console.error('[Orchestrator] ORCHESTRATOR_SECRET_KEY not set'); - process.exit(1); -} - const keypair = Keypair.fromSecret(SECRET_KEY || Keypair.random().secret()); const ORCHESTRATOR_ADDRESS = keypair.publicKey(); diff --git a/packages/registry/src/server.ts b/packages/registry/src/server.ts index 819b497..4ae6ad2 100644 --- a/packages/registry/src/server.ts +++ b/packages/registry/src/server.ts @@ -5,9 +5,13 @@ import { loadAgents, findAgent, upsertAgent, removeAgent } from './store.js'; import { updateReputation } from './reputation.js'; import { matchCapabilities } from './search.js'; import { validateRegistration } from './validate.js'; -import { logger } from '@clevercon/common'; +import { logger, validateEnvOrExit } from '@clevercon/common'; import type { AgentManifest, AgentFeedback, AgentRecord } from '@clevercon/common'; +if (!process.env.VITEST) { + validateEnvOrExit('registry', {}); +} + const app = express(); const PORT = parseInt(process.env.REGISTRY_PORT || process.env.PORT || '4000', 10);