feat: remove container layer, run agents as direct host processes

- Rewrite container-runner.ts to spawn node processes directly instead
  of Docker/Apple Container
- Runner paths configurable via env vars (NANOCLAW_GROUP_DIR, etc.)
  with container-path defaults for backwards compat
- Pass real credentials directly (no credential proxy needed)
- Remove ensureContainerSystemRunning() and startCredentialProxy()
  from startup
- Add build:runners script for building agent-runner and codex-runner
- Fix TS strict mode errors in agent-runner ipc-mcp-stdio.ts
This commit is contained in:
Eyejoker
2026-03-10 21:05:21 +09:00
parent 4a34a9c802
commit 08dd468692
15 changed files with 449 additions and 498 deletions

View File

@@ -724,6 +724,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -928,6 +929,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz",
"integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -1507,6 +1509,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -55,7 +55,13 @@ interface SDKUserMessage {
session_id: string;
}
const IPC_INPUT_DIR = '/workspace/ipc/input';
// Paths configurable via env vars (defaults to container paths for backwards compat)
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const GLOBAL_DIR = process.env.NANOCLAW_GLOBAL_DIR || '/workspace/global';
const EXTRA_BASE = process.env.NANOCLAW_EXTRA_DIR || '/workspace/extra';
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const MAX_TURNS = 100;
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
@@ -167,7 +173,7 @@ function createPreCompactHook(assistantName?: string): HookCallback {
const summary = getSessionSummary(sessionId, transcriptPath);
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
const conversationsDir = '/workspace/group/conversations';
const conversationsDir = path.join(GROUP_DIR, 'conversations');
fs.mkdirSync(conversationsDir, { recursive: true });
const date = new Date().toISOString().split('T')[0];
@@ -393,7 +399,7 @@ async function runQuery(
let resultCount = 0;
// Load global CLAUDE.md as additional system context (shared across all groups)
const globalClaudeMdPath = '/workspace/global/CLAUDE.md';
const globalClaudeMdPath = path.join(GLOBAL_DIR, 'CLAUDE.md');
let globalClaudeMd: string | undefined;
if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
@@ -402,7 +408,7 @@ async function runQuery(
// Discover additional directories mounted at /workspace/extra/*
// These are passed to the SDK so their CLAUDE.md files are loaded automatically
const extraDirs: string[] = [];
const extraBase = '/workspace/extra';
const extraBase = EXTRA_BASE;
if (fs.existsSync(extraBase)) {
for (const entry of fs.readdirSync(extraBase)) {
const fullPath = path.join(extraBase, entry);
@@ -434,7 +440,7 @@ async function runQuery(
for await (const message of query({
prompt: stream,
options: {
cwd: '/workspace/group',
cwd: GROUP_DIR,
model,
thinking,
effort,
@@ -573,7 +579,7 @@ async function main(): Promise<void> {
for await (const message of query({
prompt: trimmedPrompt,
options: {
cwd: '/workspace/group',
cwd: GROUP_DIR,
resume: sessionId,
systemPrompt: undefined,
allowedTools: [],

View File

@@ -194,7 +194,7 @@ server.tool(
'pause_task',
'Pause a scheduled task. It will not run until resumed.',
{ task_id: z.string().describe('The task ID to pause') },
async (args) => {
async (args: { task_id: string }) => {
const data = {
type: 'pause_task',
taskId: args.task_id,
@@ -213,7 +213,7 @@ server.tool(
'resume_task',
'Resume a paused task.',
{ task_id: z.string().describe('The task ID to resume') },
async (args) => {
async (args: { task_id: string }) => {
const data = {
type: 'resume_task',
taskId: args.task_id,
@@ -232,7 +232,7 @@ server.tool(
'cancel_task',
'Cancel and delete a scheduled task.',
{ task_id: z.string().describe('The task ID to cancel') },
async (args) => {
async (args: { task_id: string }) => {
const data = {
type: 'cancel_task',
taskId: args.task_id,
@@ -256,7 +256,7 @@ server.tool(
schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'),
schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'),
},
async (args) => {
async (args: { task_id: string; prompt?: string; schedule_type?: 'cron' | 'interval' | 'once'; schedule_value?: string }) => {
// Validate schedule_value if provided
if (args.schedule_type === 'cron' || (!args.schedule_type && args.schedule_value)) {
if (args.schedule_value) {
@@ -308,7 +308,7 @@ Use available_groups.json to find the JID for a group. The folder name must be c
folder: z.string().describe('Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")'),
trigger: z.string().describe('Trigger word (e.g., "@Andy")'),
},
async (args) => {
async (args: { jid: string; name: string; folder: string; trigger: string }) => {
if (!isMain) {
return {
content: [{ type: 'text' as const, text: 'Only the main group can register new groups.' }],

47
container/codex-runner/package-lock.json generated Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -33,7 +33,10 @@ interface ContainerOutput {
error?: string;
}
const IPC_INPUT_DIR = '/workspace/ipc/input';
// Paths configurable via env vars (defaults to container paths for backwards compat)
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
const MAX_TURNS = 100;
@@ -140,7 +143,7 @@ async function runCodexExec(prompt: string, resume: boolean): Promise<{ result:
args.push(
'exec',
'--dangerously-bypass-approvals-and-sandbox',
'-C', '/workspace/group',
'-C', GROUP_DIR,
'--skip-git-repo-check',
'--color', 'never',
prompt,
@@ -151,7 +154,7 @@ async function runCodexExec(prompt: string, resume: boolean): Promise<{ result:
const codex: ChildProcess = spawn('codex', args, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: '/workspace/group',
cwd: GROUP_DIR,
env: { ...process.env },
});

View File

@@ -6,6 +6,7 @@
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"build:runners": "cd container/agent-runner && npm install && npm run build && cd ../codex-runner && npm install && npm run build",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"typecheck": "tsc --noEmit",

View File

@@ -161,9 +161,7 @@ function createMessage(overrides: {
member: overrides.memberDisplayName
? { displayName: overrides.memberDisplayName }
: null,
guild: overrides.guildName
? { name: overrides.guildName }
: null,
guild: overrides.guildName ? { name: overrides.guildName } : null,
channel: {
name: overrides.channelName ?? 'general',
messages: {
@@ -646,8 +644,11 @@ describe('DiscordChannel', () => {
await channel.sendMessage('dc:1234567890123456', 'Hello');
const fetchedChannel = await currentClient().channels.fetch('1234567890123456');
expect(currentClient().channels.fetch).toHaveBeenCalledWith('1234567890123456');
const fetchedChannel =
await currentClient().channels.fetch('1234567890123456');
expect(currentClient().channels.fetch).toHaveBeenCalledWith(
'1234567890123456',
);
});
it('strips dc: prefix from JID', async () => {

View File

@@ -1,4 +1,10 @@
import { Client, Events, GatewayIntentBits, Message, TextChannel } from 'discord.js';
import {
Client,
Events,
GatewayIntentBits,
Message,
TextChannel,
} from 'discord.js';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from '../config.js';
import { readEnvFile } from '../env.js';
@@ -27,7 +33,11 @@ export class DiscordChannel implements Channel {
private typingIntervals = new Map<string, NodeJS.Timeout>();
private agentTypeFilter?: AgentType;
constructor(botToken: string, opts: DiscordChannelOpts, agentTypeFilter?: AgentType) {
constructor(
botToken: string,
opts: DiscordChannelOpts,
agentTypeFilter?: AgentType,
) {
this.botToken = botToken;
this.opts = opts;
this.agentTypeFilter = agentTypeFilter;
@@ -95,7 +105,8 @@ export class DiscordChannel implements Channel {
// Handle attachments — store placeholders so the agent knows something was sent
if (message.attachments.size > 0) {
const attachmentDescriptions = [...message.attachments.values()].map((att) => {
const attachmentDescriptions = [...message.attachments.values()].map(
(att) => {
const contentType = att.contentType || '';
if (contentType.startsWith('image/')) {
return `[Image: ${att.name || 'image'}]`;
@@ -106,7 +117,8 @@ export class DiscordChannel implements Channel {
} else {
return `[File: ${att.name || 'file'}]`;
}
});
},
);
if (content) {
content = `${content}\n${attachmentDescriptions.join('\n')}`;
} else {
@@ -132,7 +144,13 @@ export class DiscordChannel implements Channel {
// Store chat metadata for discovery
const isGroup = message.guild !== null;
this.opts.onChatMetadata(chatJid, timestamp, chatName, 'discord', isGroup);
this.opts.onChatMetadata(
chatJid,
timestamp,
chatName,
'discord',
isGroup,
);
// Only deliver full message for registered groups matching our agent type
const group = this.opts.registeredGroups()[chatJid];
@@ -143,7 +161,10 @@ export class DiscordChannel implements Channel {
);
return;
}
if (this.agentTypeFilter && (group.agentType || 'claude-code') !== this.agentTypeFilter) {
if (
this.agentTypeFilter &&
(group.agentType || 'claude-code') !== this.agentTypeFilter
) {
return; // This JID belongs to a different agent type's bot
}
@@ -278,14 +299,22 @@ registerChannel('discord', (opts: ChannelOpts) => {
return null;
}
// If a second Codex bot token exists, this instance only handles claude-code groups
const hasCodexBot = !!(process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN);
return new DiscordChannel(token, opts, hasCodexBot ? 'claude-code' : undefined);
const hasCodexBot = !!(
process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN
);
return new DiscordChannel(
token,
opts,
hasCodexBot ? 'claude-code' : undefined,
);
});
registerChannel('discord-codex', (opts: ChannelOpts) => {
const envVars = readEnvFile(['DISCORD_CODEX_BOT_TOKEN']);
const token =
process.env.DISCORD_CODEX_BOT_TOKEN || envVars.DISCORD_CODEX_BOT_TOKEN || '';
process.env.DISCORD_CODEX_BOT_TOKEN ||
envVars.DISCORD_CODEX_BOT_TOKEN ||
'';
if (!token) return null; // Codex Discord bot is optional
return new DiscordChannel(token, opts, 'codex');
});

View File

@@ -35,20 +35,25 @@ vi.mock('fs', async () => {
...actual,
default: {
...actual,
existsSync: vi.fn(() => false),
existsSync: vi.fn((p: string) => {
// Return true for runner dist entry so tests proceed past the build check
if (typeof p === 'string' && p.includes('dist/index.js')) return true;
return false;
}),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
readFileSync: vi.fn(() => ''),
readdirSync: vi.fn(() => []),
statSync: vi.fn(() => ({ isDirectory: () => false })),
copyFileSync: vi.fn(),
cpSync: vi.fn(),
},
};
});
// Mock mount-security
vi.mock('./mount-security.js', () => ({
validateAdditionalMounts: vi.fn(() => []),
// Mock env
vi.mock('./env.js', () => ({
readEnvFile: vi.fn(() => ({})),
}));
// Create a controllable fake ChildProcess

View File

@@ -1,35 +1,26 @@
/**
* Container Runner for NanoClaw
* Spawns agent execution in containers and handles IPC
* Agent Process Runner for NanoClaw
* Spawns agent execution as direct host processes and handles IPC
*/
import { ChildProcess, exec, spawn } from 'child_process';
import { ChildProcess, spawn } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
CODEX_CONTAINER_IMAGE,
CONTAINER_IMAGE,
CONTAINER_MAX_OUTPUT_SIZE,
CONTAINER_TIMEOUT,
CREDENTIAL_PROXY_PORT,
DATA_DIR,
GROUPS_DIR,
IDLE_TIMEOUT,
TIMEZONE,
} from './config.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js';
import {
CONTAINER_HOST_GATEWAY,
CONTAINER_RUNTIME_BIN,
hostGatewayArgs,
readonlyMountArgs,
stopContainer,
} from './container-runtime.js';
import { detectAuthMode } from './credential-proxy.js';
resolveGroupFolderPath,
resolveGroupIpcPath,
} from './group-folder.js';
import { logger } from './logger.js';
import { readEnvFile } from './env.js';
import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup } from './types.js';
// Sentinel markers for robust output parsing (must match agent-runner)
@@ -54,60 +45,19 @@ export interface ContainerOutput {
error?: string;
}
interface VolumeMount {
hostPath: string;
containerPath: string;
readonly: boolean;
}
function buildVolumeMounts(
/**
* Prepare the group's environment: directories, sessions, env vars.
* Returns the environment variables and paths for the runner process.
*/
function prepareGroupEnvironment(
group: RegisteredGroup,
isMain: boolean,
): VolumeMount[] {
const mounts: VolumeMount[] = [];
): { env: Record<string, string>; groupDir: string; runnerDir: string } {
const projectRoot = process.cwd();
const groupDir = resolveGroupFolderPath(group.folder);
fs.mkdirSync(groupDir, { recursive: true });
if (isMain) {
// 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: true,
});
// Main also gets its group folder as the working directory
mounts.push({
hostPath: groupDir,
containerPath: '/workspace/group',
readonly: false,
});
} else {
// Other groups only get their own folder
mounts.push({
hostPath: groupDir,
containerPath: '/workspace/group',
readonly: false,
});
// Global memory directory (read-only for non-main)
// Only directory mounts are supported, 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
// Per-group Claude sessions directory
const groupSessionsDir = path.join(
DATA_DIR,
'sessions',
@@ -122,14 +72,8 @@ function buildVolumeMounts(
JSON.stringify(
{
env: {
// Enable agent swarms (subagent orchestration)
// https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
// Load CLAUDE.md from additional mounted directories
// https://code.claude.com/docs/en/memory#load-memory-from-additional-directories
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
// Enable Claude's memory feature (persists user preferences between sessions)
// https://code.claude.com/docs/en/memory#manage-auto-memory
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
},
@@ -140,7 +84,7 @@ function buildVolumeMounts(
}
// Sync skills from container/skills/ into each group's .claude/skills/
const skillsSrc = path.join(process.cwd(), 'container', 'skills');
const skillsSrc = path.join(projectRoot, 'container', 'skills');
const skillsDst = path.join(groupSessionsDir, 'skills');
if (fs.existsSync(skillsSrc)) {
for (const skillDir of fs.readdirSync(skillsSrc)) {
@@ -150,15 +94,72 @@ function buildVolumeMounts(
fs.cpSync(srcDir, dstDir, { recursive: true });
}
}
mounts.push({
hostPath: groupSessionsDir,
containerPath: '/home/node/.claude',
readonly: false,
});
// For Codex groups: create a per-session writable .codex/ directory
if (group.agentType === 'codex') {
const hostCodexDir = path.join(process.env.HOME || os.homedir(), '.codex');
// Per-group IPC namespace
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 });
// Global memory directory (for non-main groups)
const globalDir = path.join(GROUPS_DIR, 'global');
// Additional mount directories (validated)
const extraDirs: string[] = [];
if (group.containerConfig?.additionalMounts) {
for (const mount of group.containerConfig.additionalMounts) {
if (fs.existsSync(mount.hostPath)) {
extraDirs.push(mount.hostPath);
}
}
}
// Determine runner directory
const agentType = group.agentType || 'claude-code';
const runnerDirName =
agentType === 'codex' ? 'codex-runner' : 'agent-runner';
const runnerDir = path.join(projectRoot, 'container', runnerDirName);
// Build environment variables for the runner process
const envVars = readEnvFile([
'ANTHROPIC_API_KEY',
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_MODEL',
'CLAUDE_THINKING',
'CLAUDE_THINKING_BUDGET',
'CLAUDE_EFFORT',
'OPENAI_API_KEY',
'CODEX_OPENAI_API_KEY',
]);
const env: Record<string, string> = {
...process.env as Record<string, string>,
TZ: TIMEZONE,
HOME: os.homedir(),
// Path configuration for the runner
NANOCLAW_GROUP_DIR: groupDir,
NANOCLAW_IPC_DIR: groupIpcDir,
NANOCLAW_GLOBAL_DIR: globalDir,
NANOCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '',
// MCP server context
NANOCLAW_CHAT_JID: group.folder,
NANOCLAW_GROUP_FOLDER: group.folder,
NANOCLAW_IS_MAIN: isMain ? '1' : '0',
// Claude sessions directory — set CLAUDE_CONFIG_DIR so SDK uses per-group sessions
CLAUDE_CONFIG_DIR: groupSessionsDir,
};
// Pass credentials directly (no proxy needed on host)
if (agentType === 'codex') {
const openaiKey =
envVars.CODEX_OPENAI_API_KEY ||
process.env.CODEX_OPENAI_API_KEY ||
envVars.OPENAI_API_KEY ||
process.env.OPENAI_API_KEY;
if (openaiKey) env.OPENAI_API_KEY = openaiKey;
// Codex session directory
const hostCodexDir = path.join(os.homedir(), '.codex');
const sessionCodexDir = path.join(
DATA_DIR,
'sessions',
@@ -166,15 +167,9 @@ function buildVolumeMounts(
'.codex',
);
fs.mkdirSync(sessionCodexDir, { recursive: true });
// Always refresh auth credentials from host
const authSrc = path.join(hostCodexDir, 'auth.json');
const authDst = path.join(sessionCodexDir, 'auth.json');
if (fs.existsSync(authSrc)) {
fs.copyFileSync(authSrc, authDst);
}
// Only copy config files if they don't exist yet (preserves per-session customization)
if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst);
for (const file of ['config.toml', 'config.json']) {
const src = path.join(hostCodexDir, file);
const dst = path.join(sessionCodexDir, file);
@@ -182,148 +177,37 @@ function buildVolumeMounts(
fs.copyFileSync(src, dst);
}
}
mounts.push({
hostPath: sessionCodexDir,
containerPath: '/home/node/.codex',
readonly: false,
});
}
// Per-group IPC namespace: each group gets its own IPC directory
// This prevents cross-group privilege escalation via IPC
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 });
mounts.push({
hostPath: groupIpcDir,
containerPath: '/workspace/ipc',
readonly: false,
});
// Copy 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 agentType = group.agentType || 'claude-code';
const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner';
const runnerSrcSuffix =
agentType === 'codex' ? 'codex-runner-src' : 'agent-runner-src';
const agentRunnerSrc = path.join(
projectRoot,
'container',
runnerDirName,
'src',
);
const groupAgentRunnerDir = path.join(
DATA_DIR,
'sessions',
group.folder,
runnerSrcSuffix,
);
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
}
mounts.push({
hostPath: groupAgentRunnerDir,
containerPath: '/app/src',
readonly: false,
});
// 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,
isMain: boolean,
agentType: 'claude-code' | 'codex' = 'claude-code',
): 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}`);
if (agentType === 'codex') {
// Codex uses OpenAI API — pass the key directly (no credential proxy)
const openaiKey =
process.env.CODEX_OPENAI_API_KEY || process.env.OPENAI_API_KEY;
if (openaiKey) {
args.push('-e', `OPENAI_API_KEY=${openaiKey}`);
}
env.CODEX_CONFIG_DIR = sessionCodexDir;
} else {
// Route API traffic through the credential proxy (containers never see real secrets)
args.push(
'-e',
`ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`,
);
// Mirror the host's auth method with a placeholder value.
const authMode = detectAuthMode();
if (authMode === 'api-key') {
args.push('-e', 'ANTHROPIC_API_KEY=placeholder');
} else {
args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder');
// Claude Code — pass real credentials directly
if (envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) {
env.ANTHROPIC_API_KEY =
envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';
}
if (
envVars.CLAUDE_CODE_OAUTH_TOKEN ||
process.env.CLAUDE_CODE_OAUTH_TOKEN
) {
env.CLAUDE_CODE_OAUTH_TOKEN =
envVars.CLAUDE_CODE_OAUTH_TOKEN ||
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
'';
}
// Model/thinking config
for (const key of [
'CLAUDE_MODEL',
'CLAUDE_THINKING',
'CLAUDE_THINKING_BUDGET',
'CLAUDE_EFFORT',
]) {
const val =
envVars[key as keyof typeof envVars] ||
process.env[key];
if (val) env[key] = val;
}
}
// Runtime-specific args for host gateway resolution
args.push(...hostGatewayArgs());
// Pass model and thinking configuration to container agent (read from .env file)
const modelEnv = readEnvFile(['CLAUDE_MODEL', 'CLAUDE_THINKING', 'CLAUDE_THINKING_BUDGET', 'CLAUDE_EFFORT']);
if (modelEnv.CLAUDE_MODEL) {
args.push('-e', `CLAUDE_MODEL=${modelEnv.CLAUDE_MODEL}`);
}
if (modelEnv.CLAUDE_THINKING) {
args.push('-e', `CLAUDE_THINKING=${modelEnv.CLAUDE_THINKING}`);
}
if (modelEnv.CLAUDE_THINKING_BUDGET) {
args.push('-e', `CLAUDE_THINKING_BUDGET=${modelEnv.CLAUDE_THINKING_BUDGET}`);
}
if (modelEnv.CLAUDE_EFFORT) {
args.push('-e', `CLAUDE_EFFORT=${modelEnv.CLAUDE_EFFORT}`);
}
// 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).
const hostUid = process.getuid?.();
const hostGid = process.getgid?.();
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
if (isMain) {
// Main containers start as root so the entrypoint can mount --bind
// to shadow .env. Privileges are dropped via setpriv in entrypoint.sh.
args.push('-e', `RUN_UID=${hostUid}`);
args.push('-e', `RUN_GID=${hostGid}`);
} else {
args.push('--user', `${hostUid}:${hostGid}`);
}
args.push('-e', 'HOME=/home/node');
}
for (const mount of mounts) {
if (mount.readonly) {
args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
} else {
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
}
}
// Select container image based on agent type
args.push(agentType === 'codex' ? CODEX_CONTAINER_IMAGE : CONTAINER_IMAGE);
return args;
return { env, groupDir, runnerDir };
}
export async function runContainerAgent(
@@ -333,71 +217,66 @@ export async function runContainerAgent(
onOutput?: (output: ContainerOutput) => Promise<void>,
): Promise<ContainerOutput> {
const startTime = Date.now();
const groupDir = resolveGroupFolderPath(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 agentType = group.agentType || 'claude-code';
const containerArgs = buildContainerArgs(
mounts,
containerName,
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
group,
input.isMain,
agentType,
);
logger.debug(
{
group: group.name,
containerName,
mounts: mounts.map(
(m) =>
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
),
containerArgs: containerArgs.join(' '),
},
'Container mount configuration',
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
const processName = `nanoclaw-${safeName}-${Date.now()}`;
// Check if runner is built
const distEntry = path.join(runnerDir, 'dist', 'index.js');
if (!fs.existsSync(distEntry)) {
logger.error(
{ runnerDir },
'Runner not built. Run: cd container/agent-runner && npm install && npm run build',
);
return {
status: 'error',
result: null,
error: `Runner not built at ${distEntry}. Run npm run build:runners first.`,
};
}
logger.info(
{
group: group.name,
containerName,
mountCount: mounts.length,
processName,
agentType: group.agentType || 'claude-code',
isMain: input.isMain,
},
'Spawning container agent',
'Spawning agent process',
);
const logsDir = path.join(groupDir, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
return new Promise((resolve) => {
const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
const proc = spawn('node', [distEntry], {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: runnerDir,
env,
});
onProcess(container, containerName);
onProcess(proc, processName);
let stdout = '';
let stderr = '';
let stdoutTruncated = false;
let stderrTruncated = false;
container.stdin.write(JSON.stringify(input));
container.stdin.end();
proc.stdin.write(JSON.stringify(input));
proc.stdin.end();
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
let parseBuffer = '';
let newSessionId: string | undefined;
let outputChain = Promise.resolve();
container.stdout.on('data', (data) => {
proc.stdout.on('data', (data) => {
const chunk = data.toString();
// Always accumulate for logging
if (!stdoutTruncated) {
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
if (chunk.length > remaining) {
@@ -405,20 +284,21 @@ export async function runContainerAgent(
stdoutTruncated = true;
logger.warn(
{ group: group.name, size: stdout.length },
'Container stdout truncated due to size limit',
'Agent stdout truncated due to size limit',
);
} else {
stdout += chunk;
}
}
// Stream-parse for output markers
if (onOutput) {
parseBuffer += chunk;
let startIdx: number;
while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
while (
(startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1
) {
const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
if (endIdx === -1) break; // Incomplete pair, wait for more data
if (endIdx === -1) break;
const jsonStr = parseBuffer
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
@@ -431,10 +311,7 @@ export async function runContainerAgent(
newSessionId = parsed.newSessionId;
}
hadStreamingOutput = true;
// Activity detected — reset the hard timeout
resetTimeout();
// Call onOutput for all markers (including null results)
// so idle timers start even for "silent" query completions.
outputChain = outputChain.then(() => onOutput(parsed));
} catch (err) {
logger.warn(
@@ -446,23 +323,17 @@ export async function runContainerAgent(
}
});
container.stderr.on('data', (data) => {
proc.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 (line) logger.debug({ agent: group.folder }, line);
}
// Don't reset timeout on stderr — SDK writes debug logs continuously.
// Timeout only resets on actual output (OUTPUT_MARKER in stdout).
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;
}
@@ -471,240 +342,170 @@ export async function runContainerAgent(
let timedOut = false;
let hadStreamingOutput = false;
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
// Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
// graceful _close sentinel has time to trigger before the hard kill fires.
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
const killOnTimeout = () => {
timedOut = true;
logger.error(
{ group: group.name, containerName },
'Container timeout, stopping gracefully',
{ group: group.name, processName },
'Agent timeout, sending SIGTERM',
);
exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
if (err) {
logger.warn(
{ group: group.name, containerName, err },
'Graceful stop failed, force killing',
);
container.kill('SIGKILL');
}
});
proc.kill('SIGTERM');
// Force kill after 15s if still alive
setTimeout(() => {
if (!proc.killed) proc.kill('SIGKILL');
}, 15000);
};
let timeout = setTimeout(killOnTimeout, timeoutMs);
// Reset the timeout whenever there's activity (streaming output)
const resetTimeout = () => {
clearTimeout(timeout);
timeout = setTimeout(killOnTimeout, timeoutMs);
};
container.on('close', (code) => {
proc.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,
path.join(logsDir, `agent-${ts}.log`),
[
`=== Container Run Log (TIMEOUT) ===`,
`=== Agent Run Log (TIMEOUT) ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`Container: ${containerName}`,
`Process: ${processName}`,
`Duration: ${duration}ms`,
`Exit Code: ${code}`,
`Had Streaming Output: ${hadStreamingOutput}`,
].join('\n'),
);
// Timeout after output = idle cleanup, not failure.
// The agent already sent its response; this is just the
// container being reaped after the idle period expired.
if (hadStreamingOutput) {
logger.info(
{ group: group.name, containerName, duration, code },
'Container timed out after output (idle cleanup)',
{ group: group.name, processName, duration, code },
'Agent timed out after output (idle cleanup)',
);
outputChain.then(() => {
resolve({
status: 'success',
result: null,
newSessionId,
});
resolve({ status: 'success', result: null, newSessionId });
});
return;
}
logger.error(
{ group: group.name, containerName, duration, code },
'Container timed out with no output',
);
resolve({
status: 'error',
result: null,
error: `Container timed out after ${configTimeout}ms`,
error: `Agent timed out after ${configTimeout}ms`,
});
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const logFile = path.join(logsDir, `container-${timestamp}.log`);
const logFile = path.join(logsDir, `agent-${timestamp}.log`);
const isVerbose =
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
process.env.LOG_LEVEL === 'debug' ||
process.env.LOG_LEVEL === 'trace';
const logLines = [
`=== Container Run Log ===`,
`=== Agent Run Log ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`IsMain: ${input.isMain}`,
`AgentType: ${group.agentType || 'claude-code'}`,
`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 ===`,
stderr,
``,
`=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
`=== Stdout ===`,
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',
{ group: group.name, code, duration, logFile },
'Agent exited with error',
);
resolve({
status: 'error',
result: null,
error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
error: `Agent exited with code ${code}: ${stderr.slice(-200)}`,
});
return;
}
// Streaming mode: wait for output chain to settle, return completion marker
if (onOutput) {
outputChain.then(() => {
logger.info(
{ group: group.name, duration, newSessionId },
'Container completed (streaming mode)',
'Agent completed (streaming mode)',
);
resolve({
status: 'success',
result: null,
newSessionId,
});
resolve({ status: 'success', result: null, newSessionId });
});
return;
}
// Legacy mode: parse the last output marker pair from accumulated stdout
// Legacy mode: parse output from stdout
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',
{ group: group.name, duration, status: output.status },
'Agent completed',
);
resolve(output);
} catch (err) {
logger.error(
{
group: group.name,
stdout,
stderr,
error: err,
},
'Failed to parse container output',
{ group: group.name, error: err },
'Failed to parse agent output',
);
resolve({
status: 'error',
result: null,
error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
error: `Failed to parse agent output: ${err instanceof Error ? err.message : String(err)}`,
});
}
});
container.on('error', (err) => {
proc.on('error', (err) => {
clearTimeout(timeout);
logger.error(
{ group: group.name, containerName, error: err },
'Container spawn error',
{ group: group.name, processName, error: err },
'Agent spawn error',
);
resolve({
status: 'error',
result: null,
error: `Container spawn error: ${err.message}`,
error: `Agent spawn error: ${err.message}`,
});
});
});
@@ -723,15 +524,11 @@ export function writeTasksSnapshot(
next_run: string | null;
}>,
): void {
// Write filtered tasks to the group's IPC directory
const groupIpcDir = resolveGroupIpcPath(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));
}
@@ -743,11 +540,6 @@ export interface AvailableGroup {
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,
@@ -756,18 +548,12 @@ export function writeGroupsSnapshot(
): void {
const groupIpcDir = resolveGroupIpcPath(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(),
},
{ groups: visibleGroups, lastSync: new Date().toISOString() },
null,
2,
),

View File

@@ -62,7 +62,9 @@ describe('ensureContainerRuntimeRunning', () => {
`${CONTAINER_RUNTIME_BIN} system status`,
{ stdio: 'pipe' },
);
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
expect(logger.debug).toHaveBeenCalledWith(
'Container runtime already running',
);
});
it('auto-starts when system status fails', () => {

View File

@@ -30,8 +30,14 @@ export function hostGatewayArgs(): string[] {
}
/** Returns CLI args for a readonly bind mount. */
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
return ['--mount', `type=bind,source=${hostPath},target=${containerPath},readonly`];
export function readonlyMountArgs(
hostPath: string,
containerPath: string,
): string[] {
return [
'--mount',
`type=bind,source=${hostPath},target=${containerPath},readonly`,
];
}
/** Returns the shell command to stop a container by name. */
@@ -47,7 +53,10 @@ export function ensureContainerRuntimeRunning(): void {
} catch {
logger.info('Starting container runtime...');
try {
execSync(`${CONTAINER_RUNTIME_BIN} system start`, { stdio: 'pipe', timeout: 30000 });
execSync(`${CONTAINER_RUNTIME_BIN} system start`, {
stdio: 'pipe',
timeout: 30000,
});
logger.info('Container runtime started');
} catch (err) {
logger.error({ err }, 'Failed to start container runtime');
@@ -87,17 +96,26 @@ export function cleanupOrphans(): void {
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
});
const containers: { status: string; configuration: { id: string } }[] = JSON.parse(output || '[]');
const containers: { status: string; configuration: { id: string } }[] =
JSON.parse(output || '[]');
const orphans = containers
.filter((c) => c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'))
.filter(
(c) =>
c.status === 'running' && c.configuration.id.startsWith('nanoclaw-'),
)
.map((c) => c.configuration.id);
for (const name of orphans) {
try {
execSync(stopContainer(name), { stdio: 'pipe' });
} catch { /* already stopped */ }
} catch {
/* already stopped */
}
}
if (orphans.length > 0) {
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
logger.info(
{ count: orphans.length, names: orphans },
'Stopped orphaned containers',
);
}
} catch (err) {
logger.warn({ err }, 'Failed to clean up orphaned containers');

View File

@@ -3,13 +3,11 @@ import path from 'path';
import {
ASSISTANT_NAME,
CREDENTIAL_PROXY_PORT,
IDLE_TIMEOUT,
POLL_INTERVAL,
TIMEZONE,
TRIGGER_PATTERN,
} from './config.js';
import { startCredentialProxy } from './credential-proxy.js';
import './channels/index.js';
import {
getChannelFactory,
@@ -21,11 +19,6 @@ import {
writeGroupsSnapshot,
writeTasksSnapshot,
} from './container-runner.js';
import {
cleanupOrphans,
ensureContainerRuntimeRunning,
PROXY_BIND_HOST,
} from './container-runtime.js';
import {
getAllChats,
getAllRegisteredGroups,
@@ -53,7 +46,11 @@ import {
loadSenderAllowlist,
shouldDropMessage,
} from './sender-allowlist.js';
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
import {
extractSessionCommand,
handleSessionCommand,
isSessionCommandAllowed,
} from './session-commands.js';
import { startSchedulerLoop } from './task-scheduler.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
@@ -175,18 +172,26 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
timezone: TIMEZONE,
deps: {
sendMessage: (text) => channel.sendMessage(chatJid, text),
setTyping: (typing) => channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
runAgent: (prompt, onOutput) => runAgent(group, prompt, chatJid, onOutput),
setTyping: (typing) =>
channel.setTyping?.(chatJid, typing) ?? Promise.resolve(),
runAgent: (prompt, onOutput) =>
runAgent(group, prompt, chatJid, onOutput),
closeStdin: () => queue.closeStdin(chatJid),
advanceCursor: (ts) => { lastAgentTimestamp[chatJid] = ts; saveState(); },
advanceCursor: (ts) => {
lastAgentTimestamp[chatJid] = ts;
saveState();
},
formatMessages,
canSenderInteract: (msg) => {
const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim());
const reqTrigger = !isMainGroup && group.requiresTrigger !== false;
return isMainGroup || !reqTrigger || (hasTrigger && (
msg.is_from_me ||
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())
));
return (
isMainGroup ||
!reqTrigger ||
(hasTrigger &&
(msg.is_from_me ||
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())))
);
},
},
});
@@ -431,8 +436,7 @@ async function startMessageLoop(): Promise<void> {
const lastHuman = getLastHumanMessageTimestamp(chatJid);
if (
!lastHuman ||
Date.now() - new Date(lastHuman).getTime() >
BOT_COLLAB_TIMEOUT_MS
Date.now() - new Date(lastHuman).getTime() > BOT_COLLAB_TIMEOUT_MS
) {
logger.info(
{ chatJid, lastHuman },
@@ -453,7 +457,12 @@ async function startMessageLoop(): Promise<void> {
// Only close active container if the sender is authorized — otherwise an
// untrusted user could kill in-flight work by sending /compact (DoS).
// closeStdin no-ops internally when no container is active.
if (isSessionCommandAllowed(isMainGroup, loopCmdMsg.is_from_me === true)) {
if (
isSessionCommandAllowed(
isMainGroup,
loopCmdMsg.is_from_me === true,
)
) {
queue.closeStdin(chatJid);
}
// Enqueue so processGroupMessages handles auth + cursor advancement.
@@ -536,23 +545,11 @@ function recoverPendingMessages(): void {
}
}
function ensureContainerSystemRunning(): void {
ensureContainerRuntimeRunning();
cleanupOrphans();
}
async function main(): Promise<void> {
ensureContainerSystemRunning();
initDatabase();
logger.info('Database initialized');
loadState();
// Start credential proxy (containers route API calls through this)
await startCredentialProxy(
CREDENTIAL_PROXY_PORT,
PROXY_BIND_HOST,
);
// Graceful shutdown handlers
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');

View File

@@ -1,5 +1,9 @@
import { describe, it, expect, vi } from 'vitest';
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
import {
extractSessionCommand,
handleSessionCommand,
isSessionCommandAllowed,
} from './session-commands.js';
import type { NewMessage } from './types.js';
import type { SessionCommandDeps } from './session-commands.js';
@@ -23,7 +27,9 @@ describe('extractSessionCommand', () => {
});
it('rejects regular messages', () => {
expect(extractSessionCommand('please compact the conversation', trigger)).toBeNull();
expect(
extractSessionCommand('please compact the conversation', trigger),
).toBeNull();
});
it('handles whitespace', () => {
@@ -53,7 +59,10 @@ describe('isSessionCommandAllowed', () => {
});
});
function makeMsg(content: string, overrides: Partial<NewMessage> = {}): NewMessage {
function makeMsg(
content: string,
overrides: Partial<NewMessage> = {},
): NewMessage {
return {
id: 'msg-1',
chat_jid: 'group@test',
@@ -65,7 +74,9 @@ function makeMsg(content: string, overrides: Partial<NewMessage> = {}): NewMessa
};
}
function makeDeps(overrides: Partial<SessionCommandDeps> = {}): SessionCommandDeps {
function makeDeps(
overrides: Partial<SessionCommandDeps> = {},
): SessionCommandDeps {
return {
sendMessage: vi.fn().mockResolvedValue(undefined),
setTyping: vi.fn().mockResolvedValue(undefined),
@@ -105,7 +116,10 @@ describe('handleSessionCommand', () => {
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
expect(deps.runAgent).toHaveBeenCalledWith(
'/compact',
expect.any(Function),
);
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
});
@@ -120,13 +134,17 @@ describe('handleSessionCommand', () => {
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.sendMessage).toHaveBeenCalledWith('Session commands require admin access.');
expect(deps.sendMessage).toHaveBeenCalledWith(
'Session commands require admin access.',
);
expect(deps.runAgent).not.toHaveBeenCalled();
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
});
it('silently consumes denied command when sender cannot interact', async () => {
const deps = makeDeps({ canSenderInteract: vi.fn().mockReturnValue(false) });
const deps = makeDeps({
canSenderInteract: vi.fn().mockReturnValue(false),
});
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact', { is_from_me: false })],
isMainGroup: false,
@@ -158,8 +176,14 @@ describe('handleSessionCommand', () => {
expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC');
// Two runAgent calls: pre-compact + /compact
expect(deps.runAgent).toHaveBeenCalledTimes(2);
expect(deps.runAgent).toHaveBeenCalledWith('<formatted>', expect.any(Function));
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
expect(deps.runAgent).toHaveBeenCalledWith(
'<formatted>',
expect.any(Function),
);
expect(deps.runAgent).toHaveBeenCalledWith(
'/compact',
expect.any(Function),
);
});
it('allows is_from_me sender in non-main group', async () => {
@@ -173,15 +197,20 @@ describe('handleSessionCommand', () => {
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
expect(deps.runAgent).toHaveBeenCalledWith(
'/compact',
expect.any(Function),
);
});
it('reports failure when command-stage runAgent returns error without streamed status', async () => {
// runAgent resolves 'error' but callback never gets status: 'error'
const deps = makeDeps({ runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => {
const deps = makeDeps({
runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => {
await onOutput({ status: 'success', result: null });
return 'error';
})});
}),
});
const result = await handleSessionCommand({
missedMessages: [makeMsg('/compact')],
isMainGroup: true,
@@ -191,7 +220,9 @@ describe('handleSessionCommand', () => {
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('failed'));
expect(deps.sendMessage).toHaveBeenCalledWith(
expect.stringContaining('failed'),
);
});
it('returns success:false on pre-compact failure with no output', async () => {
@@ -209,6 +240,8 @@ describe('handleSessionCommand', () => {
deps,
});
expect(result).toEqual({ handled: true, success: false });
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to process'));
expect(deps.sendMessage).toHaveBeenCalledWith(
expect.stringContaining('Failed to process'),
);
});
});

View File

@@ -5,7 +5,10 @@ import { logger } from './logger.js';
* Extract a session slash command from a message, stripping the trigger prefix if present.
* Returns the slash command (e.g., '/compact') or null if not a session command.
*/
export function extractSessionCommand(content: string, triggerPattern: RegExp): string | null {
export function extractSessionCommand(
content: string,
triggerPattern: RegExp,
): string | null {
let text = content.trim();
text = text.replace(triggerPattern, '').trim();
if (text === '/compact') return '/compact';
@@ -16,7 +19,10 @@ export function extractSessionCommand(content: string, triggerPattern: RegExp):
* Check if a session command sender is authorized.
* Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group.
*/
export function isSessionCommandAllowed(isMainGroup: boolean, isFromMe: boolean): boolean {
export function isSessionCommandAllowed(
isMainGroup: boolean,
isFromMe: boolean,
): boolean {
return isMainGroup || isFromMe;
}
@@ -61,12 +67,21 @@ export async function handleSessionCommand(opts: {
timezone: string;
deps: SessionCommandDeps;
}): Promise<{ handled: false } | { handled: true; success: boolean }> {
const { missedMessages, isMainGroup, groupName, triggerPattern, timezone, deps } = opts;
const {
missedMessages,
isMainGroup,
groupName,
triggerPattern,
timezone,
deps,
} = opts;
const cmdMsg = missedMessages.find(
(m) => extractSessionCommand(m.content, triggerPattern) !== null,
);
const command = cmdMsg ? extractSessionCommand(cmdMsg.content, triggerPattern) : null;
const command = cmdMsg
? extractSessionCommand(cmdMsg.content, triggerPattern)
: null;
if (!command || !cmdMsg) return { handled: false };
@@ -109,8 +124,13 @@ export async function handleSessionCommand(opts: {
});
if (preResult === 'error' || hadPreError) {
logger.warn({ group: groupName }, 'Pre-compact processing failed, aborting session command');
await deps.sendMessage(`Failed to process messages before ${command}. Try again.`);
logger.warn(
{ group: groupName },
'Pre-compact processing failed, aborting session command',
);
await deps.sendMessage(
`Failed to process messages before ${command}. Try again.`,
);
if (preOutputSent) {
// Output was already sent — don't retry or it will duplicate.
// Advance cursor past pre-compact messages, leave command pending.