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

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