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
+35 -2
View File
@@ -11,6 +11,11 @@ import { DATA_DIR } from '../src/config.js';
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
import {
createDestination,
getDestinationByName,
normalizeName,
} from '../src/db/agent-destinations.js';
import {
createMessagingGroup,
createMessagingGroupAgent,
@@ -41,6 +46,8 @@ interface RegisterArgs {
assistantName: string;
/** Session mode: 'shared' (one session per channel) or 'per-thread' */
sessionMode: string;
/** Optional local name the agent uses for this channel (defaults to normalized messaging group name) */
localName: string | null;
}
function parseArgs(args: string[]): RegisterArgs {
@@ -54,6 +61,7 @@ function parseArgs(args: string[]): RegisterArgs {
isMain: false,
assistantName: 'Andy',
sessionMode: 'shared',
localName: null,
};
for (let i = 0; i < args.length; i++) {
@@ -87,6 +95,9 @@ function parseArgs(args: string[]): RegisterArgs {
case '--session-mode':
result.sessionMode = args[++i] || 'shared';
break;
case '--local-name':
result.localName = args[++i] || null;
break;
}
}
@@ -168,7 +179,7 @@ export async function run(args: string[]): Promise<void> {
log.info('Created messaging group', { id: mgId, channel: parsed.channel, platformId: parsed.platformId });
}
// 3. Wire agent to messaging group
// 3. Wire agent to messaging group + create destination row for the agent's map
let newlyWired = false;
const existing = getMessagingGroupAgentByPair(messagingGroup.id, agentGroup.id);
if (!existing) {
@@ -190,7 +201,29 @@ export async function run(args: string[]): Promise<void> {
priority: parsed.isMain ? 10 : 0,
created_at: new Date().toISOString(),
});
log.info('Wired agent to messaging group', { mgaId, agentGroup: agentGroup.id, messagingGroup: messagingGroup.id });
// Create destination row so the agent can address this channel by name.
// Auto-suffix on collision within this agent's namespace.
const baseLocalName = normalizeName(parsed.localName || parsed.name);
let localName = baseLocalName;
let suffix = 2;
while (getDestinationByName(agentGroup.id, localName)) {
localName = `${baseLocalName}-${suffix}`;
suffix++;
}
createDestination({
agent_group_id: agentGroup.id,
local_name: localName,
target_type: 'channel',
target_id: messagingGroup.id,
created_at: new Date().toISOString(),
});
log.info('Wired agent to messaging group', {
mgaId,
agentGroup: agentGroup.id,
messagingGroup: messagingGroup.id,
localName,
});
}
// 4. Send onboarding message — only on first wiring, not re-registration