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

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