merge: resolve conflict with origin/main
Keep ASSISTANT_NAME import, drop removed GROUPS_DIR import. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ vi.mock('./config.js', () => ({
|
||||
DATA_DIR: '/tmp/nanoclaw-test-data',
|
||||
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
||||
IDLE_TIMEOUT: 1800000, // 30min
|
||||
TIMEZONE: 'America/Los_Angeles',
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
import { ChildProcess, exec, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
@@ -14,8 +13,10 @@ import {
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer } from './container-runtime.js';
|
||||
import { validateAdditionalMounts } from './mount-security.js';
|
||||
@@ -25,16 +26,6 @@ import { RegisteredGroup } from './types.js';
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
|
||||
function getHomeDir(): string {
|
||||
const home = process.env.HOME || os.homedir();
|
||||
if (!home) {
|
||||
throw new Error(
|
||||
'Unable to determine home directory: HOME environment variable is not set and os.homedir() returned empty',
|
||||
);
|
||||
}
|
||||
return home;
|
||||
}
|
||||
|
||||
export interface ContainerInput {
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
@@ -64,27 +55,31 @@ function buildVolumeMounts(
|
||||
isMain: boolean,
|
||||
): VolumeMount[] {
|
||||
const mounts: VolumeMount[] = [];
|
||||
const homeDir = getHomeDir();
|
||||
const projectRoot = process.cwd();
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
|
||||
if (isMain) {
|
||||
// Main gets the entire project root mounted
|
||||
// Main gets the project root read-only. Writable paths the agent needs
|
||||
// (group folder, IPC, .claude/) are mounted separately below.
|
||||
// Read-only prevents the agent from modifying host application code
|
||||
// (src/, dist/, package.json, etc.) which would bypass the sandbox
|
||||
// entirely on next restart.
|
||||
mounts.push({
|
||||
hostPath: projectRoot,
|
||||
containerPath: '/workspace/project',
|
||||
readonly: false,
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
// Main also gets its group folder as the working directory
|
||||
mounts.push({
|
||||
hostPath: path.join(GROUPS_DIR, group.folder),
|
||||
hostPath: groupDir,
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false,
|
||||
});
|
||||
} else {
|
||||
// Other groups only get their own folder
|
||||
mounts.push({
|
||||
hostPath: path.join(GROUPS_DIR, group.folder),
|
||||
hostPath: groupDir,
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false,
|
||||
});
|
||||
@@ -146,7 +141,7 @@ function buildVolumeMounts(
|
||||
|
||||
// Per-group IPC namespace: each group gets its own IPC directory
|
||||
// This prevents cross-group privilege escalation via IPC
|
||||
const groupIpcDir = path.join(DATA_DIR, 'ipc', group.folder);
|
||||
const groupIpcDir = resolveGroupIpcPath(group.folder);
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
|
||||
@@ -156,13 +151,18 @@ function buildVolumeMounts(
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Mount agent-runner source from host — recompiled on container startup.
|
||||
// Bypasses sticky build cache for code changes.
|
||||
// Copy agent-runner source into a per-group writable location so agents
|
||||
// can customize it (add tools, change behavior) without affecting other
|
||||
// groups. Recompiled on container startup via entrypoint.sh.
|
||||
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
|
||||
const groupAgentRunnerDir = path.join(DATA_DIR, 'sessions', group.folder, 'agent-runner-src');
|
||||
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
|
||||
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
|
||||
}
|
||||
mounts.push({
|
||||
hostPath: agentRunnerSrc,
|
||||
hostPath: groupAgentRunnerDir,
|
||||
containerPath: '/app/src',
|
||||
readonly: true,
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Additional mounts validated against external allowlist (tamper-proof from containers)
|
||||
@@ -189,6 +189,9 @@ function readSecrets(): Record<string, string> {
|
||||
function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] {
|
||||
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
||||
|
||||
// Pass host timezone so container's local time matches the user's
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
// Run as host user so bind-mounted files are accessible.
|
||||
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
||||
// or when getuid is unavailable (native Windows without WSL).
|
||||
@@ -220,7 +223,7 @@ export async function runContainerAgent(
|
||||
): Promise<ContainerOutput> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const groupDir = path.join(GROUPS_DIR, group.folder);
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
|
||||
const mounts = buildVolumeMounts(group, input.isMain);
|
||||
@@ -251,7 +254,7 @@ export async function runContainerAgent(
|
||||
'Spawning container agent',
|
||||
);
|
||||
|
||||
const logsDir = path.join(GROUPS_DIR, group.folder, 'logs');
|
||||
const logsDir = path.join(groupDir, 'logs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
@@ -595,7 +598,7 @@ export function writeTasksSnapshot(
|
||||
}>,
|
||||
): void {
|
||||
// Write filtered tasks to the group's IPC directory
|
||||
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
|
||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||
|
||||
// Main sees all tasks, others only see their own
|
||||
@@ -625,7 +628,7 @@ export function writeGroupsSnapshot(
|
||||
groups: AvailableGroup[],
|
||||
registeredJids: Set<string>,
|
||||
): void {
|
||||
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
|
||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||
|
||||
// Main sees all groups; others see nothing (they can't activate groups)
|
||||
|
||||
28
src/db.ts
28
src/db.ts
@@ -3,6 +3,8 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js';
|
||||
import { isValidGroupFolder } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { NewMessage, RegisteredGroup, ScheduledTask, TaskRunLog } from './types.js';
|
||||
|
||||
let db: Database.Database;
|
||||
@@ -520,6 +522,13 @@ export function getRegisteredGroup(
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return undefined;
|
||||
if (!isValidGroupFolder(row.folder)) {
|
||||
logger.warn(
|
||||
{ jid: row.jid, folder: row.folder },
|
||||
'Skipping registered group with invalid folder',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
jid: row.jid,
|
||||
name: row.name,
|
||||
@@ -537,6 +546,9 @@ export function setRegisteredGroup(
|
||||
jid: string,
|
||||
group: RegisteredGroup,
|
||||
): void {
|
||||
if (!isValidGroupFolder(group.folder)) {
|
||||
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
@@ -565,6 +577,13 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
||||
}>;
|
||||
const result: Record<string, RegisteredGroup> = {};
|
||||
for (const row of rows) {
|
||||
if (!isValidGroupFolder(row.folder)) {
|
||||
logger.warn(
|
||||
{ jid: row.jid, folder: row.folder },
|
||||
'Skipping registered group with invalid folder',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
result[row.jid] = {
|
||||
name: row.name,
|
||||
folder: row.folder,
|
||||
@@ -629,7 +648,14 @@ function migrateJsonState(): void {
|
||||
> | null;
|
||||
if (groups) {
|
||||
for (const [jid, group] of Object.entries(groups)) {
|
||||
setRegisteredGroup(jid, group);
|
||||
try {
|
||||
setRegisteredGroup(jid, group);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ jid, folder: group.folder, err },
|
||||
'Skipping migrated registered group with invalid folder',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
src/group-folder.test.ts
Normal file
39
src/group-folder.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isValidGroupFolder, resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
|
||||
describe('group folder validation', () => {
|
||||
it('accepts normal group folder names', () => {
|
||||
expect(isValidGroupFolder('main')).toBe(true);
|
||||
expect(isValidGroupFolder('family-chat')).toBe(true);
|
||||
expect(isValidGroupFolder('Team_42')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects traversal and reserved names', () => {
|
||||
expect(isValidGroupFolder('../../etc')).toBe(false);
|
||||
expect(isValidGroupFolder('/tmp')).toBe(false);
|
||||
expect(isValidGroupFolder('global')).toBe(false);
|
||||
expect(isValidGroupFolder('')).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves safe paths under groups directory', () => {
|
||||
const resolved = resolveGroupFolderPath('family-chat');
|
||||
expect(
|
||||
resolved.endsWith(`${path.sep}groups${path.sep}family-chat`),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves safe paths under data ipc directory', () => {
|
||||
const resolved = resolveGroupIpcPath('family-chat');
|
||||
expect(
|
||||
resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('throws for unsafe folder names', () => {
|
||||
expect(() => resolveGroupFolderPath('../../etc')).toThrow();
|
||||
expect(() => resolveGroupIpcPath('/tmp')).toThrow();
|
||||
});
|
||||
});
|
||||
44
src/group-folder.ts
Normal file
44
src/group-folder.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR, GROUPS_DIR } from './config.js';
|
||||
|
||||
const GROUP_FOLDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
||||
const RESERVED_FOLDERS = new Set(['global']);
|
||||
|
||||
export function isValidGroupFolder(folder: string): boolean {
|
||||
if (!folder) return false;
|
||||
if (folder !== folder.trim()) return false;
|
||||
if (!GROUP_FOLDER_PATTERN.test(folder)) return false;
|
||||
if (folder.includes('/') || folder.includes('\\')) return false;
|
||||
if (folder.includes('..')) return false;
|
||||
if (RESERVED_FOLDERS.has(folder.toLowerCase())) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function assertValidGroupFolder(folder: string): void {
|
||||
if (!isValidGroupFolder(folder)) {
|
||||
throw new Error(`Invalid group folder "${folder}"`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWithinBase(baseDir: string, resolvedPath: string): void {
|
||||
const rel = path.relative(baseDir, resolvedPath);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error(`Path escapes base directory: ${resolvedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGroupFolderPath(folder: string): string {
|
||||
assertValidGroupFolder(folder);
|
||||
const groupPath = path.resolve(GROUPS_DIR, folder);
|
||||
ensureWithinBase(GROUPS_DIR, groupPath);
|
||||
return groupPath;
|
||||
}
|
||||
|
||||
export function resolveGroupIpcPath(folder: string): string {
|
||||
assertValidGroupFolder(folder);
|
||||
const ipcBaseDir = path.resolve(DATA_DIR, 'ipc');
|
||||
const ipcPath = path.resolve(ipcBaseDir, folder);
|
||||
ensureWithinBase(ipcBaseDir, ipcPath);
|
||||
return ipcPath;
|
||||
}
|
||||
14
src/index.ts
14
src/index.ts
@@ -3,7 +3,6 @@ import path from 'path';
|
||||
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
DATA_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
MAIN_GROUP_FOLDER,
|
||||
POLL_INTERVAL,
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
storeMessage,
|
||||
} from './db.js';
|
||||
import { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { startIpcWatcher } from './ipc.js';
|
||||
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
||||
import { startSchedulerLoop } from './task-scheduler.js';
|
||||
@@ -78,11 +78,21 @@ function saveState(): void {
|
||||
}
|
||||
|
||||
function registerGroup(jid: string, group: RegisteredGroup): void {
|
||||
let groupDir: string;
|
||||
try {
|
||||
groupDir = resolveGroupFolderPath(group.folder);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ jid, folder: group.folder, err },
|
||||
'Rejecting group registration with invalid folder',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
registeredGroups[jid] = group;
|
||||
setRegisteredGroup(jid, group);
|
||||
|
||||
// Create group folder
|
||||
const groupDir = path.join(DATA_DIR, '..', 'groups', group.folder);
|
||||
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -301,6 +301,23 @@ describe('register_group authorization', () => {
|
||||
// registeredGroups should not have changed
|
||||
expect(groups['new@g.us']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('main group cannot register with unsafe folder path', async () => {
|
||||
await processTaskIpc(
|
||||
{
|
||||
type: 'register_group',
|
||||
jid: 'new@g.us',
|
||||
name: 'New Group',
|
||||
folder: '../../outside',
|
||||
trigger: '@Andy',
|
||||
},
|
||||
'main',
|
||||
true,
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(groups['new@g.us']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// --- refresh_groups authorization ---
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './config.js';
|
||||
import { AvailableGroup } from './container-runner.js';
|
||||
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
||||
import { isValidGroupFolder } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
@@ -357,6 +358,13 @@ export async function processTaskIpc(
|
||||
break;
|
||||
}
|
||||
if (data.jid && data.name && data.folder && data.trigger) {
|
||||
if (!isValidGroupFolder(data.folder)) {
|
||||
logger.warn(
|
||||
{ sourceGroup, folder: data.folder },
|
||||
'Invalid register_group request - unsafe folder name',
|
||||
);
|
||||
break;
|
||||
}
|
||||
deps.registerGroup(data.jid, {
|
||||
name: data.name,
|
||||
folder: data.folder,
|
||||
|
||||
53
src/task-scheduler.test.ts
Normal file
53
src/task-scheduler.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { _initTestDatabase, createTask, getTaskById } from './db.js';
|
||||
import {
|
||||
_resetSchedulerLoopForTests,
|
||||
startSchedulerLoop,
|
||||
} from './task-scheduler.js';
|
||||
|
||||
describe('task scheduler', () => {
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
_resetSchedulerLoopForTests();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('pauses due tasks with invalid group folders to prevent retry churn', async () => {
|
||||
createTask({
|
||||
id: 'task-invalid-folder',
|
||||
group_folder: '../../outside',
|
||||
chat_jid: 'bad@g.us',
|
||||
prompt: 'run',
|
||||
schedule_type: 'once',
|
||||
schedule_value: '2026-02-22T00:00:00.000Z',
|
||||
context_mode: 'isolated',
|
||||
next_run: new Date(Date.now() - 60_000).toISOString(),
|
||||
status: 'active',
|
||||
created_at: '2026-02-22T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const enqueueTask = vi.fn(
|
||||
(_groupJid: string, _taskId: string, fn: () => Promise<void>) => {
|
||||
void fn();
|
||||
},
|
||||
);
|
||||
|
||||
startSchedulerLoop({
|
||||
registeredGroups: () => ({}),
|
||||
getSessions: () => ({}),
|
||||
queue: { enqueueTask } as any,
|
||||
onProcess: () => {},
|
||||
sendMessage: async () => {},
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
const task = getTaskById('task-invalid-folder');
|
||||
expect(task?.status).toBe('paused');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
ASSISTANT_NAME,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
MAIN_GROUP_FOLDER,
|
||||
SCHEDULER_POLL_INTERVAL,
|
||||
@@ -17,9 +15,11 @@ import {
|
||||
getDueTasks,
|
||||
getTaskById,
|
||||
logTaskRun,
|
||||
updateTask,
|
||||
updateTaskAfterRun,
|
||||
} from './db.js';
|
||||
import { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import { RegisteredGroup, ScheduledTask } from './types.js';
|
||||
|
||||
@@ -36,7 +36,27 @@ async function runTask(
|
||||
deps: SchedulerDependencies,
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const groupDir = path.join(GROUPS_DIR, task.group_folder);
|
||||
let groupDir: string;
|
||||
try {
|
||||
groupDir = resolveGroupFolderPath(task.group_folder);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
// Stop retry churn for malformed legacy rows.
|
||||
updateTask(task.id, { status: 'paused' });
|
||||
logger.error(
|
||||
{ taskId: task.id, groupFolder: task.group_folder, error },
|
||||
'Task has invalid group folder',
|
||||
);
|
||||
logTaskRun({
|
||||
task_id: task.id,
|
||||
run_at: new Date().toISOString(),
|
||||
duration_ms: Date.now() - startTime,
|
||||
status: 'error',
|
||||
result: null,
|
||||
error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
|
||||
logger.info(
|
||||
@@ -222,3 +242,8 @@ export function startSchedulerLoop(deps: SchedulerDependencies): void {
|
||||
|
||||
loop();
|
||||
}
|
||||
|
||||
/** @internal - for tests only. */
|
||||
export function _resetSchedulerLoopForTests(): void {
|
||||
schedulerRunning = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user