morpheus-cli 0.6.5 → 0.6.7
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 +7 -1
- package/dist/channels/discord.js +3 -2
- package/dist/channels/telegram.js +3 -2
- package/dist/cli/commands/start.js +30 -0
- package/dist/config/manager.js +117 -1
- package/dist/config/precedence.js +107 -0
- package/dist/http/api.js +29 -0
- package/dist/runtime/providers/factory.js +7 -5
- package/dist/runtime/tools/morpheus-tools.js +99 -26
- package/dist/runtime/trinity-crypto.js +51 -0
- package/dist/ui/assets/index-B9ngtbja.css +1 -0
- package/dist/ui/assets/index-CfCRfIwg.js +111 -0
- package/dist/ui/index.html +2 -2
- package/dist/ui/sw.js +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/index-BDWWF6gM.css +0 -1
- package/dist/ui/assets/index-DTUgtIcM.js +0 -111
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
|
|
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
|
|
package/dist/channels/discord.js
CHANGED
|
@@ -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)
|
package/dist/config/manager.js
CHANGED
|
@@ -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
|
-
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
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 ||
|
|
63
|
+
apiKey: process.env.GOOGLE_API_KEY || usableApiKey
|
|
62
64
|
});
|
|
63
65
|
default:
|
|
64
66
|
throw new Error(`Unsupported provider: ${config.provider}`);
|
|
@@ -7,8 +7,58 @@ import { homedir } from "os";
|
|
|
7
7
|
import Database from "better-sqlite3";
|
|
8
8
|
import { TaskRepository } from "../tasks/repository.js";
|
|
9
9
|
import { TaskRequestContext } from "../tasks/context.js";
|
|
10
|
+
import { isEnvVarSet } from "../../config/precedence.js";
|
|
10
11
|
// ─── Shared ───────────────────────────────────────────────────────────────────
|
|
11
12
|
const shortMemoryDbPath = path.join(homedir(), ".morpheus", "memory", "short-memory.db");
|
|
13
|
+
/**
|
|
14
|
+
* Map of config paths to their corresponding environment variable names.
|
|
15
|
+
* Used to check if a config field is being overridden by an env var.
|
|
16
|
+
*/
|
|
17
|
+
const CONFIG_TO_ENV_MAP = {
|
|
18
|
+
'llm.provider': ['MORPHEUS_LLM_PROVIDER'],
|
|
19
|
+
'llm.model': ['MORPHEUS_LLM_MODEL'],
|
|
20
|
+
'llm.temperature': ['MORPHEUS_LLM_TEMPERATURE'],
|
|
21
|
+
'llm.max_tokens': ['MORPHEUS_LLM_MAX_TOKENS'],
|
|
22
|
+
'llm.api_key': ['MORPHEUS_LLM_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'GOOGLE_API_KEY', 'OPENROUTER_API_KEY'],
|
|
23
|
+
'llm.context_window': ['MORPHEUS_LLM_CONTEXT_WINDOW'],
|
|
24
|
+
'sati.provider': ['MORPHEUS_SATI_PROVIDER'],
|
|
25
|
+
'sati.model': ['MORPHEUS_SATI_MODEL'],
|
|
26
|
+
'sati.temperature': ['MORPHEUS_SATI_TEMPERATURE'],
|
|
27
|
+
'sati.api_key': ['MORPHEUS_SATI_API_KEY'],
|
|
28
|
+
'neo.provider': ['MORPHEUS_NEO_PROVIDER'],
|
|
29
|
+
'neo.model': ['MORPHEUS_NEO_MODEL'],
|
|
30
|
+
'neo.temperature': ['MORPHEUS_NEO_TEMPERATURE'],
|
|
31
|
+
'neo.api_key': ['MORPHEUS_NEO_API_KEY'],
|
|
32
|
+
'apoc.provider': ['MORPHEUS_APOC_PROVIDER'],
|
|
33
|
+
'apoc.model': ['MORPHEUS_APOC_MODEL'],
|
|
34
|
+
'apoc.temperature': ['MORPHEUS_APOC_TEMPERATURE'],
|
|
35
|
+
'apoc.api_key': ['MORPHEUS_APOC_API_KEY'],
|
|
36
|
+
'apoc.working_dir': ['MORPHEUS_APOC_WORKING_DIR'],
|
|
37
|
+
'apoc.timeout_ms': ['MORPHEUS_APOC_TIMEOUT_MS'],
|
|
38
|
+
'trinity.provider': ['MORPHEUS_TRINITY_PROVIDER'],
|
|
39
|
+
'trinity.model': ['MORPHEUS_TRINITY_MODEL'],
|
|
40
|
+
'trinity.temperature': ['MORPHEUS_TRINITY_TEMPERATURE'],
|
|
41
|
+
'trinity.api_key': ['MORPHEUS_TRINITY_API_KEY'],
|
|
42
|
+
'audio.provider': ['MORPHEUS_AUDIO_PROVIDER'],
|
|
43
|
+
'audio.model': ['MORPHEUS_AUDIO_MODEL'],
|
|
44
|
+
'audio.apiKey': ['MORPHEUS_AUDIO_API_KEY'],
|
|
45
|
+
'audio.maxDurationSeconds': ['MORPHEUS_AUDIO_MAX_DURATION'],
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Checks if a config field is overridden by an environment variable.
|
|
49
|
+
*/
|
|
50
|
+
function isFieldOverriddenByEnv(fieldPath) {
|
|
51
|
+
const envVars = CONFIG_TO_ENV_MAP[fieldPath];
|
|
52
|
+
if (!envVars)
|
|
53
|
+
return false;
|
|
54
|
+
return envVars.some(envVar => isEnvVarSet(envVar));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Gets all fields that are overridden by environment variables.
|
|
58
|
+
*/
|
|
59
|
+
function getOverriddenFields() {
|
|
60
|
+
return Object.keys(CONFIG_TO_ENV_MAP).filter(isFieldOverriddenByEnv);
|
|
61
|
+
}
|
|
12
62
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
13
63
|
function setNestedValue(obj, dotPath, value) {
|
|
14
64
|
const keys = dotPath.split(".");
|
|
@@ -43,23 +93,37 @@ export const ConfigQueryTool = tool(async ({ key }) => {
|
|
|
43
93
|
}),
|
|
44
94
|
});
|
|
45
95
|
export const ConfigUpdateTool = tool(async ({ updates }) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
96
|
+
const configManager = ConfigManager.getInstance();
|
|
97
|
+
await configManager.load();
|
|
98
|
+
// Check if any fields are overridden by environment variables
|
|
99
|
+
const overriddenFields = [];
|
|
100
|
+
for (const key in updates) {
|
|
101
|
+
if (isFieldOverriddenByEnv(key)) {
|
|
102
|
+
overriddenFields.push(key);
|
|
53
103
|
}
|
|
54
|
-
await configManager.save(newConfig);
|
|
55
|
-
return JSON.stringify({ success: true, message: "Configuration updated successfully" });
|
|
56
104
|
}
|
|
57
|
-
|
|
58
|
-
|
|
105
|
+
if (overriddenFields.length > 0) {
|
|
106
|
+
const envVarNames = overriddenFields
|
|
107
|
+
.flatMap(field => CONFIG_TO_ENV_MAP[field] || [])
|
|
108
|
+
.filter((v, i, arr) => arr.indexOf(v) === i) // Remove duplicates
|
|
109
|
+
.join(', ');
|
|
110
|
+
const errorMsg = `BLOCKED_BY_ENV: Cannot update ${overriddenFields.join(', ')} because these fields are controlled by environment variables (${envVarNames}). To change them, edit your .env file and restart Morpheus.`;
|
|
111
|
+
throw new Error(errorMsg);
|
|
112
|
+
}
|
|
113
|
+
const currentConfig = configManager.get();
|
|
114
|
+
const newConfig = { ...currentConfig };
|
|
115
|
+
for (const key in updates) {
|
|
116
|
+
setNestedValue(newConfig, key, updates[key]);
|
|
59
117
|
}
|
|
118
|
+
await configManager.save(newConfig);
|
|
119
|
+
return JSON.stringify({
|
|
120
|
+
success: true,
|
|
121
|
+
message: "Configuration updated successfully",
|
|
122
|
+
updatedFields: Object.keys(updates),
|
|
123
|
+
});
|
|
60
124
|
}, {
|
|
61
125
|
name: "morpheus_config_update",
|
|
62
|
-
description: "Updates configuration values with validation. Accepts an 'updates' object containing key-value pairs to update. Supports dot notation for nested fields (e.g. 'llm.model').",
|
|
126
|
+
description: "Updates configuration values with validation. Accepts an 'updates' object containing key-value pairs to update. Supports dot notation for nested fields (e.g. 'llm.model'). Note: Fields overridden by environment variables cannot be updated.",
|
|
63
127
|
schema: z.object({
|
|
64
128
|
updates: z.object({}).passthrough(),
|
|
65
129
|
}),
|
|
@@ -77,30 +141,39 @@ export const DiagnosticTool = tool(async () => {
|
|
|
77
141
|
const config = configManager.get();
|
|
78
142
|
const requiredFields = ["llm", "logging", "ui"];
|
|
79
143
|
const missingFields = requiredFields.filter((field) => !(field in config));
|
|
144
|
+
// Check MORPHEUS_SECRET
|
|
145
|
+
const hasSecret = !!process.env.MORPHEUS_SECRET;
|
|
80
146
|
if (missingFields.length === 0) {
|
|
81
147
|
const sati = config.sati;
|
|
82
148
|
const apoc = config.apoc;
|
|
149
|
+
const details = {
|
|
150
|
+
oracleProvider: config.llm?.provider,
|
|
151
|
+
oracleModel: config.llm?.model,
|
|
152
|
+
satiProvider: sati?.provider ?? `${config.llm?.provider} (inherited)`,
|
|
153
|
+
satiModel: sati?.model ?? `${config.llm?.model} (inherited)`,
|
|
154
|
+
apocProvider: apoc?.provider ?? `${config.llm?.provider} (inherited)`,
|
|
155
|
+
apocModel: apoc?.model ?? `${config.llm?.model} (inherited)`,
|
|
156
|
+
apocWorkingDir: apoc?.working_dir ?? "not set",
|
|
157
|
+
uiEnabled: config.ui?.enabled,
|
|
158
|
+
uiPort: config.ui?.port,
|
|
159
|
+
morpheusSecret: hasSecret ? "configured ✓" : "NOT SET ⚠️",
|
|
160
|
+
};
|
|
83
161
|
components.config = {
|
|
84
|
-
status: "healthy",
|
|
85
|
-
message:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
satiProvider: sati?.provider ?? `${config.llm?.provider} (inherited)`,
|
|
90
|
-
satiModel: sati?.model ?? `${config.llm?.model} (inherited)`,
|
|
91
|
-
apocProvider: apoc?.provider ?? `${config.llm?.provider} (inherited)`,
|
|
92
|
-
apocModel: apoc?.model ?? `${config.llm?.model} (inherited)`,
|
|
93
|
-
apocWorkingDir: apoc?.working_dir ?? "not set",
|
|
94
|
-
uiEnabled: config.ui?.enabled,
|
|
95
|
-
uiPort: config.ui?.port,
|
|
96
|
-
},
|
|
162
|
+
status: hasSecret ? "healthy" : "warning",
|
|
163
|
+
message: hasSecret
|
|
164
|
+
? "Configuration is valid and complete"
|
|
165
|
+
: "Configuration is valid but MORPHEUS_SECRET is not set. API keys and database passwords will be stored in plaintext.",
|
|
166
|
+
details,
|
|
97
167
|
};
|
|
98
168
|
}
|
|
99
169
|
else {
|
|
100
170
|
components.config = {
|
|
101
171
|
status: "warning",
|
|
102
172
|
message: `Missing required configuration fields: ${missingFields.join(", ")}`,
|
|
103
|
-
details: {
|
|
173
|
+
details: {
|
|
174
|
+
missingFields,
|
|
175
|
+
morpheusSecret: hasSecret ? "configured ✓" : "NOT SET ⚠️",
|
|
176
|
+
},
|
|
104
177
|
};
|
|
105
178
|
}
|
|
106
179
|
}
|