morpheus-cli 0.6.5 → 0.6.6

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/README.md CHANGED
@@ -82,8 +82,11 @@ Minimal `env.docker`:
82
82
  ```env
83
83
  OPENAI_API_KEY=sk-...
84
84
  THE_ARCHITECT_PASS=changeme
85
+ MORPHEUS_SECRET=<generate-a-random-secret>
85
86
  ```
86
87
 
88
+ > **Tip:** Generate a secure `MORPHEUS_SECRET` with: `openssl rand -base64 32` or `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"`
89
+
87
90
  **2. Run:**
88
91
 
89
92
  ```bash
@@ -121,6 +124,7 @@ docker run -d \
121
124
  -p 3333:3333 \
122
125
  -e OPENAI_API_KEY=sk-... \
123
126
  -e THE_ARCHITECT_PASS=changeme \
127
+ -e MORPHEUS_SECRET=<generate-a-random-secret> \
124
128
  -v morpheus_data:/root/.morpheus \
125
129
  morpheus
126
130
  ```
@@ -133,6 +137,7 @@ docker run -d \
133
137
  -p 3333:3333 \
134
138
  -e OPENAI_API_KEY=sk-... \
135
139
  -e THE_ARCHITECT_PASS=changeme \
140
+ -e MORPHEUS_SECRET=<generate-a-random-secret> \
136
141
  -e MORPHEUS_TELEGRAM_ENABLED=true \
137
142
  -e MORPHEUS_TELEGRAM_TOKEN=<bot-token> \
138
143
  -e MORPHEUS_TELEGRAM_ALLOWED_USERS=123456789 \
@@ -148,6 +153,7 @@ docker run -d \
148
153
  -p 3333:3333 \
149
154
  -e OPENAI_API_KEY=sk-... \
150
155
  -e THE_ARCHITECT_PASS=changeme \
156
+ -e MORPHEUS_SECRET=<generate-a-random-secret> \
151
157
  -e MORPHEUS_DISCORD_ENABLED=true \
152
158
  -e MORPHEUS_DISCORD_TOKEN=<bot-token> \
153
159
  -e MORPHEUS_DISCORD_ALLOWED_USERS=987654321 \
@@ -365,7 +371,7 @@ Provider-specific keys:
365
371
  - `THE_ARCHITECT_PASS`
366
372
 
367
373
  Security:
368
- - `MORPHEUS_SECRET` — AES-256-GCM key for encrypting Trinity database passwords (required when using Trinity)
374
+ - `MORPHEUS_SECRET` — AES-256-GCM encryption key for Trinity database passwords and agent API keys. When set, all API keys saved via UI or config file are automatically encrypted at rest.
369
375
 
370
376
  Generic Morpheus overrides (selected):
371
377
 
@@ -7,6 +7,7 @@ import { SQLiteChatMessageHistory } from '../runtime/memory/sqlite.js';
7
7
  import { DisplayManager } from '../runtime/display.js';
8
8
  import { ConfigManager } from '../config/manager.js';
9
9
  import { createTelephonist } from '../runtime/telephonist.js';
10
+ import { getUsableApiKey } from '../runtime/trinity-crypto.js';
10
11
  // ─── Slash Command Definitions ────────────────────────────────────────────────
