morpheus-cli 0.7.5 → 0.7.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 CHANGED
@@ -240,6 +240,10 @@ Discord bot responds to **DMs only** from authorized user IDs (`allowedUsers`).
240
240
  | `/status` | Check Morpheus status |
241
241
  | `/stats` | Token usage statistics |
242
242
  | `/newsession` | Start a new session |
243
+ | `/mcps` | List MCP servers with tool counts |
244
+ | `/mcpreload` | Reload MCP connections and tools |
245
+ | `/mcp_enable name:` | Enable an MCP server |
246
+ | `/mcp_disable name:` | Disable an MCP server |
243
247
  | `/chronos prompt: time:` | Schedule a job |
244
248
  | `/chronos_list` | List all scheduled jobs |
245
249
  | `/chronos_view id:` | View job + executions |
@@ -74,6 +74,24 @@ const SLASH_COMMANDS = [
74
74
  .setDescription('Disable a skill')
75
75
  .addStringOption(opt => opt.setName('name').setDescription('Skill name').setRequired(true))
76
76
  .setDMPermission(true),
77
+ new SlashCommandBuilder()
78
+ .setName('mcps')
79
+ .setDescription('List MCP servers and their status')
80
+ .setDMPermission(true),
81
+ new SlashCommandBuilder()
82
+ .setName('mcpreload')
83
+ .setDescription('Reload MCP tools from servers')
84
+ .setDMPermission(true),
85
+ new SlashCommandBuilder()
86
+ .setName('mcp_enable')
87
+ .setDescription('Enable an MCP server')
88
+ .addStringOption(opt => opt.setName('name').setDescription('MCP server name').setRequired(true))
89
+ .setDMPermission(true),
90
+ new SlashCommandBuilder()
91
+ .setName('mcp_disable')
92
+ .setDescription('Disable an MCP server')
93
+ .addStringOption(opt => opt.setName('name').setDescription('MCP server name').setRequired(true))
94
+ .setDMPermission(true),
77
95
  ].map(cmd => cmd.toJSON());
78
96
  // ─── Adapter ──────────────────────────────────────────────────────────────────
79
97
  export class DiscordAdapter {
@@ -367,6 +385,18 @@ export class DiscordAdapter {
367
385
  case 'skill_disable':
368
386
  await this.cmdSkillDisable(interaction);
369
387
  break;
388
+ case 'mcps':
389
+ await this.cmdMcps(interaction);
390
+ break;
391
+ case 'mcpreload':
392
+ await this.cmdMcpReload(interaction);
393
+ break;
394
+ case 'mcp_enable':
395
+ await this.cmdMcpEnable(interaction);
396
+ break;
397
+ case 'mcp_disable':
398
+ await this.cmdMcpDisable(interaction);
399
+ break;
370
400
  }
371
401
  }
372
402
  async cmdHelp(interaction) {
@@ -392,7 +422,13 @@ export class DiscordAdapter {
392
422
  '`/skill_enable name:` — Enable a skill',
393
423
  '`/skill_disable name:` — Disable a skill',
394
424
  '',
395
- 'You can also send text or voice messages to chat with the Oracle.',
425
+ '**MCP Servers**',
426
+ '`/mcps` — List MCP servers and status',
427
+ '`/mcpreload` — Reload MCP tools from servers',
428
+ '`/mcp_enable name:` — Enable an MCP server',
429
+ '`/mcp_disable name:` — Disable an MCP server',
430
+ '',
431
+ 'You can also send text or voice messages to chat with the Oracle.'
396
432
  ].join('\n');
397
433
  await interaction.reply({ content });
398
434
  }
@@ -657,6 +693,80 @@ export class DiscordAdapter {
657
693
  await interaction.reply({ content: `Error: ${err.message}` });
658
694
  }
659
695
  }
