From 4dfa2427169a6c7226cc6364aa781207341cda23 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 6 Feb 2026 16:04:14 -0800 Subject: [PATCH] feat: wire Docker sandboxing and agent routing into daemon --- src/daemon/index.ts | 122 ++++++++++++++++++++++++++++++++++--- src/daemon/routing.test.ts | 38 ++++++++++++ 2 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 src/daemon/routing.test.ts diff --git a/src/daemon/index.ts b/src/daemon/index.ts index bc4dee6..3ca11ea 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -6,6 +6,7 @@ import { AgentOrchestrator, type DelegationConfig } from '../backends/index.js'; import { SessionStore, SessionManager } from '../session/index.js'; import { HookEngine } from '../hooks/index.js'; import { ToolRegistry, ToolExecutor, ToolPolicy, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager } from '../tools/index.js'; +import type { Tool } from '../tools/types.js'; import { MemoryStore } from '../memory/index.js'; import { createMemoryTools } from '../tools/builtin/index.js'; import { GatewayServer } from '../gateway/index.js'; @@ -15,6 +16,8 @@ import type { InboundMessage, OutboundMessage } from '../channels/index.js'; import { McpManager } from '../mcp/index.js'; import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/index.js'; import { assembleSystemPrompt } from '../prompt/index.js'; +import { AgentConfigRegistry, AgentRouter } from '../agents/index.js'; +import { DockerSandbox, SandboxManager, createSandboxedShellTool, createSandboxedProcessStartTool } from '../sandbox/index.js'; import { resolve } from 'path'; import { homedir } from 'os'; import { mkdirSync } from 'fs'; @@ -33,6 +36,9 @@ export interface DaemonContext { mcpManager: McpManager; skillRegistry: SkillRegistry; skillInstaller: SkillInstaller; + agentConfigRegistry: AgentConfigRegistry; + agentRouter: AgentRouter; + sandboxManager?: SandboxManager; } function loadSystemPrompt(config: Config): string { @@ -164,15 +170,32 @@ function createMessageRouter(deps: { toolExecutor: ToolExecutor; config: Config; memoryStore?: MemoryStore; + agentConfigRegistry?: AgentConfigRegistry; + agentRouter?: AgentRouter; + sandboxManager?: SandboxManager; }) { - // Cache agents by session ID to avoid recreating on every message + // Cache agents by session ID + agent config name to avoid recreating on every message const agents = new Map(); function getOrCreateAgent(channel: string, senderId: string): AgentOrchestrator { - const sessionId = `${channel}:${senderId}`; + // Resolve agent config name via routing (sender → channel → default fallback) + const agentConfigName = deps.agentRouter?.resolve(channel, senderId); + const agentConfig = agentConfigName ? deps.agentConfigRegistry?.get(agentConfigName) : undefined; + + // Include agent config name in cache key so different agents aren't shared + const sessionId = agentConfigName + ? `${channel}:${senderId}:${agentConfigName}` + : `${channel}:${senderId}`; + let agent = agents.get(sessionId); if (!agent) { const session = deps.sessionManager.getSession(channel, senderId); + + // Use agent config overrides where available, falling back to global config + const effectiveSystemPrompt = agentConfig?.systemPrompt ?? deps.systemPrompt; + const effectiveTier = agentConfig?.modelTier ?? deps.config.agents.primary_tier ?? 'default'; + const effectiveProvider = deps.config.models.default.provider; + const delegationConfig: DelegationConfig = { compaction: deps.config.agents.delegation.compaction ?? 'fast', memory_extraction: deps.config.agents.delegation.memory_extraction ?? 'fast', @@ -180,13 +203,65 @@ function createMessageRouter(deps: { tool_summarisation: deps.config.agents.delegation.tool_summarisation ?? 'fast', complex_reasoning: deps.config.agents.delegation.complex_reasoning ?? 'complex', }; + + // Clone the tool registry and replace shell tools with sandboxed versions if configured + let effectiveToolRegistry = deps.toolRegistry; + if (agentConfig?.sandbox && deps.sandboxManager && deps.config.sandbox.enabled) { + effectiveToolRegistry = deps.toolRegistry.clone(); + // Lazy sandbox: create the sandboxed tools with a deferred sandbox reference + // The sandbox is created on first use via SandboxManager.getOrCreate() + const sandboxSessionId = sessionId; + const sandboxManager = deps.sandboxManager; + + // Create a proxy sandbox that lazily initializes + const lazySandboxShell: Tool = { + name: 'shell.exec', + description: 'Execute a shell command inside a sandboxed container and return stdout/stderr.', + inputSchema: { + type: 'object', + properties: { + command: { type: 'string', description: 'The shell command to execute' }, + cwd: { type: 'string', description: 'Working directory inside the container (optional)' }, + timeout: { type: 'number', description: 'Timeout in milliseconds (default 30000)' }, + }, + required: ['command'], + }, + execute: async (rawArgs: unknown) => { + const sandbox = await sandboxManager.getOrCreate(sandboxSessionId); + const tool = createSandboxedShellTool(sandbox); + return tool.execute(rawArgs); + }, + }; + + const lazySandboxProcess: Tool = { + name: 'process.start', + description: 'Start a command in the background inside a sandboxed container.', + inputSchema: { + type: 'object', + properties: { + command: { type: 'string', description: 'The shell command to run in the background' }, + cwd: { type: 'string', description: 'Working directory inside the container (optional)' }, + }, + required: ['command'], + }, + execute: async (rawArgs: unknown) => { + const sandbox = await sandboxManager.getOrCreate(sandboxSessionId); + const tool = createSandboxedProcessStartTool(sandbox); + return tool.execute(rawArgs); + }, + }; + + effectiveToolRegistry.replace(lazySandboxShell); + effectiveToolRegistry.replace(lazySandboxProcess); + } + agent = new AgentOrchestrator({ modelRouter: deps.modelRouter, - systemPrompt: deps.systemPrompt, + systemPrompt: effectiveSystemPrompt, session, - toolRegistry: deps.toolRegistry, + toolRegistry: effectiveToolRegistry, toolExecutor: deps.toolExecutor, - primaryTier: deps.config.agents.primary_tier ?? 'default', + primaryTier: effectiveTier, delegation: delegationConfig, maxDelegationDepth: deps.config.agents.max_delegation_depth ?? 3, compaction: deps.config.compaction.enabled ? { @@ -198,8 +273,8 @@ function createMessageRouter(deps: { contextWindow: deps.config.models.default.context_window, memoryStore: deps.memoryStore, toolPolicyContext: { - agent: deps.config.agents.primary_tier ?? 'default', - provider: deps.config.models.default.provider, + agent: effectiveTier, + provider: effectiveProvider, }, }); agents.set(sessionId, agent); @@ -384,6 +459,33 @@ export async function startDaemon(config: Config): Promise { console.log(`Loaded ${skills.length} skill(s) (${available} available)`); } + // Initialize agent config registry and router + const agentConfigRegistry = new AgentConfigRegistry(); + if (config.agent_configs && Object.keys(config.agent_configs).length > 0) { + agentConfigRegistry.loadFromConfig(config.agent_configs); + console.log(`Loaded ${Object.keys(config.agent_configs).length} agent config(s)`); + } + const agentRouter = new AgentRouter(config.routing); + + // Initialize sandbox manager if Docker is available + let sandboxManager: SandboxManager | undefined; + if (config.sandbox.enabled) { + const dockerAvailable = await DockerSandbox.isAvailable(); + if (dockerAvailable) { + sandboxManager = new SandboxManager(config.sandbox); + console.log(`Docker sandbox enabled (image=${config.sandbox.image}, network=${config.sandbox.network})`); + } else { + console.warn('Docker sandbox enabled but Docker not available — falling back to host execution'); + } + } + + if (sandboxManager) { + lifecycle.onShutdown(async () => { + await sandboxManager!.destroyAll(); + console.log('Docker sandboxes destroyed'); + }); + } + // Initialize model router const modelRouter = createModelRouter(config); @@ -426,6 +528,9 @@ export async function startDaemon(config: Config): Promise { toolExecutor, config, memoryStore, + agentConfigRegistry, + agentRouter, + sandboxManager, })); // Register Telegram adapter @@ -527,6 +632,9 @@ export async function startDaemon(config: Config): Promise { mcpManager, skillRegistry, skillInstaller, + agentConfigRegistry, + agentRouter, + sandboxManager, }; } diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts new file mode 100644 index 0000000..03cf4a6 --- /dev/null +++ b/src/daemon/routing.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { AgentRouter } from '../agents/router.js'; +import { AgentConfigRegistry } from '../agents/registry.js'; + +describe('daemon agent routing integration', () => { + it('resolves agent config for channel messages', () => { + const registry = new AgentConfigRegistry(); + registry.loadFromConfig({ + assistant: { system_prompt: 'Be helpful.', model_tier: 'default', tool_profile: 'messaging', sandbox: false }, + coder: { system_prompt: 'Write code.', model_tier: 'complex', tool_profile: 'coding', sandbox: true }, + }); + + const router = new AgentRouter({ + default_agent: 'assistant', + channels: { discord: 'coder' }, + senders: { 'telegram:admin': 'coder' }, + }); + + // Discord user gets coder + const discordAgent = router.resolve('discord', 'user123'); + expect(discordAgent).toBe('coder'); + expect(registry.get(discordAgent!)!.systemPrompt).toBe('Write code.'); + + // Telegram admin gets coder + const telegramAdmin = router.resolve('telegram', 'admin'); + expect(telegramAdmin).toBe('coder'); + + // Random telegram user gets assistant + const telegramUser = router.resolve('telegram', 'random'); + expect(telegramUser).toBe('assistant'); + expect(registry.get(telegramUser!)!.systemPrompt).toBe('Be helpful.'); + }); + + it('uses default agent when no routing configured', () => { + const router = new AgentRouter({ channels: {}, senders: {} }); + expect(router.resolve('telegram', '123')).toBeUndefined(); + }); +});