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