11
12
  const SLASH_COMMANDS = [
12
13
  new SlashCommandBuilder()
@@ -219,8 +220,8 @@ export class DiscordAdapter {
219
220
  await channel.send('Audio transcription is currently disabled.');
220
221
  return;
221
222
  }
222
- const apiKey = config.audio.apiKey ||
223
- (config.llm.provider === config.audio.provider ? config.llm.api_key : undefined);
223
+ const apiKey = getUsableApiKey(config.audio.apiKey) ||
224
+ (config.llm.provider === config.audio.provider ? getUsableApiKey(config.llm.api_key) : undefined);
224
225
  if (!apiKey) {
225
226
  this.display.log(`Audio transcription failed: No API key for provider '${config.audio.provider}'`, { source: 'Telephonist', level: 'error' });
226
227
  await channel.send(`Audio transcription requires an API key for provider '${config.audio.provider}'.`);
@@ -8,6 +8,7 @@ import { spawn } from 'child_process';
8
8
  import { ConfigManager } from '../config/manager.js';
9
9
  import { DisplayManager } from '../runtime/display.js';
10
10
  import { createTelephonist } from '../runtime/telephonist.js';
11
+ import { getUsableApiKey } from '../runtime/trinity-crypto.js';
11
12
  import { readPid, isProcessRunning, checkStalePid } from '../runtime/lifecycle.js';
12
13
  import { SQLiteChatMessageHistory } from '../runtime/memory/sqlite.js';
13
14
  import { SatiRepository } from '../runtime/memory/sati/repository.js';
@@ -281,8 +282,8 @@ export class TelegramAdapter {
281
282
  await ctx.reply("Audio transcription is currently disabled.");
282
283
  return;
283
284
  }
284
- const apiKey = config.audio.apiKey ||
285
- (config.llm.provider === config.audio.provider ? config.llm.api_key : undefined);
285
+ const apiKey = getUsableApiKey(config.audio.apiKey) ||
286
+ (config.llm.provider === config.audio.provider ? getUsableApiKey(config.llm.api_key) : undefined);
286
287
  if (!apiKey) {
287
288
  this.display.log(`Audio transcription failed: No API key available for provider '${config.audio.provider}'`, { source: 'Telephonist', level: 'error' });
288
289
  await ctx.reply(`Audio transcription requires an API key for provider '${config.audio.provider}'. Please configure \`audio.apiKey\` or use the same provider as your LLM.`);
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import fs from 'fs-extra';
4
+ import path from 'path';
4
5
  import { confirm } from '@inquirer/prompts';
5
6
  import { scaffold } from '../../runtime/scaffold.js';
6
7
  import { DisplayManager } from '../../runtime/display.js';
@@ -21,6 +22,35 @@ import { TaskWorker } from '../../runtime/tasks/worker.js';
21
22
  import { TaskNotifier } from '../../runtime/tasks/notifier.js';
22
23
  import { ChronosWorker } from '../../runtime/chronos/worker.js';
23
24
  import { ChronosRepository } from '../../runtime/chronos/repository.js';
25
+ // Load .env file explicitly in start command
26
+ const envPath = path.join(process.cwd(), '.env');
27
+ if (fs.existsSync(envPath)) {
28
+ try {
29
+ const envConfig = fs.readFileSync(envPath, 'utf-8');
30
+ envConfig.split('\n').forEach(line => {
31
+ const trimmed = line.trim();
32
+ if (!trimmed || trimmed.startsWith('#'))
33
+ return;
34
+ const match = trimmed.match(/^([^=]+)=(.*)$/);
35
+ if (match) {
36
+ const key = match[1].trim();
37
+ let value = match[2].trim();
38
+ // Remove quotes if present
39
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
40
+ value = value.slice(1, -1);
41
+ }
42
+ // Don't overwrite existing env vars
43
+ if (!process.env[key]) {
44
+ process.env[key] = value;
45
+ }
46
+ }
47
+ });
48
+ console.log('[DEBUG] Loaded .env file from:', envPath);
49
+ }
50
+ catch (err) {
51
+ console.error('[WARN] Failed to load .env:', err);
52
+ }
53
+ }
24
54
  export const startCommand = new Command('start')
25
55
  .description('Start the Morpheus agent')
26
56
  .option('--ui', 'Enable web UI', true)
@@ -6,6 +6,8 @@ import { setByPath } from './utils.js';
6
6
  import { ConfigSchema } from './schemas.js';
7
7
  import { migrateConfigFile } from '../runtime/migration.js';
8
8
  import { resolveApiKey, resolveModel, resolveNumeric, resolveString, resolveBoolean, resolveProvider, resolveStringArray } from './precedence.js';
9
+ import { encrypt, safeDecrypt, looksLikeEncrypted, canEncrypt } from '../runtime/trinity-crypto.js';
10
+ import { DisplayManager } from '../runtime/display.js';
9
11
  export class ConfigManager {
10
12
  static instance;
11
13
  config = DEFAULT_CONFIG;
@@ -16,6 +18,89 @@ export class ConfigManager {
16
18
  }
17
19
  return ConfigManager.instance;
18
20
  }
21
+ /**
22
+ * Decrypts API keys in config if they appear to be encrypted.
23
+ * Fail-open: returns original value if decryption fails.
24
+ */
25
+ decryptAgentApiKeys(config) {
26
+ const decrypted = { ...config };
27
+ // Helper to decrypt a single key
28
+ const tryDecrypt = (apiKey) => {
29
+ if (!apiKey)
30
+ return undefined;
31
+ if (!looksLikeEncrypted(apiKey))
32
+ return apiKey;
33
+ const decrypted = safeDecrypt(apiKey);
34
+ return decrypted ?? apiKey; // Return original if decryption fails
35
+ };
36
+ // Decrypt Oracle/LLM
37
+ if (decrypted.llm.api_key) {
38
+ decrypted.llm = { ...decrypted.llm, api_key: tryDecrypt(decrypted.llm.api_key) };
39
+ }
40
+ // Decrypt Sati
41
+ if (decrypted.sati?.api_key) {
42
+ decrypted.sati = { ...decrypted.sati, api_key: tryDecrypt(decrypted.sati.api_key) };
43
+ }
44
+ // Decrypt Apoc
45
+ if (decrypted.apoc?.api_key) {
46
+ decrypted.apoc = { ...decrypted.apoc, api_key: tryDecrypt(decrypted.apoc.api_key) };
47
+ }
48
+ // Decrypt Neo
49
+ if (decrypted.neo?.api_key) {
50
+ decrypted.neo = { ...decrypted.neo, api_key: tryDecrypt(decrypted.neo.api_key) };
51
+ }
52
+ // Decrypt Trinity
53
+ if (decrypted.trinity?.api_key) {
54
+ decrypted.trinity = { ...decrypted.trinity, api_key: tryDecrypt(decrypted.trinity.api_key) };
55
+ }
56
+ // Decrypt Audio (Telephonist)
57
+ if (decrypted.audio?.apiKey) {
58
+ decrypted.audio = { ...decrypted.audio, apiKey: tryDecrypt(decrypted.audio.apiKey) };
59
+ }
60
+ return decrypted;
61
+ }
62
+ /**
63
+ * Encrypts API keys in config if MORPHEUS_SECRET is set.
64
+ */
65
+ encryptAgentApiKeys(config) {
66
+ if (!canEncrypt())
67
+ return config;
68
+ const encrypted = { ...config };
69
+ // Helper to encrypt a single key
70
+ const tryEncrypt = (apiKey) => {
71
+ if (!apiKey)
72
+ return undefined;
73
+ // Don't double-encrypt
74
+ if (looksLikeEncrypted(apiKey))
75
+ return apiKey;
76
+ return encrypt(apiKey);
77
+ };
78
+ // Encrypt Oracle/LLM
79
+ if (encrypted.llm.api_key) {
80
+ encrypted.llm = { ...encrypted.llm, api_key: tryEncrypt(encrypted.llm.api_key) };
81
+ }
82
+ // Encrypt Sati
83
+ if (encrypted.sati?.api_key) {
84
+ encrypted.sati = { ...encrypted.sati, api_key: tryEncrypt(encrypted.sati.api_key) };
85
+ }
86
+ // Encrypt Apoc
87
+ if (encrypted.apoc?.api_key) {
88
+ encrypted.apoc = { ...encrypted.apoc, api_key: tryEncrypt(encrypted.apoc.api_key) };
89
+ }
90
+ // Encrypt Neo
91
+ if (encrypted.neo?.api_key) {
92
+ encrypted.neo = { ...encrypted.neo, api_key: tryEncrypt(encrypted.neo.api_key) };
93
+ }
94
+ // Encrypt Trinity
95
+ if (encrypted.trinity?.api_key) {
96
+ encrypted.trinity = { ...encrypted.trinity, api_key: tryEncrypt(encrypted.trinity.api_key) };
97
+ }
98
+ // Encrypt Audio (Telephonist)
99
+ if (encrypted.audio?.apiKey) {
100
+ encrypted.audio = { ...encrypted.audio, apiKey: tryEncrypt(encrypted.audio.apiKey) };
101
+ }
102
+ return encrypted;
103
+ }
19
104
  async load() {
20
105
  try {
21
106
  await migrateConfigFile();
@@ -222,9 +307,16 @@ export class ConfigManager {
222
307
  }
223
308
  async save(newConfig) {
224
309
  // Deep merge or overwrite? simpler to overwrite for now or merge top level
225
- const updated = { ...this.config, ...newConfig };
310
+ let updated = { ...this.config, ...newConfig };
311
+ // Encrypt API keys before saving if MORPHEUS_SECRET is set
312
+ updated = this.encryptAgentApiKeys(updated);
226
313
  // Validate before saving
227
314
  const valid = ConfigSchema.parse(updated);
315
+ // Warn if saving without encryption
316
+ if (!canEncrypt()) {
317
+ const display = DisplayManager.getInstance();
318
+ display.log('API keys saved in PLAINTEXT. Set MORPHEUS_SECRET environment variable to enable AES-256-GCM encryption.', { source: 'ConfigManager', level: 'warning' });
319
+ }
228
320
  await fs.ensureDir(PATHS.root);
229
321
  await fs.writeFile(PATHS.config, yaml.dump(valid), 'utf8');
230
322
  this.config = valid;
@@ -285,4 +377,28 @@ export class ConfigManager {
285
377
  }
286
378
  return defaults;
287
379
  }
380
+ /**
381
+ * Returns encryption status for all agent API keys.
382
+ */
383
+ getEncryptionStatus() {
384
+ const checkKey = (apiKey) => {
385
+ if (!apiKey)
386
+ return true; // No key = not plaintext
387
+ return looksLikeEncrypted(apiKey);
388
+ };
389
+ const apiKeysEncrypted = {
390
+ oracle: checkKey(this.config.llm.api_key),
391
+ sati: checkKey(this.config.sati?.api_key),
392
+ neo: checkKey(this.config.neo?.api_key),
393
+ apoc: checkKey(this.config.apoc?.api_key),
394
+ trinity: checkKey(this.config.trinity?.api_key),
395
+ audio: checkKey(this.config.audio?.apiKey),
396
+ };
397
+ const hasPlaintextKeys = Object.values(apiKeysEncrypted).some(encrypted => !encrypted);
398
+ return {
399
+ morpheusSecretSet: canEncrypt(),
400
+ apiKeysEncrypted,
401
+ hasPlaintextKeys,
402
+ };
403
+ }
288
404
  }
@@ -136,3 +136,110 @@ export function resolveProvider(genericEnvVar, configFileValue, defaultValue) {
136
136
  }
137
137
  return defaultValue;
138
138
  }
139
+ /**
140
+ * Check if a configuration value is being overridden by an environment variable.
141
+ * Returns true if the value comes from an environment variable (either provider-specific or generic).
142
+ */
143
+ export function isOverriddenByEnv(provider, genericEnvVar, configFileValue) {
144
+ const providerSpecificVars = {
145
+ 'openai': 'OPENAI_API_KEY',
146
+ 'anthropic': 'ANTHROPIC_API_KEY',
147
+ 'openrouter': 'OPENROUTER_API_KEY',
148
+ 'ollama': '',
149
+ 'gemini': 'GOOGLE_API_KEY'
150
+ };
151
+ const providerSpecificVar = providerSpecificVars[provider];
152
+ // Check if provider-specific variable is set
153
+ if (providerSpecificVar && process.env[providerSpecificVar]) {
154
+ return true;
155
+ }
156
+ // Check if generic variable is set
157
+ if (process.env[genericEnvVar]) {
158
+ return true;
159
+ }
160
+ return false;
161
+ }
162
+ /**
163
+ * Check if a generic configuration value is being overridden by an environment variable.
164
+ */
165
+ export function isEnvVarSet(envVarName) {
166
+ return process.env[envVarName] !== undefined && process.env[envVarName] !== '';
167
+ }
168
+ /**
169
+ * Get list of all active environment variable overrides for agent configurations.
170
+ */
171
+ export function getActiveEnvOverrides() {
172
+ const overrides = {};
173
+ // LLM/Oracle
174
+ if (isEnvVarSet('MORPHEUS_LLM_PROVIDER'))
175
+ overrides['llm.provider'] = true;
176
+ if (isEnvVarSet('MORPHEUS_LLM_MODEL'))
177
+ overrides['llm.model'] = true;
178
+ if (isEnvVarSet('MORPHEUS_LLM_TEMPERATURE'))
179
+ overrides['llm.temperature'] = true;
180
+ if (isEnvVarSet('MORPHEUS_LLM_MAX_TOKENS'))
181
+ overrides['llm.max_tokens'] = true;
182
+ if (isEnvVarSet('MORPHEUS_LLM_API_KEY'))
183
+ overrides['llm.api_key'] = true;
184
+ if (isEnvVarSet('MORPHEUS_LLM_CONTEXT_WINDOW'))
185
+ overrides['llm.context_window'] = true;
186
+ // Provider-specific API keys
187
+ if (isEnvVarSet('OPENAI_API_KEY'))
188
+ overrides['llm.api_key_openai'] = true;
189
+ if (isEnvVarSet('ANTHROPIC_API_KEY'))
190
+ overrides['llm.api_key_anthropic'] = true;
191
+ if (isEnvVarSet('GOOGLE_API_KEY'))
192
+ overrides['llm.api_key_gemini'] = true;
193
+ if (isEnvVarSet('OPENROUTER_API_KEY'))
194
+ overrides['llm.api_key_openrouter'] = true;
195
+ // Sati
196
+ if (isEnvVarSet('MORPHEUS_SATI_PROVIDER'))
197
+ overrides['sati.provider'] = true;
198
+ if (isEnvVarSet('MORPHEUS_SATI_MODEL'))
199
+ overrides['sati.model'] = true;
200
+ if (isEnvVarSet('MORPHEUS_SATI_TEMPERATURE'))
201
+ overrides['sati.temperature'] = true;
202
+ if (isEnvVarSet('MORPHEUS_SATI_API_KEY'))
203
+ overrides['sati.api_key'] = true;
204
+ // Neo
205
+ if (isEnvVarSet('MORPHEUS_NEO_PROVIDER'))
206
+ overrides['neo.provider'] = true;
207
+ if (isEnvVarSet('MORPHEUS_NEO_MODEL'))
208
+ overrides['neo.model'] = true;
209
+ if (isEnvVarSet('MORPHEUS_NEO_TEMPERATURE'))
210
+ overrides['neo.temperature'] = true;
211
+ if (isEnvVarSet('MORPHEUS_NEO_API_KEY'))
212
+ overrides['neo.api_key'] = true;
213
+ // Apoc
214
+ if (isEnvVarSet('MORPHEUS_APOC_PROVIDER'))
215
+ overrides['apoc.provider'] = true;
216
+ if (isEnvVarSet('MORPHEUS_APOC_MODEL'))
217
+ overrides['apoc.model'] = true;
218
+ if (isEnvVarSet('MORPHEUS_APOC_TEMPERATURE'))
219
+ overrides['apoc.temperature'] = true;
220
+ if (isEnvVarSet('MORPHEUS_APOC_API_KEY'))
221
+ overrides['apoc.api_key'] = true;
222
+ if (isEnvVarSet('MORPHEUS_APOC_WORKING_DIR'))
223
+ overrides['apoc.working_dir'] = true;
224
+ if (isEnvVarSet('MORPHEUS_APOC_TIMEOUT_MS'))
225
+ overrides['apoc.timeout_ms'] = true;
226
+ // Trinity
227
+ if (isEnvVarSet('MORPHEUS_TRINITY_PROVIDER'))
228
+ overrides['trinity.provider'] = true;
229
+ if (isEnvVarSet('MORPHEUS_TRINITY_MODEL'))
230
+ overrides['trinity.model'] = true;
231
+ if (isEnvVarSet('MORPHEUS_TRINITY_TEMPERATURE'))
232
+ overrides['trinity.temperature'] = true;
233
+ if (isEnvVarSet('MORPHEUS_TRINITY_API_KEY'))
234
+ overrides['trinity.api_key'] = true;
235
+ // Audio
236
+ if (isEnvVarSet('MORPHEUS_AUDIO_PROVIDER'))
237
+ overrides['audio.provider'] = true;
238
+ if (isEnvVarSet('MORPHEUS_AUDIO_MODEL'))
239
+ overrides['audio.model'] = true;
240
+ if (isEnvVarSet('MORPHEUS_AUDIO_API_KEY'))
241
+ overrides['audio.apiKey'] = true;
242
+ if (isEnvVarSet('MORPHEUS_AUDIO_MAX_DURATION'))
243
+ overrides['audio.maxDurationSeconds'] = true;
244
+ return overrides;
245
+ }
package/dist/http/api.js CHANGED
@@ -18,6 +18,7 @@ import { Trinity } from '../runtime/trinity.js';
18
18
  import { ChronosRepository } from '../runtime/chronos/repository.js';
19
19
  import { ChronosWorker } from '../runtime/chronos/worker.js';
20
20
  import { createChronosJobRouter, createChronosConfigRouter } from './routers/chronos.js';
21
+ import { getActiveEnvOverrides } from '../config/precedence.js';
21
22
  async function readLastLines(filePath, n) {
22
23
  try {
23
24
  const content = await fs.readFile(filePath, 'utf8');
@@ -813,6 +814,34 @@ export function createApiRouter(oracle, chronosWorker) {
813
814
  res.status(500).json({ error: error.message });
814
815
  }
815
816
  });
817
+ // ─── Encryption Status ─────────────────────────────────────────────────────
818
+ router.get('/config/encryption-status', (req, res) => {
819
+ try {
820
+ const status = configManager.getEncryptionStatus();
821
+ res.json(status);
822
+ }
823
+ catch (error) {
824
+ res.status(500).json({ error: error.message });
825
+ }
826
+ });
827
+ // ─── Environment Variable Overrides ────────────────────────────────────────
828
+ router.get('/config/env-overrides', (req, res) => {
829
+ try {
830
+ const overrides = getActiveEnvOverrides();
831
+ // Debug log to see what's being detected
832
+ // console.log('[DEBUG] Env overrides:', JSON.stringify(overrides, null, 2));
833
+ // console.log('[DEBUG] Sample env vars:', {
834
+ // MORPHEUS_LLM_PROVIDER: !!process.env.MORPHEUS_LLM_PROVIDER,
835
+ // MORPHEUS_LLM_MODEL: !!process.env.MORPHEUS_LLM_MODEL,
836
+ // OPENAI_API_KEY: !!process.env.OPENAI_API_KEY,
837
+ // });
838
+ res.json(overrides);
839
+ }
840
+ catch (error) {
841
+ console.error('[ERROR] Failed to get env overrides:', error);
842
+ res.status(500).json({ error: error.message });
843
+ }
844
+ });
816
845
  // ─── Trinity Databases CRUD ─────────────────────────────────────────────────
817
846
  const DatabaseCreateSchema = z.object({
818
847
  name: z.string().min(1).max(100),
@@ -5,6 +5,7 @@ import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
5
5
  import { ProviderError } from "../errors.js";
6
6
  import { createAgent, createMiddleware } from "langchain";
7
7
  import { DisplayManager } from "../display.js";
8
+ import { getUsableApiKey } from "../trinity-crypto.js";
8
9
  export class ProviderFactory {
9
10
  static buildMonitoringMiddleware() {
10
11
  const display = DisplayManager.getInstance();
@@ -26,24 +27,25 @@ export class ProviderFactory {
26
27
  });
27
28
  }
28
29
  static buildModel(config) {
30
+ const usableApiKey = getUsableApiKey(config.api_key);
29
31
  switch (config.provider) {
30
32
  case 'openai':
31
33
  return new ChatOpenAI({
32
34
  modelName: config.model,
33
35
  temperature: config.temperature,
34
- apiKey: process.env.OPENAI_API_KEY || config.api_key,
36
+ apiKey: process.env.OPENAI_API_KEY || usableApiKey,
35
37
  });
36
38
  case 'anthropic':
37
39
  return new ChatAnthropic({
38
40
  modelName: config.model,
39
41
  temperature: config.temperature,
40
- apiKey: process.env.ANTHROPIC_API_KEY || config.api_key,
42
+ apiKey: process.env.ANTHROPIC_API_KEY || usableApiKey,
41
43
  });
42
44
  case 'openrouter':
43
45
  return new ChatOpenAI({
44
46
  modelName: config.model,
45
47
  temperature: config.temperature,
46
- apiKey: process.env.OPENROUTER_API_KEY || config.api_key,
48
+ apiKey: process.env.OPENROUTER_API_KEY || usableApiKey,
47
49
  configuration: {
48
50
  baseURL: config.base_url || 'https://openrouter.ai/api/v1'
49
51
  }
@@ -52,13 +54,13 @@ export class ProviderFactory {
52
54
  return new ChatOllama({
53
55
  model: config.model,
54
56
  temperature: config.temperature,
55
- baseUrl: config.base_url || config.api_key,
57
+ baseUrl: config.base_url || usableApiKey,
56
58
  });
57
59
  case 'gemini':
58
60
  return new ChatGoogleGenerativeAI({
59
61
  model: config.model,
60
62
  temperature: config.temperature,
61
- apiKey: process.env.GOOGLE_API_KEY || config.api_key
63
+ apiKey: process.env.GOOGLE_API_KEY || usableApiKey
62
64
  });
63
65
  default:
64
66
  throw new Error(`Unsupported provider: ${config.provider}`);
@@ -50,3 +50,54 @@ export function decrypt(ciphertext) {
50
50
  export function canEncrypt() {
51
51
  return !!process.env.MORPHEUS_SECRET;
52
52
  }
53
+ /**
54
+ * Checks if a string appears to be an encrypted value.
55
+ * Format: base64(iv):base64(authTag):base64(ciphertext)
56
+ */
57
+ export function looksLikeEncrypted(value) {
58
+ const parts = value.split(':');
59
+ if (parts.length !== 3)
60
+ return false;
61
+ // Each part should be valid base64
62
+ return parts.every(part => {
63
+ try {
64
+ const decoded = Buffer.from(part, 'base64');
65
+ return decoded.toString('base64') === part;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ });
71
+ }
72
+ /**
73
+ * Safely decrypts a value, returning null if decryption fails.
74
+ * Use this for fail-open scenarios where MORPHEUS_SECRET may not be set.
75
+ */
76
+ export function safeDecrypt(ciphertext) {
77
+ if (!ciphertext)
78
+ return null;
79
+ try {
80
+ return decrypt(ciphertext);
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ /**
87
+ * Gets the usable API key from a potentially encrypted value.
88
+ * If the value looks encrypted, attempts to decrypt it.
89
+ * If decryption fails or MORPHEUS_SECRET is not set, returns the original value.
90
+ * Use this when you need the actual plaintext key for API calls.
91
+ */
92
+ export function getUsableApiKey(encryptedOrPlain) {
93
+ if (!encryptedOrPlain)
94
+ return undefined;
95
+ // If it looks encrypted, try to decrypt
96
+ if (looksLikeEncrypted(encryptedOrPlain)) {
97
+ const decrypted = safeDecrypt(encryptedOrPlain);
98
+ // If decryption succeeded, return it; otherwise return original
99
+ return decrypted ?? encryptedOrPlain;
100
+ }
101
+ // Not encrypted, return as-is
102
+ return encryptedOrPlain;
103
+ }