refactor: scaffold module registries and default-module layout

Additive change — existing code paths still run via inline fallbacks.
Prepares core for per-module extractions in PR #3 onward.

Four registries added with empty defaults:
  - delivery action handlers (delivery.ts)
  - router inbound gate (router.ts)
  - response dispatcher (index.ts)
  - MCP tool self-registration (container/agent-runner/src/mcp-tools/server.ts)

Default modules moved to src/modules/ for signaling:
  - src/modules/typing/       (extracted from delivery.ts)
  - src/modules/mount-security/ (moved from src/mount-security.ts)

Both are imported directly by core — no hook, no registry. Removal
requires editing core imports.

Migrator now keys applied rows by name (uniqueness) so module
migrations can pick arbitrary version numbers. Stored version column
is auto-assigned as an applied-order sequence.

sqlite_master guards added around core calls into module-owned tables
(user_roles, agent_destinations, pending_questions). No-ops today;
load-bearing after the owning modules are extracted.

MODULE-HOOK markers placed at scheduling's two skill-edit sites
(host-sweep.ts recurrence call, poll-loop.ts pre-task gate). PR #4
replaces the marked blocks when scheduling moves to its module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-18 14:46:19 +03:00
parent 1888ecc1e9
commit 4202041d0b
19 changed files with 480 additions and 234 deletions

16
src/modules/index.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* Modules barrel.
*
* Each module self-registers at import time. This barrel is imported by
* src/index.ts for side effects (registry registrations, typing impl setup,
* etc.). Core runs with an empty barrel — the registries have inline
* fallbacks and `sqlite_master` guards.
*
* Default modules (ship with main, direct core import):
* - src/modules/typing/ → imported directly by router/delivery/container-runner
* - src/modules/mount-security/ → imported directly by container-runner
*
* Registry-based modules (installed via /add-<name> skills, pulled from the
* `modules` branch): append imports below.
*/
export {};

View File

