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

@@ -31,8 +31,7 @@ let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH;
/** Inbound DB — container opens read-only (host is the sole writer). */
export function getInboundDb(): Database {
if (!_inbound) {
const dbPath = process.env.SESSION_INBOUND_DB_PATH || DEFAULT_INBOUND_PATH;
_inbound = new Database(dbPath, { readonly: true });
_inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
_inbound.exec('PRAGMA busy_timeout = 5000');
}
return _inbound;
@@ -41,8 +40,7 @@ export function getInboundDb(): Database {
/** Outbound DB — container owns this file (sole writer). */
export function getOutboundDb(): Database {
if (!_outbound) {
const dbPath = process.env.SESSION_OUTBOUND_DB_PATH || DEFAULT_OUTBOUND_PATH;
_outbound = new Database(dbPath);
_outbound = new Database(DEFAULT_OUTBOUND_PATH);
_outbound.exec('PRAGMA journal_mode = DELETE');
_outbound.exec('PRAGMA busy_timeout = 5000');
_outbound.exec('PRAGMA foreign_keys = ON');
@@ -122,7 +120,7 @@ export function clearContainerToolInFlight(): void {
* A file touch is cheaper and avoids cross-boundary DB write contention.
*/
export function touchHeartbeat(): void {
const p = process.env.SESSION_HEARTBEAT_PATH || _heartbeatPath;
const p = _heartbeatPath;
const now = new Date();
try {
fs.utimesSync(p, now, now);

View File

@@ -7,6 +7,7 @@
* The container never writes to inbound.db — all status tracking goes through
* processing_ack. The host reads processing_ack to sync message lifecycle.
*/
import { getConfig } from '../config.js';
import { getInboundDb, getOutboundDb } from './connection.js';
export interface MessageInRow {
@@ -26,14 +27,16 @@ export interface MessageInRow {
content: string;
}
// Cap on how many messages reach the agent in one prompt, including any
// accumulated-but-not-triggered context. Host controls the cap via the
// NANOCLAW_MAX_MESSAGES_PER_PROMPT env var; default mirrors the host's
// config.ts default of 10.
const MAX_MESSAGES_PER_PROMPT = Math.max(
1,
parseInt(process.env.NANOCLAW_MAX_MESSAGES_PER_PROMPT || '10', 10) || 10,
);
// Cap on how many messages reach the agent in one prompt. Read from
// container.json; falls back to 10.
function getMaxMessagesPerPrompt(): number {
try {
return getConfig().maxMessagesPerPrompt;
} catch {
// Config not loaded yet (e.g. test harness) — use default
return 10;
}
}
/**
* Fetch pending messages that are due for processing.
@@ -58,7 +61,7 @@ export function getPendingMessages(): MessageInRow[] {
ORDER BY seq DESC
LIMIT ?`,
)
.all(MAX_MESSAGES_PER_PROMPT) as MessageInRow[];
.all(getMaxMessagesPerPrompt()) as MessageInRow[];
if (pending.length === 0) return [];