Always log detailed input/output/stderr on error (not just in verbose mode), and stop truncating stderr/stdout in structured log fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
527 lines
15 KiB
TypeScript
527 lines
15 KiB
TypeScript
/**
|
|
* Container Runner for NanoClaw
|
|
* Spawns agent execution in Apple Container and handles IPC
|
|
*/
|
|
import { ChildProcess, exec, spawn } from 'child_process';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
import {
|
|
CONTAINER_IMAGE,
|
|
CONTAINER_MAX_OUTPUT_SIZE,
|
|
CONTAINER_TIMEOUT,
|
|
DATA_DIR,
|
|
GROUPS_DIR,
|
|
} from './config.js';
|
|
import { logger } from './logger.js';
|
|
import { validateAdditionalMounts } from './mount-security.js';
|
|
import { RegisteredGroup } from './types.js';
|
|
|
|
// Sentinel markers for robust output parsing (must match agent-runner)
|
|
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;
|
|
groupFolder: string;
|
|
chatJid: string;
|
|
isMain: boolean;
|
|
}
|
|
|
|
export interface AgentResponse {
|
|
outputType: 'message' | 'log';
|
|
userMessage?: string;
|
|
internalLog?: string;
|
|
}
|
|
|
|
export interface ContainerOutput {
|
|
status: 'success' | 'error';
|
|
result: AgentResponse | null;
|
|
newSessionId?: string;
|
|
error?: string;
|
|
}
|
|
|
|
interface VolumeMount {
|
|
hostPath: string;
|
|
containerPath: string;
|
|
readonly: boolean;
|
|
}
|
|
|
|
function buildVolumeMounts(
|
|
group: RegisteredGroup,
|
|
isMain: boolean,
|
|
): VolumeMount[] {
|
|
const mounts: VolumeMount[] = [];
|
|
const homeDir = getHomeDir();
|
|
const projectRoot = process.cwd();
|
|
|
|
if (isMain) {
|
|
// Main gets the entire project root mounted
|
|
mounts.push({
|
|
hostPath: projectRoot,
|
|
containerPath: '/workspace/project',
|
|
readonly: false,
|
|
});
|
|
|
|
// Main also gets its group folder as the working directory
|
|
mounts.push({
|
|
hostPath: path.join(GROUPS_DIR, group.folder),
|
|
containerPath: '/workspace/group',
|
|
readonly: false,
|
|
});
|
|
} else {
|
|
// Other groups only get their own folder
|
|
mounts.push({
|
|
hostPath: path.join(GROUPS_DIR, group.folder),
|
|
containerPath: '/workspace/group',
|
|
readonly: false,
|
|
});
|
|
|
|
// Global memory directory (read-only for non-main)
|
|
// Apple Container only supports directory mounts, not file mounts
|
|
const globalDir = path.join(GROUPS_DIR, 'global');
|
|
if (fs.existsSync(globalDir)) {
|
|
mounts.push({
|
|
hostPath: globalDir,
|
|
containerPath: '/workspace/global',
|
|
readonly: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Per-group Claude sessions directory (isolated from other groups)
|
|
// Each group gets their own .claude/ to prevent cross-group session access
|
|
const groupSessionsDir = path.join(
|
|
DATA_DIR,
|
|
'sessions',
|
|
group.folder,
|
|
'.claude',
|
|
);
|
|
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
|
mounts.push({
|
|
hostPath: groupSessionsDir,
|
|
containerPath: '/home/node/.claude',
|
|
readonly: false,
|
|
});
|
|
|
|
// 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);
|
|
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
|
|
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
|
|
mounts.push({
|
|
hostPath: groupIpcDir,
|
|
containerPath: '/workspace/ipc',
|
|
readonly: false,
|
|
});
|
|
|
|
// Environment file directory (workaround for Apple Container -i env var bug)
|
|
// Only expose specific auth variables needed by Claude Code, not the entire .env
|
|
const envDir = path.join(DATA_DIR, 'env');
|
|
fs.mkdirSync(envDir, { recursive: true });
|
|
const envFile = path.join(projectRoot, '.env');
|
|
if (fs.existsSync(envFile)) {
|
|
const envContent = fs.readFileSync(envFile, 'utf-8');
|
|
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
|
|
const filteredLines = envContent.split('\n').filter((line) => {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) return false;
|
|
return allowedVars.some((v) => trimmed.startsWith(`${v}=`));
|
|
});
|
|
|
|
if (filteredLines.length > 0) {
|
|
fs.writeFileSync(
|
|
path.join(envDir, 'env'),
|
|
filteredLines.join('\n') + '\n',
|
|
);
|
|
mounts.push({
|
|
hostPath: envDir,
|
|
containerPath: '/workspace/env-dir',
|
|
readonly: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Additional mounts validated against external allowlist (tamper-proof from containers)
|
|
if (group.containerConfig?.additionalMounts) {
|
|
const validatedMounts = validateAdditionalMounts(
|
|
group.containerConfig.additionalMounts,
|
|
group.name,
|
|
isMain,
|
|
);
|
|
mounts.push(...validatedMounts);
|
|
}
|
|
|
|
return mounts;
|
|
}
|
|
|
|
function buildContainerArgs(mounts: VolumeMount[], containerName: string): string[] {
|
|
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
|
|
|
// Apple Container: --mount for readonly, -v for read-write
|
|
for (const mount of mounts) {
|
|
if (mount.readonly) {
|
|
args.push(
|
|
'--mount',
|
|
`type=bind,source=${mount.hostPath},target=${mount.containerPath},readonly`,
|
|
);
|
|
} else {
|
|
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
|
|
}
|
|
}
|
|
|
|
args.push(CONTAINER_IMAGE);
|
|
|
|
return args;
|
|
}
|
|
|
|
export async function runContainerAgent(
|
|
group: RegisteredGroup,
|
|
input: ContainerInput,
|
|
onProcess: (proc: ChildProcess, containerName: string) => void,
|
|
): Promise<ContainerOutput> {
|
|
const startTime = Date.now();
|
|
|
|
const groupDir = path.join(GROUPS_DIR, group.folder);
|
|
fs.mkdirSync(groupDir, { recursive: true });
|
|
|
|
const mounts = buildVolumeMounts(group, input.isMain);
|
|
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
|
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
|
|
const containerArgs = buildContainerArgs(mounts, containerName);
|
|
|
|
logger.debug(
|
|
{
|
|
group: group.name,
|
|
containerName,
|
|
mounts: mounts.map(
|
|
(m) =>
|
|
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
|
|
),
|
|
containerArgs: containerArgs.join(' '),
|
|
},
|
|
'Container mount configuration',
|
|
);
|
|
|
|
logger.info(
|
|
{
|
|
group: group.name,
|
|
containerName,
|
|
mountCount: mounts.length,
|
|
isMain: input.isMain,
|
|
},
|
|
'Spawning container agent',
|
|
);
|
|
|
|
const logsDir = path.join(GROUPS_DIR, group.folder, 'logs');
|
|
fs.mkdirSync(logsDir, { recursive: true });
|
|
|
|
return new Promise((resolve) => {
|
|
const container = spawn('container', containerArgs, {
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
|
|
onProcess(container, containerName);
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let stdoutTruncated = false;
|
|
let stderrTruncated = false;
|
|
|
|
container.stdin.write(JSON.stringify(input));
|
|
container.stdin.end();
|
|
|
|
container.stdout.on('data', (data) => {
|
|
if (stdoutTruncated) return;
|
|
const chunk = data.toString();
|
|
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
|
|
if (chunk.length > remaining) {
|
|
stdout += chunk.slice(0, remaining);
|
|
stdoutTruncated = true;
|
|
logger.warn(
|
|
{ group: group.name, size: stdout.length },
|
|
'Container stdout truncated due to size limit',
|
|
);
|
|
} else {
|
|
stdout += chunk;
|
|
}
|
|
});
|
|
|
|
container.stderr.on('data', (data) => {
|
|
const chunk = data.toString();
|
|
const lines = chunk.trim().split('\n');
|
|
for (const line of lines) {
|
|
if (line) logger.debug({ container: group.folder }, line);
|
|
}
|
|
if (stderrTruncated) return;
|
|
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
|
|
if (chunk.length > remaining) {
|
|
stderr += chunk.slice(0, remaining);
|
|
stderrTruncated = true;
|
|
logger.warn(
|
|
{ group: group.name, size: stderr.length },
|
|
'Container stderr truncated due to size limit',
|
|
);
|
|
} else {
|
|
stderr += chunk;
|
|
}
|
|
});
|
|
|
|
let timedOut = false;
|
|
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
logger.error({ group: group.name, containerName }, 'Container timeout, stopping gracefully');
|
|
// Graceful stop: sends SIGTERM, waits, then SIGKILL — lets --rm fire
|
|
exec(`container stop ${containerName}`, { timeout: 15000 }, (err) => {
|
|
if (err) {
|
|
logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing');
|
|
container.kill('SIGKILL');
|
|
}
|
|
});
|
|
}, group.containerConfig?.timeout || CONTAINER_TIMEOUT);
|
|
|
|
container.on('close', (code) => {
|
|
clearTimeout(timeout);
|
|
const duration = Date.now() - startTime;
|
|
|
|
if (timedOut) {
|
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const timeoutLog = path.join(logsDir, `container-${ts}.log`);
|
|
fs.writeFileSync(timeoutLog, [
|
|
`=== Container Run Log (TIMEOUT) ===`,
|
|
`Timestamp: ${new Date().toISOString()}`,
|
|
`Group: ${group.name}`,
|
|
`Container: ${containerName}`,
|
|
`Duration: ${duration}ms`,
|
|
`Exit Code: ${code}`,
|
|
].join('\n'));
|
|
|
|
logger.error(
|
|
{ group: group.name, containerName, duration, code },
|
|
'Container timed out',
|
|
);
|
|
|
|
resolve({
|
|
status: 'error',
|
|
result: null,
|
|
error: `Container timed out after ${group.containerConfig?.timeout || CONTAINER_TIMEOUT}ms`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const logFile = path.join(logsDir, `container-${timestamp}.log`);
|
|
const isVerbose =
|
|
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
|
|
|
const logLines = [
|
|
`=== Container Run Log ===`,
|
|
`Timestamp: ${new Date().toISOString()}`,
|
|
`Group: ${group.name}`,
|
|
`IsMain: ${input.isMain}`,
|
|
`Duration: ${duration}ms`,
|
|
`Exit Code: ${code}`,
|
|
`Stdout Truncated: ${stdoutTruncated}`,
|
|
`Stderr Truncated: ${stderrTruncated}`,
|
|
``,
|
|
];
|
|
|
|
const isError = code !== 0;
|
|
|
|
if (isVerbose || isError) {
|
|
logLines.push(
|
|
`=== Input ===`,
|
|
JSON.stringify(input, null, 2),
|
|
``,
|
|
`=== Container Args ===`,
|
|
containerArgs.join(' '),
|
|
``,
|
|
`=== Mounts ===`,
|
|
mounts
|
|
.map(
|
|
(m) =>
|
|
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
|
|
)
|
|
.join('\n'),
|
|
``,
|
|
`=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
|
|
stderr,
|
|
``,
|
|
`=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
|
|
stdout,
|
|
);
|
|
} else {
|
|
logLines.push(
|
|
`=== Input Summary ===`,
|
|
`Prompt length: ${input.prompt.length} chars`,
|
|
`Session ID: ${input.sessionId || 'new'}`,
|
|
``,
|
|
`=== Mounts ===`,
|
|
mounts
|
|
.map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`)
|
|
.join('\n'),
|
|
``,
|
|
);
|
|
}
|
|
|
|
fs.writeFileSync(logFile, logLines.join('\n'));
|
|
logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
|
|
|
|
if (code !== 0) {
|
|
logger.error(
|
|
{
|
|
group: group.name,
|
|
code,
|
|
duration,
|
|
stderr,
|
|
stdout,
|
|
logFile,
|
|
},
|
|
'Container exited with error',
|
|
);
|
|
|
|
resolve({
|
|
status: 'error',
|
|
result: null,
|
|
error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Extract JSON between sentinel markers for robust parsing
|
|
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
|
|
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
|
|
|
|
let jsonLine: string;
|
|
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
jsonLine = stdout
|
|
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
|
|
.trim();
|
|
} else {
|
|
// Fallback: last non-empty line (backwards compatibility)
|
|
const lines = stdout.trim().split('\n');
|
|
jsonLine = lines[lines.length - 1];
|
|
}
|
|
|
|
const output: ContainerOutput = JSON.parse(jsonLine);
|
|
|
|
logger.info(
|
|
{
|
|
group: group.name,
|
|
duration,
|
|
status: output.status,
|
|
hasResult: !!output.result,
|
|
},
|
|
'Container completed',
|
|
);
|
|
|
|
resolve(output);
|
|
} catch (err) {
|
|
logger.error(
|
|
{
|
|
group: group.name,
|
|
stdout,
|
|
stderr,
|
|
error: err,
|
|
},
|
|
'Failed to parse container output',
|
|
);
|
|
|
|
resolve({
|
|
status: 'error',
|
|
result: null,
|
|
error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
|
|
});
|
|
}
|
|
});
|
|
|
|
container.on('error', (err) => {
|
|
clearTimeout(timeout);
|
|
logger.error({ group: group.name, containerName, error: err }, 'Container spawn error');
|
|
resolve({
|
|
status: 'error',
|
|
result: null,
|
|
error: `Container spawn error: ${err.message}`,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
export function writeTasksSnapshot(
|
|
groupFolder: string,
|
|
isMain: boolean,
|
|
tasks: Array<{
|
|
id: string;
|
|
groupFolder: string;
|
|
prompt: string;
|
|
schedule_type: string;
|
|
schedule_value: string;
|
|
status: string;
|
|
next_run: string | null;
|
|
}>,
|
|
): void {
|
|
// Write filtered tasks to the group's IPC directory
|
|
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
|
|
fs.mkdirSync(groupIpcDir, { recursive: true });
|
|
|
|
// Main sees all tasks, others only see their own
|
|
const filteredTasks = isMain
|
|
? tasks
|
|
: tasks.filter((t) => t.groupFolder === groupFolder);
|
|
|
|
const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
|
|
fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
|
|
}
|
|
|
|
export interface AvailableGroup {
|
|
jid: string;
|
|
name: string;
|
|
lastActivity: string;
|
|
isRegistered: boolean;
|
|
}
|
|
|
|
/**
|
|
* Write available groups snapshot for the container to read.
|
|
* Only main group can see all available groups (for activation).
|
|
* Non-main groups only see their own registration status.
|
|
*/
|
|
export function writeGroupsSnapshot(
|
|
groupFolder: string,
|
|
isMain: boolean,
|
|
groups: AvailableGroup[],
|
|
registeredJids: Set<string>,
|
|
): void {
|
|
const groupIpcDir = path.join(DATA_DIR, 'ipc', groupFolder);
|
|
fs.mkdirSync(groupIpcDir, { recursive: true });
|
|
|
|
// Main sees all groups; others see nothing (they can't activate groups)
|
|
const visibleGroups = isMain ? groups : [];
|
|
|
|
const groupsFile = path.join(groupIpcDir, 'available_groups.json');
|
|
fs.writeFileSync(
|
|
groupsFile,
|
|
JSON.stringify(
|
|
{
|
|
groups: visibleGroups,
|
|
lastSync: new Date().toISOString(),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
}
|