@@ -0,0 +1,389 @@
/**
* Mount Security Module for NanoClaw
*
* Validates additional mounts against an allowlist stored OUTSIDE the project root.
* This prevents container agents from modifying security configuration.
*
* Allowlist location: ~/.config/nanoclaw/mount-allowlist.json
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { MOUNT_ALLOWLIST_PATH } from '../../config.js';
import { log } from '../../log.js';
export interface AdditionalMount {
hostPath: string;
containerPath?: string;
readonly?: boolean;
}
export interface MountAllowlist {
allowedRoots: AllowedRoot[];
blockedPatterns: string[];
}
export interface AllowedRoot {
path: string;
allowReadWrite: boolean;
description?: string;
}
// Cache the allowlist in memory - only reloads on process restart
let cachedAllowlist: MountAllowlist | null = null;
let allowlistLoadError: string | null = null;
/**
* Default blocked patterns - paths that should never be mounted
*/
const DEFAULT_BLOCKED_PATTERNS = [
'.ssh',
'.gnupg',
'.gpg',
'.aws',
'.azure',
'.gcloud',
'.kube',
'.docker',
'credentials',
'.env',
'.netrc',
'.npmrc',
'.pypirc',
'id_rsa',
'id_ed25519',
'private_key',
'.secret',
];
/**
* Load the mount allowlist from the external config location.
* Returns null if the file doesn't exist or is invalid.
* Result is cached in memory for the lifetime of the process.
*/
export function loadMountAllowlist(): MountAllowlist | null {
if (cachedAllowlist !== null) {
return cachedAllowlist;
}
if (allowlistLoadError !== null) {
// Already tried and failed, don't spam logs
return null;
}
try {
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
// Do NOT cache this as an error — file may be created later without restart.
// Only parse/structural errors are permanently cached.
log.warn(
'Mount allowlist not found - additional mounts will be BLOCKED. Create the file to enable additional mounts.',
{ path: MOUNT_ALLOWLIST_PATH },
);
return null;
}
const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8');
const allowlist = JSON.parse(content) as MountAllowlist;
// Validate structure
if (!Array.isArray(allowlist.allowedRoots)) {
throw new Error('allowedRoots must be an array');
}
if (!Array.isArray(allowlist.blockedPatterns)) {
throw new Error('blockedPatterns must be an array');
}
// Merge with default blocked patterns
const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
allowlist.blockedPatterns = mergedBlockedPatterns;
cachedAllowlist = allowlist;
log.info('Mount allowlist loaded successfully', {
path: MOUNT_ALLOWLIST_PATH,
allowedRoots: allowlist.allowedRoots.length,
blockedPatterns: allowlist.blockedPatterns.length,
});
return cachedAllowlist;
} catch (err) {
allowlistLoadError = err instanceof Error ? err.message : String(err);
log.error('Failed to load mount allowlist - additional mounts will be BLOCKED', {
path: MOUNT_ALLOWLIST_PATH,
error: allowlistLoadError,
});
return null;
}
}
/**
* Expand ~ to home directory and resolve to absolute path
*/
function expandPath(p: string): string {
const homeDir = process.env.HOME || os.homedir();
if (p.startsWith('~/')) {
return path.join(homeDir, p.slice(2));
}
if (p === '~') {
return homeDir;
}
return path.resolve(p);
}
/**
* Get the real path, resolving symlinks.
* Returns null if the path doesn't exist.
*/
function getRealPath(p: string): string | null {
try {
return fs.realpathSync(p);
} catch {
return null;
}
}
/**
* Check if a path matches any blocked pattern
*/
function matchesBlockedPattern(realPath: string, blockedPatterns: string[]): string | null {
const pathParts = realPath.split(path.sep);
for (const pattern of blockedPatterns) {
// Check if any path component matches the pattern
for (const part of pathParts) {
if (part === pattern || part.includes(pattern)) {
return pattern;
}
}
// Also check if the full path contains the pattern
if (realPath.includes(pattern)) {
return pattern;
}
}
return null;
}
/**
* Check if a real path is under an allowed root
*/
function findAllowedRoot(realPath: string, allowedRoots: AllowedRoot[]): AllowedRoot | null {
for (const root of allowedRoots) {
const expandedRoot = expandPath(root.path);
const realRoot = getRealPath(expandedRoot);
if (realRoot === null) {
// Allowed root doesn't exist, skip it
continue;
}
// Check if realPath is under realRoot
const relative = path.relative(realRoot, realPath);
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
return root;
}
}
return null;
}
/**
* Validate the container path to prevent escaping /workspace/extra/
*/
function isValidContainerPath(containerPath: string): boolean {
// Must not contain .. to prevent path traversal
if (containerPath.includes('..')) {
return false;
}
// Must not be absolute (it will be prefixed with /workspace/extra/)
if (containerPath.startsWith('/')) {
return false;
}
// Must not be empty
if (!containerPath || containerPath.trim() === '') {
return false;
}
// Must not contain colons — prevents Docker -v option injection (e.g., "repo:rw")
if (containerPath.includes(':')) {
return false;
}
return true;
}
export interface MountValidationResult {
allowed: boolean;
reason: string;
realHostPath?: string;
resolvedContainerPath?: string;
effectiveReadonly?: boolean;
}
/**
* Validate a single additional mount against the allowlist.
* Returns validation result with reason.
*/
export function validateMount(mount: AdditionalMount): MountValidationResult {
const allowlist = loadMountAllowlist();
// If no allowlist, block all additional mounts
if (allowlist === null) {
return {
allowed: false,
reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}`,
};
}
// Derive containerPath from hostPath basename if not specified
const containerPath = mount.containerPath || path.basename(mount.hostPath);
// Validate container path (cheap check)
if (!isValidContainerPath(containerPath)) {
return {
allowed: false,
reason: `Invalid container path: "${containerPath}" - must be relative, non-empty, and not contain ".."`,
};
}
// Expand and resolve the host path
const expandedPath = expandPath(mount.hostPath);
const realPath = getRealPath(expandedPath);
if (realPath === null) {
return {
allowed: false,
reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")`,
};
}
// Check against blocked patterns
const blockedMatch = matchesBlockedPattern(realPath, allowlist.blockedPatterns);
if (blockedMatch !== null) {
return {
allowed: false,
reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"`,
};
}
// Check if under an allowed root
const allowedRoot = findAllowedRoot(realPath, allowlist.allowedRoots);
if (allowedRoot === null) {
return {
allowed: false,
reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${allowlist.allowedRoots
.map((r) => expandPath(r.path))
.join(', ')}`,
};
}
// Determine effective readonly status.
// RW is only granted if the mount explicitly requests it AND the allowed
// root permits it. Otherwise it's forced read-only.
const requestedReadWrite = mount.readonly === false;
let effectiveReadonly = true;
if (requestedReadWrite) {
if (!allowedRoot.allowReadWrite) {
log.info('Mount forced to read-only - root does not allow read-write', {
mount: mount.hostPath,
root: allowedRoot.path,
});
} else {
effectiveReadonly = false;
}
}
return {
allowed: true,
reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`,
realHostPath: realPath,
resolvedContainerPath: containerPath,
effectiveReadonly,
};
}
/**
* Validate all additional mounts for a group.
* Returns array of validated mounts (only those that passed validation).
* Logs warnings for rejected mounts.
*/
export function validateAdditionalMounts(
mounts: AdditionalMount[],
groupName: string,
): Array<{
hostPath: string;
containerPath: string;
readonly: boolean;
}> {
const validatedMounts: Array<{
hostPath: string;
containerPath: string;
readonly: boolean;
}> = [];
for (const mount of mounts) {
const result = validateMount(mount);
if (result.allowed) {
validatedMounts.push({
hostPath: result.realHostPath!,
containerPath: `/workspace/extra/${result.resolvedContainerPath}`,
readonly: result.effectiveReadonly!,
});
log.debug('Mount validated successfully', {
group: groupName,
hostPath: result.realHostPath,
containerPath: result.resolvedContainerPath,
readonly: result.effectiveReadonly,
reason: result.reason,
});
} else {
log.warn('Additional mount REJECTED', {
group: groupName,
requestedPath: mount.hostPath,
containerPath: mount.containerPath,
reason: result.reason,
});
}
}
return validatedMounts;
}
/**
* Generate a template allowlist file for users to customize
*/
export function generateAllowlistTemplate(): string {
const template: MountAllowlist = {
allowedRoots: [
{
path: '~/projects',
allowReadWrite: true,
description: 'Development projects',
},
{
path: '~/repos',
allowReadWrite: true,
description: 'Git repositories',
},
{
path: '~/Documents/work',
allowReadWrite: false,
description: 'Work documents (read-only)',
},
],
blockedPatterns: [
// Additional patterns beyond defaults
'password',
'secret',
'token',
],
};
return JSON.stringify(template, null, 2);
}

