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