696
+ // ─── MCP Commands ─────────────────────────────────────────────────────────
697
+ async cmdMcps(interaction) {
698
+ try {
699
+ const { MCPManager } = await import('../config/mcp-manager.js');
700
+ const { Construtor } = await import('../runtime/tools/factory.js');
701
+ const [servers, stats] = await Promise.all([
702
+ MCPManager.listServers(),
703
+ Promise.resolve(Construtor.getStats()),
704
+ ]);
705
+ if (!servers.length) {
706
+ await interaction.reply({ content: 'No MCP servers configured.' });
707
+ return;
708
+ }
709
+ const statsMap = new Map(stats.servers.map(s => [s.name, s]));
710
+ const lines = servers.map(s => {
711
+ const status = s.enabled ? '🟢' : '🔴';
712
+ const serverStats = statsMap.get(s.name);
713
+ const toolCount = serverStats?.ok ? `(${serverStats.toolCount} tools)` :
714
+ serverStats?.error ? '(failed)' : '(not loaded)';
715
+ const transport = s.config.transport.toUpperCase();
716
+ return `${status} **${s.name}** ${toolCount}\n _${transport}_`;
717
+ });
718
+ const enabled = servers.filter(s => s.enabled).length;
719
+ const totalTools = stats.totalTools;
720
+ await interaction.reply({
721
+ content: `**MCP Servers** (${enabled}/${servers.length} enabled, ${totalTools} tools cached)\n\n${lines.join('\n')}`
722
+ });
723
+ }
724
+ catch (err) {
725
+ await interaction.reply({ content: `Error: ${err.message}` });
726
+ }
727
+ }
728
+ async cmdMcpReload(interaction) {
729
+ await interaction.deferReply();
730
+ try {
731
+ await this.oracle.reloadTools();
732
+ const { Construtor } = await import('../runtime/tools/factory.js');
733
+ const stats = Construtor.getStats();
734
+ await interaction.editReply({
735
+ content: `✅ MCP tools reloaded: ${stats.totalTools} tools from ${stats.servers.length} servers.`
736
+ });
737
+ this.display.log(`MCP reload triggered by Discord user`, { source: 'Discord', level: 'info' });
738
+ }
739
+ catch (err) {
740
+ await interaction.editReply({ content: `❌ Failed to reload MCP tools: ${err.message}` });
741
+ this.display.log(`MCP reload failed: ${err.message}`, { source: 'Discord', level: 'error' });
742
+ }
743
+ }
744
+ async cmdMcpEnable(interaction) {
745
+ const name = interaction.options.getString('name', true);
746
+ try {
747
+ const { MCPManager } = await import('../config/mcp-manager.js');
748
+ await MCPManager.setServerEnabled(name, true);
749
+ await interaction.reply({
750
+ content: `MCP server \`${name}\` enabled. Use \`/mcpreload\` to apply changes.`
751
+ });
752
+ }
753
+ catch (err) {
754
+ await interaction.reply({ content: `Error: ${err.message}` });
755
+ }
756
+ }
757
+ async cmdMcpDisable(interaction) {
758
+ const name = interaction.options.getString('name', true);
759
+ try {
760
+ const { MCPManager } = await import('../config/mcp-manager.js');
761
+ await MCPManager.setServerEnabled(name, false);
762
+ await interaction.reply({
763
+ content: `MCP server \`${name}\` disabled. Use \`/mcpreload\` to apply changes.`
764
+ });
765
+ }
766
+ catch (err) {
767
+ await interaction.reply({ content: `Error: ${err.message}` });
768
+ }
769
+ }
660
770
  // ─── Helpers ──────────────────────────────────────────────────────────────