165
src/modules/typing/index.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Typing indicator refresh — default module.
*
* Most platforms expire a typing indicator after 510s, so a one-shot
* call on message arrival goes stale long before the agent finishes
* thinking. This module keeps it alive by re-firing `setTyping` on a
* short interval — but only while the agent is actually WORKING, gated
* on the heartbeat file's mtime after an initial grace period.
*
* After delivering a user-facing message, the refresh is paused for
* POST_DELIVERY_PAUSE_MS so the client-side indicator can visually
* clear.
*
* Default module status:
* - Lives in src/modules/ for signaling (not really core), but ships
* on main and is imported directly by core. No registry, no hook.
* - Removing requires editing src/router.ts, src/delivery.ts, and
* src/container-runner.ts to drop the calls.
*/
import fs from 'fs';
import { heartbeatPath } from '../../session-manager.js';
const TYPING_REFRESH_MS = 4000;
/**
* Grace window from startTypingRefresh: fire typing unconditionally
* for this long regardless of heartbeat state. Covers container
* spawn/wake latency (512s on cold start before first heartbeat).
*/
const TYPING_GRACE_MS = 15000;
/**
* After the grace window, a heartbeat must be mtimed within this
* many ms of now to count as "agent is working." Heartbeats land
* every few hundred ms during active work, so 6s is well above
* the working floor and small enough to stop typing quickly when
* the agent goes idle.
*/
const HEARTBEAT_FRESH_MS = 6000;
/**
* After we deliver a user-facing message, pause typing for this
* long so the client-side indicator has time to visually clear.
* Tuned for the longest common expiry (Discord ~10s). The interval
* stays running; ticks inside the pause just skip the setTyping call.
*/
const POST_DELIVERY_PAUSE_MS = 10000;
interface TypingAdapter {
setTyping?(channelType: string, platformId: string, threadId: string | null): Promise<void>;
}
interface TypingTarget {
agentGroupId: string;
channelType: string;
platformId: string;
threadId: string | null;
interval: NodeJS.Timeout;
startedAt: number;
pausedUntil: number; // epoch ms; 0 = not paused
}
let adapter: TypingAdapter | null = null;
const typingRefreshers = new Map<string, TypingTarget>();
/**
* Bind the typing module to the channel delivery adapter so it can
* call `setTyping`. Called once by `src/delivery.ts` inside
* `setDeliveryAdapter`. Passing a fresh adapter replaces the prior
* binding and leaves active refreshers in place (they'll use the
* new adapter on their next tick).
*/
export function setTypingAdapter(a: TypingAdapter): void {
adapter = a;
}
async function triggerTyping(channelType: string, platformId: string, threadId: string | null): Promise<void> {
try {
await adapter?.setTyping?.(channelType, platformId, threadId);
} catch {
// Typing is best-effort — don't let it fail delivery or routing.
}
}
function isHeartbeatFresh(agentGroupId: string, sessionId: string): boolean {
const hbPath = heartbeatPath(agentGroupId, sessionId);
try {
const stat = fs.statSync(hbPath);
return Date.now() - stat.mtimeMs < HEARTBEAT_FRESH_MS;
} catch {
return false;
}
}
export function startTypingRefresh(
sessionId: string,
agentGroupId: string,
channelType: string,
platformId: string,
threadId: string | null,
): void {
const existing = typingRefreshers.get(sessionId);
if (existing) {
// Already refreshing. Fire an immediate tick for the new inbound
// event and reset the grace window — the new message restarts
// the container-wake latency budget. Also clear any lingering
// post-delivery pause: a new inbound means the user expects
// typing to show immediately.
triggerTyping(channelType, platformId, threadId).catch(() => {});
existing.startedAt = Date.now();
existing.pausedUntil = 0;
return;
}
// Immediate tick + periodic refresh.
triggerTyping(channelType, platformId, threadId).catch(() => {});
const startedAt = Date.now();
const interval = setInterval(() => {
const entry = typingRefreshers.get(sessionId);
if (!entry) return; // stopped externally since this tick was scheduled
// Inside a post-delivery pause: skip setTyping but keep the
// interval running so we resume automatically once the pause
// expires.
if (entry.pausedUntil > Date.now()) return;
const withinGrace = Date.now() - entry.startedAt < TYPING_GRACE_MS;
if (withinGrace || isHeartbeatFresh(entry.agentGroupId, sessionId)) {
triggerTyping(entry.channelType, entry.platformId, entry.threadId).catch(() => {});
return;
}
// Out of grace AND heartbeat stale — agent is idle, stop refreshing.
clearInterval(entry.interval);
typingRefreshers.delete(sessionId);
}, TYPING_REFRESH_MS);
// unref so a stale refresher can't hold the event loop alive.
interval.unref();
typingRefreshers.set(sessionId, {
agentGroupId,
channelType,
platformId,
threadId,
interval,
startedAt,
pausedUntil: 0,
});
}
/**
* Pause the typing refresh for POST_DELIVERY_PAUSE_MS. Called after
* a user-facing message is delivered so the client-side indicator
* has a chance to visually clear before the agent's next SDK event
* pushes it back on. No-op if no refresh is active for this session.
*/
export function pauseTypingRefreshAfterDelivery(sessionId: string): void {
const entry = typingRefreshers.get(sessionId);
if (!entry) return;
entry.pausedUntil = Date.now() + POST_DELIVERY_PAUSE_MS;
}
export function stopTypingRefresh(sessionId: string): void {
const entry = typingRefreshers.get(sessionId);
if (!entry) return;
clearInterval(entry.interval);
typingRefreshers.delete(sessionId);
}