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:
@@ -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()
|
||||
|
||||
91
container/agent-runner/src/destinations.ts
Normal file
91
container/agent-runner/src/destinations.ts
Normal 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');
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.`);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user