refactor: shared source — replace per-group agent-runner copies with single RO mount

Replace the per-group agent-runner-src copy model with a single shared
read-only mount. Source and skills are now RO + shared; personality,
config, working files, and Claude state stay RW + per-group.

Key changes:
- Mount container/agent-runner/src/ RO at /app/src (all groups share one copy)
- Mount container/skills/ RO at /app/skills; per-group skill selection via
  symlinks in .claude-shared/skills/ based on container.json "skills" field
- Mount container.json as nested RO bind on top of RW group dir
- Move all NANOCLAW_* env vars to container.json (runner reads at startup)
- New runner config.ts module replaces process.env reads
- Move command gate (filtered/admin) from container to host router
- Dockerfile: remove source COPY, split CLI installs (claude-code last),
  move agent-runner deps above CLIs for better layer caching
- Add writeOutboundDirect for router denial responses
- Design doc at docs/shared-src.md

Not included (follow-up): DB migration to drop agent_provider columns,
cleanup of orphaned agent-runner-src directories.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
exe.dev user
2026-04-21 12:05:19 +00:00
committed by gavrielc
parent 596035be09
commit 8a12fa61ac
14 changed files with 715 additions and 249 deletions

View File

@@ -18,6 +18,7 @@
* for policy refusals.
*/
import { getChannelAdapter } from './channels/channel-registry.js';
import { gateCommand } from './command-gate.js';
import { getAgentGroup } from './db/agent-groups.js';
import { recordDroppedMessage } from './db/dropped-messages.js';
import {
@@ -28,7 +29,7 @@ import {
import { findSessionForAgent } from './db/sessions.js';
import { startTypingRefresh } from './modules/typing/index.js';
import { log } from './log.js';
import { resolveSession, writeSessionMessage } from './session-manager.js';
import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js';
import { wakeContainer } from './container-runner.js';
import { getSession } from './db/sessions.js';
import type { AgentGroup, MessagingGroup, MessagingGroupAgent } from './types.js';
@@ -398,6 +399,29 @@ async function deliverToAgent(
threadId: event.threadId,
};
// Command gate: classify slash commands before they reach the container.
// Filtered commands are dropped silently. Denied admin commands get a
// permission-denied response written directly to messages_out.
if (event.message.kind === 'chat' || event.message.kind === 'chat-sdk') {
const gate = gateCommand(event.message.content, userId, agent.agent_group_id);
if (gate.action === 'filter') {
log.debug('Filtered command dropped by gate', { agentGroupId: agent.agent_group_id });
return;
}
if (gate.action === 'deny') {
writeOutboundDirect(session.agent_group_id, session.id, {
id: `deny-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
platformId: deliveryAddr.platformId,
channelType: deliveryAddr.channelType,
threadId: deliveryAddr.threadId,
content: JSON.stringify({ text: `Permission denied: ${gate.command} requires admin access.` }),
});
log.info('Admin command denied by gate', { command: gate.command, userId, agentGroupId: agent.agent_group_id });
return;
}
}
writeSessionMessage(session.agent_group_id, session.id, {
id: messageIdForAgent(event.message.id, agent.agent_group_id),
kind: event.message.kind,