661
771
  isAuthorized(userId) {
662
772
  return this.allowedUsers.includes(userId);
@@ -1591,7 +1591,8 @@ How can I assist you today?`;
1591
1591
  try {
1592
1592
  await ctx.reply('🔄 Reloading MCP servers...');
1593
1593
  await this.oracle.reloadTools();
1594
- await ctx.reply('✅ MCP servers reloaded successfully.');
1594
+ const stats = Construtor.getStats();
1595
+ await ctx.reply(`✅ MCP tools reloaded: ${stats.totalTools} tools from ${stats.servers.length} servers.`);
1595
1596
  this.display.log(`MCP reload triggered by @${user}`, { source: 'Telegram', level: 'info' });
1596
1597
  }
1597
1598
  catch (error) {
@@ -12,6 +12,7 @@ import { TelegramAdapter } from '../../channels/telegram.js';
12
12
  import { DiscordAdapter } from '../../channels/discord.js';
13
13
  import { ChannelRegistry } from '../../channels/registry.js';
14
14
  import { WebhookDispatcher } from '../../runtime/webhooks/dispatcher.js';
15
+ import { registerOracleForHotReload } from '../../runtime/hot-reload.js';
15
16
  import { PATHS } from '../../config/paths.js';
16
17
  import { Oracle } from '../../runtime/oracle.js';
17
18
  import { ProviderError } from '../../runtime/errors.js';
@@ -23,6 +24,7 @@ import { TaskNotifier } from '../../runtime/tasks/notifier.js';
23
24
  import { ChronosWorker } from '../../runtime/chronos/worker.js';
24
25
  import { ChronosRepository } from '../../runtime/chronos/repository.js';
25
26
  import { SkillRegistry } from '../../runtime/skills/index.js';
27
+ import { MCPToolCache } from '../../runtime/tools/cache.js';
26
28
  // Load .env file explicitly in start command
27
29
  const envPath = path.join(process.cwd(), '.env');
28
30
  if (fs.existsSync(envPath)) {
@@ -138,6 +140,24 @@ export const startCommand = new Command('start')
138
140
  catch (err) {
139
141
  display.log(chalk.yellow(`Skills initialization warning: ${err.message}`), { source: 'Skills' });
140
142
  }
143
+ // Initialize MCP Tool Cache before Oracle (so agents get cached tools)
144
+ try {
145
+ display.startSpinner('Loading MCP tools...');
146
+ const mcpCache = MCPToolCache.getInstance();
147
+ await mcpCache.load();
148
+ const stats = mcpCache.getStats();
149
+ display.stopSpinner();
150
+ if (stats.totalTools > 0) {
151
+ display.log(chalk.green(`✓ MCP tools cached: ${stats.totalTools} tools from ${stats.servers.length} servers`), { source: 'MCP' });
152
+ }
153
+ else if (stats.servers.length > 0) {
154
+ display.log(chalk.yellow(`⚠ MCP servers configured but no tools loaded`), { source: 'MCP' });
155
+ }
156
+ }
157
+ catch (err) {
158
+ display.stopSpinner();
159
+ display.log(chalk.yellow(`MCP cache warning: ${err.message}`), { source: 'MCP' });
160
+ }
141
161
  // Initialize Oracle
142
162
  const oracle = new Oracle(config);
143
163
  try {
@@ -145,6 +165,8 @@ export const startCommand = new Command('start')
145
165
  await oracle.initialize();
146
166
  display.stopSpinner();
147
167
  display.log(chalk.green('✓ Oracle initialized'), { source: 'Oracle' });
168
+ // Register Oracle for hot-reload
169
+ registerOracleForHotReload(oracle);
148
170
  }
149
171
  catch (err) {
150
172
  display.stopSpinner();
@@ -287,6 +287,22 @@ export class ConfigManager {
287
287
  max_active_jobs: resolveNumeric('MORPHEUS_CHRONOS_MAX_ACTIVE_JOBS', config.chronos.max_active_jobs, 100),
288
288
  };
289
289
  }
290
+ // Apply precedence to DevKit config
291
+ // Migration: if devkit is absent but apoc.working_dir exists, migrate it
292
+ const rawDevKit = config.devkit ?? {};
293
+ const migratedSandboxDir = rawDevKit.sandbox_dir || config.apoc?.working_dir || undefined;
294
+ const devkitConfig = {
295
+ sandbox_dir: resolveString('MORPHEUS_DEVKIT_SANDBOX_DIR', migratedSandboxDir, process.cwd()),
296
+ readonly_mode: resolveBoolean('MORPHEUS_DEVKIT_READONLY_MODE', rawDevKit.readonly_mode, false),
297
+ allowed_shell_commands: process.env.MORPHEUS_DEVKIT_ALLOWED_SHELL_COMMANDS
298
+ ? process.env.MORPHEUS_DEVKIT_ALLOWED_SHELL_COMMANDS.split(',').map(s => s.trim()).filter(Boolean)
299
+ : (rawDevKit.allowed_shell_commands ?? []),
300
+ enable_filesystem: resolveBoolean('MORPHEUS_DEVKIT_ENABLE_FILESYSTEM', rawDevKit.enable_filesystem, true),
301
+ enable_shell: resolveBoolean('MORPHEUS_DEVKIT_ENABLE_SHELL', rawDevKit.enable_shell, true),
302
+ enable_git: resolveBoolean('MORPHEUS_DEVKIT_ENABLE_GIT', rawDevKit.enable_git, true),
303
+ enable_network: resolveBoolean('MORPHEUS_DEVKIT_ENABLE_NETWORK', rawDevKit.enable_network, true),
304
+ timeout_ms: resolveNumeric('MORPHEUS_DEVKIT_TIMEOUT_MS', rawDevKit.timeout_ms, 30_000),
305
+ };
290
306
  return {
291
307
  agent: agentConfig,
292
308
  llm: llmConfig,
@@ -300,6 +316,7 @@ export class ConfigManager {
300
316
  logging: loggingConfig,
301
317
  memory: memoryConfig,
302
318
  chronos: chronosConfig,
319
+ devkit: devkitConfig,
303
320
  verbose_mode: resolveBoolean('MORPHEUS_VERBOSE_MODE', config.verbose_mode, true),
304
321
  };
305
322
  }
@@ -384,6 +401,22 @@ export class ConfigManager {
384
401
  }
385
402
  return defaults;
386
403
  }
404
+ getDevKitConfig() {
405
+ const defaults = {
406
+ sandbox_dir: process.cwd(),
407
+ readonly_mode: false,
408
+ allowed_shell_commands: [],
409
+ enable_filesystem: true,
410
+ enable_shell: true,
411
+ enable_git: true,
412
+ enable_network: true,
413
+ timeout_ms: 30_000,
414
+ };
415
+ if (this.config.devkit) {
416
+ return { ...defaults, ...this.config.devkit };
417
+ }
418
+ return defaults;
419
+ }
387
420
  /**
388
421
  * Returns encryption status for all agent API keys.
389
422
  */
@@ -44,6 +44,16 @@ export const ChronosConfigSchema = z.object({
44
44
  check_interval_ms: z.number().min(60000).default(60000),
45
45
  max_active_jobs: z.number().min(1).max(1000).default(100),
46
46
  });
47
+ export const DevKitConfigSchema = z.object({
48
+ sandbox_dir: z.string().optional(),
49
+ readonly_mode: z.boolean().default(false),
50
+ allowed_shell_commands: z.array(z.string()).default([]),
51
+ enable_filesystem: z.boolean().default(true),
52
+ enable_shell: z.boolean().default(true),
53
+ enable_git: z.boolean().default(true),
54
+ enable_network: z.boolean().default(true),
55
+ timeout_ms: z.number().int().positive().default(30000),
56
+ });
47
57
  // Zod Schema matching MorpheusConfig interface
48
58
  export const ConfigSchema = z.object({
49
59
  agent: z.object({
@@ -67,6 +77,7 @@ export const ConfigSchema = z.object({
67
77
  }).default(DEFAULT_CONFIG.runtime?.async_tasks ?? { enabled: true }),
68
78
  }).optional(),
69
79
  chronos: ChronosConfigSchema.optional(),
80
+ devkit: DevKitConfigSchema.optional(),
70
81
  verbose_mode: z.boolean().default(true),
71
82
  channels: z.object({
72
83
  telegram: z.object({
@@ -1,12 +1,27 @@
1
1
  const factories = [];
2
- export function registerToolFactory(factory) {
3
- factories.push(factory);
2
+ export function registerToolFactory(factory, category = 'system') {
3
+ factories.push({ category, factory });
4
4
  }
5
+ /** Categories that can be toggled off via DevKit config */
6
+ const TOGGLEABLE_CATEGORIES = {
7
+ filesystem: 'enable_filesystem',
8
+ shell: 'enable_shell',
9
+ git: 'enable_git',
10
+ network: 'enable_network',
11
+ };
5
12
  /**
6
13
  * Builds the full DevKit tool set for a given context.
7
14
  * Each factory receives the context (working_dir, allowed_commands, etc.)
8
15
  * and returns tools with the context captured in closure.
16
+ * Disabled categories are filtered out based on context flags.
9
17
  */
10
18
  export function buildDevKit(ctx) {
11
- return factories.flatMap(factory => factory(ctx));
19
+ return factories
20
+ .filter(({ category }) => {
21
+ const ctxKey = TOGGLEABLE_CATEGORIES[category];
22
+ if (!ctxKey)
23
+ return true; // non-toggleable categories always load
24
+ return ctx[ctxKey] !== false;
25
+ })
26
+ .flatMap(({ factory }) => factory(ctx));
12
27
  }
@@ -501,4 +501,4 @@ export function createBrowserTools(_ctx) {
501
501
  browserSearchTool,
502
502
  ];
503
503
  }
504
- registerToolFactory(createBrowserTools);
504
+ registerToolFactory(createBrowserTools, 'browser');
@@ -6,23 +6,31 @@ import { glob } from 'glob';
6
6
  import { truncateOutput, isWithinDir } from '../utils.js';
7
7
  import { registerToolFactory } from '../registry.js';
8
8
  function resolveSafe(ctx, filePath) {
9
- // Always resolve relative to working_dir
10
- const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.working_dir, filePath);
9
+ // Resolve relative to sandbox_dir (preferred) or working_dir
10
+ const base = ctx.sandbox_dir || ctx.working_dir;
11
+ const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(base, filePath);
11
12
  return resolved;
12
13
  }
14
+ /**
15
+ * Guards a resolved path against the sandbox directory.
16
+ * When sandbox_dir is set, ALL paths (read and write) must be within it.
17
+ * When readonly_mode is true, destructive operations are blocked.
18
+ */
13
19
  function guardPath(ctx, resolved, destructive = false) {
14
- // If allowed_commands is empty (Merovingian), no path restriction
15
- if (!destructive)
16
- return;
17
- // For Apoc (non-empty allowed_commands) or explicit working_dir set, guard destructive ops
18
- if (ctx.allowed_commands.length > 0 && !isWithinDir(resolved, ctx.working_dir)) {
19
- throw new Error(`Path '${resolved}' is outside the working directory '${ctx.working_dir}'. Operation denied.`);
20
+ // Enforce readonly_mode for destructive operations
21
+ if (destructive && ctx.readonly_mode) {
22
+ throw new Error(`Operation denied: DevKit is in read-only mode. Write/delete operations are blocked.`);
23
+ }
24
+ // Enforce sandbox_dir for ALL operations (read and write)
25
+ if (ctx.sandbox_dir && !isWithinDir(resolved, ctx.sandbox_dir)) {
26
+ throw new Error(`Path '${resolved}' is outside the sandbox directory '${ctx.sandbox_dir}'. Operation denied.`);
20
27
  }
21
28
  }
22
29
  export function createFilesystemTools(ctx) {
23
30
  return [
24
31
  tool(async ({ file_path, encoding, start_line, end_line }) => {
25
32
  const resolved = resolveSafe(ctx, file_path);
33
+ guardPath(ctx, resolved);
26
34
  const content = await fs.readFile(resolved, encoding ?? 'utf8');
27
35
  const lines = content.split('\n');
28
36
  const sliced = (start_line || end_line)
@@ -80,6 +88,7 @@ export function createFilesystemTools(ctx) {
80
88
  tool(async ({ source, destination }) => {
81
89
  const src = resolveSafe(ctx, source);
82
90
  const dest = resolveSafe(ctx, destination);
91
+ guardPath(ctx, src, true);
83
92
  guardPath(ctx, dest, true);
84
93
  await fs.ensureDir(path.dirname(dest));
85
94
  await fs.move(src, dest, { overwrite: true });
@@ -95,6 +104,8 @@ export function createFilesystemTools(ctx) {
95
104
  tool(async ({ source, destination }) => {
96
105
  const src = resolveSafe(ctx, source);
97
106
  const dest = resolveSafe(ctx, destination);
107
+ guardPath(ctx, src);
108
+ guardPath(ctx, dest, true);
98
109
  await fs.ensureDir(path.dirname(dest));
99
110
  await fs.copy(src, dest);
100
111
  return JSON.stringify({ success: true, from: src, to: dest });
@@ -108,6 +119,7 @@ export function createFilesystemTools(ctx) {
108
119
  }),
109
120
  tool(async ({ dir_path, recursive, pattern }) => {
110
121
  const resolved = resolveSafe(ctx, dir_path ?? '.');
122
+ guardPath(ctx, resolved);
111
123
  const entries = await fs.readdir(resolved, { withFileTypes: true });
112
124
  let results = entries.map(e => ({
113
125
  name: e.name,
@@ -145,6 +157,7 @@ export function createFilesystemTools(ctx) {
145
157
  }),
146
158
  tool(async ({ dir_path }) => {
147
159
  const resolved = resolveSafe(ctx, dir_path);
160
+ guardPath(ctx, resolved, true);
148
161
  await fs.ensureDir(resolved);
149
162
  return JSON.stringify({ success: true, path: resolved });
150
163
  }, {
@@ -154,6 +167,7 @@ export function createFilesystemTools(ctx) {
154
167
  }),
155
168
  tool(async ({ file_path }) => {
156
169
  const resolved = resolveSafe(ctx, file_path);
170
+ guardPath(ctx, resolved);
157
171
  const stat = await fs.stat(resolved);
158
172
  return JSON.stringify({
159
173
  path: resolved,
@@ -171,6 +185,7 @@ export function createFilesystemTools(ctx) {
171
185
  }),
172
186
  tool(async ({ pattern, search_path, regex, case_insensitive, max_results }) => {
173
187
  const base = resolveSafe(ctx, search_path ?? '.');
188
+ guardPath(ctx, base);
174
189
  const files = await glob('**/*', { cwd: base, nodir: true, absolute: true });
175
190
  const re = new RegExp(pattern, case_insensitive ? 'i' : undefined);
176
191
  const results = [];
@@ -204,6 +219,7 @@ export function createFilesystemTools(ctx) {
204
219
  }),
205
220
  tool(async ({ pattern, search_path }) => {
206
221
  const base = resolveSafe(ctx, search_path ?? '.');
222
+ guardPath(ctx, base);
207
223
  const files = await glob(pattern, { cwd: base, absolute: true });
208
224
  return truncateOutput(JSON.stringify(files.map(f => path.relative(base, f)), null, 2));
209
225
  }, {
@@ -216,4 +232,4 @@ export function createFilesystemTools(ctx) {
216
232
  }),
217
233
  ];
218
234
  }
219
- registerToolFactory(createFilesystemTools);
235
+ registerToolFactory(createFilesystemTools, 'filesystem');
@@ -1,7 +1,8 @@
1
1
  import { tool } from '@langchain/core/tools';
2
2
  import { z } from 'zod';
3
+ import path from 'path';
3
4
  import { ShellAdapter } from '../adapters/shell.js';
4
- import { truncateOutput, isCommandAllowed } from '../utils.js';
5
+ import { truncateOutput, isCommandAllowed, isWithinDir } from '../utils.js';
5
6
  import { registerToolFactory } from '../registry.js';
6
7
  export function createGitTools(ctx) {
7
8
  const shell = ShellAdapter.create();
@@ -176,8 +177,16 @@ export function createGitTools(ctx) {
176
177
  }),
177
178
  tool(async ({ url, destination, depth }) => {
178
179
  const args = ['clone', url];
179
- if (destination)
180
+ if (destination) {
181
+ // Enforce sandbox_dir on clone destination
182
+ if (ctx.sandbox_dir) {
183
+ const resolvedDest = path.isAbsolute(destination) ? destination : path.resolve(ctx.working_dir, destination);
184
+ if (!isWithinDir(resolvedDest, ctx.sandbox_dir)) {
185
+ return JSON.stringify({ success: false, output: `Clone destination '${resolvedDest}' is outside the sandbox directory '${ctx.sandbox_dir}'. Operation denied.` });
186
+ }
187
+ }
180
188
  args.push(destination);
189
+ }
181
190
  if (depth)
182
191
  args.push('--depth', String(depth));
183
192
  const r = await git(args);
@@ -192,6 +201,13 @@ export function createGitTools(ctx) {
192
201
  }),
193
202
  }),
194
203
  tool(async ({ path: worktreePath, branch }) => {
204
+ // Enforce sandbox_dir on worktree path
205
+ if (ctx.sandbox_dir) {
206
+ const resolvedPath = path.isAbsolute(worktreePath) ? worktreePath : path.resolve(ctx.working_dir, worktreePath);
207
+ if (!isWithinDir(resolvedPath, ctx.sandbox_dir)) {
208
+ return JSON.stringify({ success: false, output: `Worktree path '${resolvedPath}' is outside the sandbox directory '${ctx.sandbox_dir}'. Operation denied.` });
209
+ }
210
+ }
195
211
  const args = ['worktree', 'add', worktreePath];
196
212
  if (branch)
197
213
  args.push('-b', branch);
@@ -207,4 +223,4 @@ export function createGitTools(ctx) {
207
223
  }),
208
224
  ];
209
225
  }
210
- registerToolFactory(createGitTools);
226
+ registerToolFactory(createGitTools, 'git');
@@ -4,7 +4,7 @@ import net from 'net';
4
4
  import dns from 'dns';
5
5
  import fs from 'fs-extra';
6
6
  import path from 'path';
7
- import { truncateOutput } from '../utils.js';
7
+ import { truncateOutput, isWithinDir } from '../utils.js';
8
8
  import { registerToolFactory } from '../registry.js';
9
9
  export function createNetworkTools(ctx) {
10
10
  return [
@@ -121,6 +121,13 @@ export function createNetworkTools(ctx) {
121
121
  const destPath = path.isAbsolute(destination)
122
122
  ? destination
123
123
  : path.resolve(ctx.working_dir, destination);
124
+ // Enforce sandbox_dir on download destination
125
+ if (ctx.sandbox_dir && !isWithinDir(destPath, ctx.sandbox_dir)) {
126
+ return JSON.stringify({
127
+ success: false,
128
+ error: `Download destination '${destPath}' is outside the sandbox directory '${ctx.sandbox_dir}'. Operation denied.`,
129
+ });
130
+ }
124
131
  await fs.ensureDir(path.dirname(destPath));
125
132
  const controller = new AbortController();
126
133
  const timer = setTimeout(() => controller.abort(), timeout_ms ?? 60_000);
@@ -155,4 +162,4 @@ export function createNetworkTools(ctx) {
155
162
  }),
156
163
  ];
157
164
  }
158
- registerToolFactory(createNetworkTools);
165
+ registerToolFactory(createNetworkTools, 'network');
@@ -70,4 +70,4 @@ export function createPackageTools(ctx) {
70
70
  }),
71
71
  ];
72
72
  }
73
- registerToolFactory(createPackageTools);
73
+ registerToolFactory(createPackageTools, 'packages');
@@ -127,4 +127,4 @@ export function createProcessTools(ctx) {
127
127
  }),
128
128
  ];
129
129
  }
130
- registerToolFactory(createProcessTools);
130
+ registerToolFactory(createProcessTools, 'processes');
@@ -5,7 +5,7 @@ import path from 'path';
5
5
  import os from 'os';
6
6
  import { randomUUID } from 'crypto';
7
7
  import { ShellAdapter } from '../adapters/shell.js';
8
- import { truncateOutput, isCommandAllowed } from '../utils.js';
8
+ import { truncateOutput, isCommandAllowed, isWithinDir } from '../utils.js';
9
9
  import { registerToolFactory } from '../registry.js';
10
10
  export function createShellTools(ctx) {
11
11
  const shell = ShellAdapter.create();
@@ -17,8 +17,20 @@ export function createShellTools(ctx) {
17
17
  error: `Command '${command}' is not in the allowed_commands list for this project. Allowed: [${ctx.allowed_commands.join(', ')}]`,
18
18
  });
19
19
  }
20
+ // Enforce sandbox_dir: override cwd to stay within sandbox
21
+ let effectiveCwd = cwd ?? ctx.working_dir;
22
+ if (ctx.sandbox_dir) {
23
+ const resolvedCwd = path.isAbsolute(effectiveCwd) ? effectiveCwd : path.resolve(ctx.sandbox_dir, effectiveCwd);
24
+ if (!isWithinDir(resolvedCwd, ctx.sandbox_dir)) {
25
+ return JSON.stringify({
26
+ success: false,
27
+ error: `Working directory '${resolvedCwd}' is outside the sandbox directory '${ctx.sandbox_dir}'. Operation denied.`,
28
+ });
29
+ }
30
+ effectiveCwd = resolvedCwd;
31
+ }
20
32
  const result = await shell.run(command, args ?? [], {
21
- cwd: cwd ?? ctx.working_dir,
33
+ cwd: effectiveCwd,
22
34
  timeout_ms: timeout_ms ?? ctx.timeout_ms ?? 30_000,
23
35
  });
24
36
  return JSON.stringify({
@@ -91,4 +103,4 @@ export function createShellTools(ctx) {
91
103
  }),
92
104
  ];
93
105
  }
94
- registerToolFactory(createShellTools);
106
+ registerToolFactory(createShellTools, 'shell');
@@ -129,4 +129,4 @@ export function createSystemTools(ctx) {
129
129
  }),
130
130
  ];
131
131
  }
132
- registerToolFactory(createSystemTools);
132
+ registerToolFactory(createSystemTools, 'system');
package/dist/http/api.js CHANGED
@@ -20,6 +20,7 @@ import { ChronosWorker } from '../runtime/chronos/worker.js';
20
20
  import { createChronosJobRouter, createChronosConfigRouter } from './routers/chronos.js';
21
21
  import { createSkillsRouter } from './routers/skills.js';
22
22
  import { getActiveEnvOverrides } from '../config/precedence.js';
23
+ import { hotReloadConfig, getRestartRequiredChanges } from '../runtime/hot-reload.js';
23
24
  async function readLastLines(filePath, n) {
24
25
  try {
25
26
  const content = await fs.readFile(filePath, 'utf8');
@@ -479,7 +480,14 @@ export function createApiRouter(oracle, chronosWorker) {
479
480
  level: 'info'
480
481
  });
481
482
  }
482
- res.json(newConfig);
483
+ // Hot-reload agents with new config
484
+ const hotReloadResult = await hotReloadConfig();
485
+ const restartRequired = getRestartRequiredChanges(oldConfig, newConfig);
486
+ res.json({
487
+ ...newConfig,
488
+ _hotReload: hotReloadResult,
489
+ _restartRequired: restartRequired
490
+ });
483
491
  }
484
492
  catch (error) {
485
493
  if (error.name === 'ZodError') {
@@ -509,6 +517,8 @@ export function createApiRouter(oracle, chronosWorker) {
509
517
  source: 'Zaion',
510
518
  level: 'info'
511
519
  });
520
+ // Hot-reload agents
521
+ await hotReloadConfig();
512
522
  res.json({ success: true });
513
523
  }
514
524
  catch (error) {
@@ -565,6 +575,8 @@ export function createApiRouter(oracle, chronosWorker) {
565
575
  source: 'Zaion',
566
576
  level: 'info'
567
577
  });
578
+ // Hot-reload agents
579
+ await hotReloadConfig();
568
580
  res.json({ success: true });
569
581
  }
570
582
  catch (error) {
@@ -601,6 +613,8 @@ export function createApiRouter(oracle, chronosWorker) {
601
613
  source: 'Zaion',
602
614
  level: 'info'
603
615
  });
616
+ // Hot-reload agents
617
+ await hotReloadConfig();
604
618
  res.json({ success: true });
605
619
  }
606
620
  catch (error) {
@@ -752,8 +766,17 @@ export function createApiRouter(oracle, chronosWorker) {
752
766
  });
753
767
  router.post('/mcp/reload', async (_req, res) => {
754
768
  try {
769
+ // First reload the MCP tool cache from servers
770
+ await Construtor.reload();
771
+ // Then reinitialize agents with the new cached tools
755
772
  await oracle.reloadTools();
756
- res.json({ ok: true, message: 'MCP tools reloaded successfully.' });
773
+ const stats = Construtor.getStats();
774
+ res.json({
775
+ ok: true,
776
+ message: 'MCP tools reloaded successfully.',
777
+ totalTools: stats.totalTools,
778
+ servers: stats.servers,
779
+ });
757
780
  }
758
781
  catch (error) {
759
782
  res.status(500).json({ error: 'Failed to reload MCP tools.', details: String(error) });
@@ -768,6 +791,16 @@ export function createApiRouter(oracle, chronosWorker) {
768
791
  res.status(500).json({ error: 'Failed to probe MCP servers.', details: String(error) });
769
792
  }
770
793
  });
794
+ // Get MCP tool cache stats (fast, no server connection)
795
+ router.get('/mcp/stats', async (_req, res) => {
796
+ try {
797
+ const stats = Construtor.getStats();
798
+ res.json(stats);
799
+ }
800
+ catch (error) {
801
+ res.status(500).json({ error: 'Failed to get MCP stats.', details: String(error) });
802
+ }
803
+ });
771
804
  // Keep PUT for backward compatibility if needed, or remove.
772
805
  // Tasks says Implement POST. I'll remove PUT to avoid confusion or redirect it.
773
806
  router.put('/config', async (req, res) => {
@@ -790,6 +823,8 @@ export function createApiRouter(oracle, chronosWorker) {
790
823
  await configManager.save({ ...config, trinity: req.body });
791
824
  const display = DisplayManager.getInstance();
792
825
  display.log('Trinity configuration updated via UI', { source: 'Zaion', level: 'info' });
826
+ // Hot-reload agents
827
+ await hotReloadConfig();
793
828
  res.json({ success: true });
794
829
  }
795
830
  catch (error) {