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

70
src/command-gate.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* Host-side command gate. Classifies inbound slash commands and gates
* them before they reach the container.
*
* - Filtered commands: dropped silently (never reach the container)
* - Admin commands: checked against user_roles; denied senders get a
* "Permission denied" response written directly to messages_out
* - Normal messages: pass through unchanged
*/
import { getDb, hasTable } from './db/connection.js';
export type GateResult =
| { action: 'pass' }
| { action: 'filter' }
| { action: 'deny'; command: string };
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files']);
/**
* Classify a message and decide whether it should reach the container.
* Returns 'pass' for normal messages and authorized admin commands,
* 'filter' for silently-dropped commands, 'deny' for unauthorized
* admin commands.
*/
export function gateCommand(
content: string,
userId: string | null,
agentGroupId: string,
): GateResult {
let text: string;
try {
const parsed = JSON.parse(content);
text = (parsed.text || '').trim();
} catch {
text = content.trim();
}
if (!text.startsWith('/')) return { action: 'pass' };
const command = text.split(/\s/)[0].toLowerCase();
if (FILTERED_COMMANDS.has(command)) return { action: 'filter' };
if (ADMIN_COMMANDS.has(command)) {
if (isAdmin(userId, agentGroupId)) {
return { action: 'pass' };
}
return { action: 'deny', command };
}
// Unknown slash commands pass through (the agent/SDK handles them)
return { action: 'pass' };
}
function isAdmin(userId: string | null, agentGroupId: string): boolean {
if (!userId) return false;
if (!hasTable(getDb(), 'user_roles')) return true; // no permissions module = allow all
const db = getDb();
const row = db
.prepare(
`SELECT 1 FROM user_roles
WHERE user_id = ?
AND (role = 'owner' OR role = 'admin')
AND (agent_group_id IS NULL OR agent_group_id = ?)
LIMIT 1`,
)
.get(userId, agentGroupId);
return row != null;
}