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:
@@ -1,3 +1,4 @@
|
||||
import { findByName } from './destinations.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
@@ -143,9 +144,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
|
||||
log(`Processing ${normalMessages.length} message(s), kinds: ${[...new Set(normalMessages.map((m) => m.kind))].join(',')}`);
|
||||
|
||||
// Set routing context as env vars for MCP tools
|
||||
setRoutingEnv(routing, config.env);
|
||||
|
||||
const query = config.provider.query({
|
||||
prompt,
|
||||
sessionId,
|
||||
@@ -247,9 +245,6 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config:
|
||||
log(`Pushing ${newMessages.length} follow-up message(s) into active query`);
|
||||
query.push(prompt);
|
||||
|
||||
const newRouting = extractRouting(newMessages);
|
||||
setRoutingEnv(newRouting, config.env);
|
||||
|
||||
markCompleted(newIds);
|
||||
lastEventTime = Date.now(); // new input counts as activity
|
||||
}
|
||||
@@ -270,15 +265,7 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config:
|
||||
if (event.type === 'init') {
|
||||
querySessionId = event.sessionId;
|
||||
} else if (event.type === 'result' && event.text) {
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
in_reply_to: routing.inReplyTo,
|
||||
kind: routing.channelType ? 'chat' : 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: event.text }),
|
||||
});
|
||||
dispatchResultText(event.text, routing);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -306,10 +293,66 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
function setRoutingEnv(routing: RoutingContext, env: Record<string, string | undefined>): void {
|
||||
env.NANOCLAW_PLATFORM_ID = routing.platformId ?? undefined;
|
||||
env.NANOCLAW_CHANNEL_TYPE = routing.channelType ?? undefined;
|
||||
env.NANOCLAW_THREAD_ID = routing.threadId ?? undefined;
|
||||
/**
|
||||
* Parse the agent's final text for <message to="name">...</message> blocks
|
||||
* and dispatch each one to its resolved destination. Text outside of blocks
|
||||
* (including <internal>...</internal>) is scratchpad — logged but not sent.
|
||||
*
|
||||
* If the agent emits zero <message> blocks AND non-empty text, log a warning:
|
||||
* the agent produced output with no recipient. That's usually a bug in the
|
||||
* agent — the system prompt tells it to wrap user-visible text in blocks.
|
||||
*/
|
||||
function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
let sent = 0;
|
||||
let lastIndex = 0;
|
||||
const scratchpadParts: string[] = [];
|
||||
|
||||
while ((match = MESSAGE_RE.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
scratchpadParts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
const toName = match[1];
|
||||
const body = match[2].trim();
|
||||
lastIndex = MESSAGE_RE.lastIndex;
|
||||
|
||||
const dest = findByName(toName);
|
||||
if (!dest) {
|
||||
log(`Unknown destination in <message to="${toName}">, dropping block`);
|
||||
scratchpadParts.push(`[dropped: unknown destination "${toName}"] ${body}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
|
||||
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
in_reply_to: routing.inReplyTo,
|
||||
kind: 'chat',
|
||||
platform_id: platformId,
|
||||
channel_type: channelType,
|
||||
thread_id: null,
|
||||
content: JSON.stringify({ text: body }),
|
||||
});
|
||||
sent++;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
scratchpadParts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
const scratchpad = scratchpadParts
|
||||
.join('')
|
||||
.replace(/<internal>[\s\S]*?<\/internal>/g, '')
|
||||
.trim();
|
||||
if (scratchpad) {
|
||||
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
|
||||
}
|
||||
|
||||
if (sent === 0 && text.trim()) {
|
||||
log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user