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>
75 lines
2.6 KiB
TypeScript
75 lines
2.6 KiB
TypeScript
/**
|
|
* Per-agent destination map + ACL.
|
|
*
|
|
* Each row means: agent `agent_group_id` is allowed to send messages to
|
|
* target (`target_type`, `target_id`), and refers to it locally as `local_name`.
|
|
*
|
|
* Names are local to each source agent — they exist only inside that agent's
|
|
* namespace. The host uses this table both for routing (resolve name → ID)
|
|
* and for permission checks (row exists ⇒ authorized).
|
|
*/
|
|
import type { AgentDestination } from '../types.js';
|
|
import { getDb } from './connection.js';
|
|
|
|
export function createDestination(row: AgentDestination): void {
|
|
getDb()
|
|
.prepare(
|
|
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
|
VALUES (@agent_group_id, @local_name, @target_type, @target_id, @created_at)`,
|
|
)
|
|
.run(row);
|
|
}
|
|
|
|
export function getDestinations(agentGroupId: string): AgentDestination[] {
|
|
return getDb()
|
|
.prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ?')
|
|
.all(agentGroupId) as AgentDestination[];
|
|
}
|
|
|
|
export function getDestinationByName(agentGroupId: string, localName: string): AgentDestination | undefined {
|
|
return getDb()
|
|
.prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
|
|
.get(agentGroupId, localName) as AgentDestination | undefined;
|
|
}
|
|
|
|
/** Reverse lookup: what does this agent call the given target? */
|
|
export function getDestinationByTarget(
|
|
agentGroupId: string,
|
|
targetType: 'channel' | 'agent',
|
|
targetId: string,
|
|
): AgentDestination | undefined {
|
|
return getDb()
|
|
.prepare(
|
|
'SELECT * FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ?',
|
|
)
|
|
.get(agentGroupId, targetType, targetId) as AgentDestination | undefined;
|
|
}
|
|
|
|
/** Permission check: can this agent send to this target? */
|
|
export function hasDestination(
|
|
agentGroupId: string,
|
|
targetType: 'channel' | 'agent',
|
|
targetId: string,
|
|
): boolean {
|
|
const row = getDb()
|
|
.prepare(
|
|
'SELECT 1 FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ? LIMIT 1',
|
|
)
|
|
.get(agentGroupId, targetType, targetId);
|
|
return !!row;
|
|
}
|
|
|
|
export function deleteDestination(agentGroupId: string, localName: string): void {
|
|
getDb().prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?').run(agentGroupId, localName);
|
|
}
|
|
|
|
/** Normalize a human-readable name into a lowercase, dash-separated identifier. */
|
|
export function normalizeName(name: string): string {
|
|
return (
|
|
name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '') || 'unnamed'
|
|
);
|
|
}
|