chore: delete v1 reference code

Removes src/v1/ (37 files) and container/agent-runner/src/v1/ (3 files)
along with the v1 reference note in CLAUDE.md and the now-obsolete
tsconfig exclude. v1 was already out of the runtime path; this just
removes the dead weight.

~8,800 LOC removed, zero runtime change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-18 14:23:47 +03:00
parent 27c52205f9
commit 86becf8dea
39 changed files with 1 additions and 8830 deletions

View File

@@ -8,8 +8,6 @@ The host is a single Node process that orchestrates per-session agent containers
**Everything is a message.** There is no IPC, no file watcher, no stdin piping between host and container. The two session DBs are the sole IO surface.
A `src/v1/` tree exists for historical reference and is not part of the runtime — ignore it unless you're explicitly working on a migration.
## Entity Model
```

View File

@@ -1,736 +0,0 @@
/**
* NanoClaw Agent Runner
* Runs inside a container, receives config via stdin, outputs result to stdout
*
* Input protocol:
* Stdin: Full ContainerInput JSON (read until EOF, like before)
* IPC: Follow-up messages written as JSON files to /workspace/ipc/input/
* Files: {type:"message", text:"..."}.json — polled and consumed
* Sentinel: /workspace/ipc/input/_close — signals session end
*
* Stdout protocol:
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
* Multiple results may be emitted (one per agent teams result).
* Final marker after loop ends signals completion.
*/
import fs from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import {
query,
HookCallback,
PreCompactHookInput,
} from '@anthropic-ai/claude-agent-sdk';
import { fileURLToPath } from 'url';
interface ContainerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
script?: string;
}
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
interface SessionEntry {
sessionId: string;
fullPath: string;
summary: string;
firstPrompt: string;
}
interface SessionsIndex {
entries: SessionEntry[];
}
interface SDKUserMessage {
type: 'user';
message: { role: 'user'; content: string };
parent_tool_use_id: null;
session_id: string;
}
const IPC_INPUT_DIR = '/workspace/ipc/input';
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
/**
* Push-based async iterable for streaming user messages to the SDK.
* Keeps the iterable alive until end() is called, preventing isSingleUserTurn.
*/
class MessageStream {
private queue: SDKUserMessage[] = [];
private waiting: (() => void) | null = null;
private done = false;
push(text: string): void {
this.queue.push({
type: 'user',
message: { role: 'user', content: text },
parent_tool_use_id: null,
session_id: '',
});
this.waiting?.();
}
end(): void {
this.done = true;
this.waiting?.();
}
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
while (true) {
while (this.queue.length > 0) {
yield this.queue.shift()!;
}
if (this.done) return;
await new Promise<void>((r) => {
this.waiting = r;
});
this.waiting = null;
}
}
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
data += chunk;
});
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
function writeOutput(output: ContainerOutput): void {
console.log(OUTPUT_START_MARKER);
console.log(JSON.stringify(output));
console.log(OUTPUT_END_MARKER);
}
function log(message: string): void {
console.error(`[agent-runner] ${message}`);
}
function getSessionSummary(
sessionId: string,
transcriptPath: string,
): string | null {
const projectDir = path.dirname(transcriptPath);
const indexPath = path.join(projectDir, 'sessions-index.json');
if (!fs.existsSync(indexPath)) {
log(`Sessions index not found at ${indexPath}`);
return null;
}
try {
const index: SessionsIndex = JSON.parse(
fs.readFileSync(indexPath, 'utf-8'),
);
const entry = index.entries.find((e) => e.sessionId === sessionId);
if (entry?.summary) {
return entry.summary;
}
} catch (err) {
log(
`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`,
);
}
return null;
}
/**
* Archive the full transcript to conversations/ before compaction.
*/
function createPreCompactHook(assistantName?: string): HookCallback {
return async (input, _toolUseId, _context) => {
const preCompact = input as PreCompactHookInput;
const transcriptPath = preCompact.transcript_path;
const sessionId = preCompact.session_id;
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
log('No transcript found for archiving');
return {};
}
try {
const content = fs.readFileSync(transcriptPath, 'utf-8');
const messages = parseTranscript(content);
if (messages.length === 0) {
log('No messages to archive');
return {};
}
const summary = getSessionSummary(sessionId, transcriptPath);
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
const conversationsDir = '/workspace/group/conversations';
fs.mkdirSync(conversationsDir, { recursive: true });
const date = new Date().toISOString().split('T')[0];
const filename = `${date}-${name}.md`;
const filePath = path.join(conversationsDir, filename);
const markdown = formatTranscriptMarkdown(
messages,
summary,
assistantName,
);
fs.writeFileSync(filePath, markdown);
log(`Archived conversation to ${filePath}`);
} catch (err) {
log(
`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`,
);
}
return {};
};
}
function sanitizeFilename(summary: string): string {
return summary
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50);
}
function generateFallbackName(): string {
const time = new Date();
return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`;
}
interface ParsedMessage {
role: 'user' | 'assistant';
content: string;
}
function parseTranscript(content: string): ParsedMessage[] {
const messages: ParsedMessage[] = [];
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.content) {
const text =
typeof entry.message.content === 'string'
? entry.message.content
: entry.message.content
.map((c: { text?: string }) => c.text || '')
.join('');
if (text) messages.push({ role: 'user', content: text });
} else if (entry.type === 'assistant' && entry.message?.content) {
const textParts = entry.message.content
.filter((c: { type: string }) => c.type === 'text')
.map((c: { text: string }) => c.text);
const text = textParts.join('');
if (text) messages.push({ role: 'assistant', content: text });
}
} catch {}
}
return messages;
}
function formatTranscriptMarkdown(
messages: ParsedMessage[],
title?: string | null,
assistantName?: string,
): string {
const now = new Date();
const formatDateTime = (d: Date) =>
d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
const lines: string[] = [];
lines.push(`# ${title || 'Conversation'}`);
lines.push('');
lines.push(`Archived: ${formatDateTime(now)}`);
lines.push('');
lines.push('---');
lines.push('');
for (const msg of messages) {
const sender = msg.role === 'user' ? 'User' : assistantName || 'Assistant';
const content =
msg.content.length > 2000
? msg.content.slice(0, 2000) + '...'
: msg.content;
lines.push(`**${sender}**: ${content}`);
lines.push('');
}
return lines.join('\n');
}
/**
* Check for _close sentinel.
*/
function shouldClose(): boolean {
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
return true;
}
return false;
}
/**
* Drain all pending IPC input messages.
* Returns messages found, or empty array.
*/
function drainIpcInput(): string[] {
try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
const files = fs
.readdirSync(IPC_INPUT_DIR)
.filter((f) => f.endsWith('.json'))
.sort();
const messages: string[] = [];
for (const file of files) {
const filePath = path.join(IPC_INPUT_DIR, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
fs.unlinkSync(filePath);
if (data.type === 'message' && data.text) {
messages.push(data.text);
}
} catch (err) {
log(
`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`,
);
try {
fs.unlinkSync(filePath);
} catch {
/* ignore */
}
}
}
return messages;
} catch (err) {
log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
return [];
}
}
/**
* Wait for a new IPC message or _close sentinel.
* Returns the messages as a single string, or null if _close.
*/
function waitForIpcMessage(): Promise<string | null> {
return new Promise((resolve) => {
const poll = () => {
if (shouldClose()) {
resolve(null);
return;
}
const messages = drainIpcInput();
if (messages.length > 0) {
resolve(messages.join('\n'));
return;
}
setTimeout(poll, IPC_POLL_MS);
};
poll();
});
}
/**
* Run a single query and stream results via writeOutput.
* Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
* allowing agent teams subagents to run to completion.
* Also pipes IPC messages into the stream during the query.
*/
async function runQuery(
prompt: string,
sessionId: string | undefined,
mcpServerPath: string,
containerInput: ContainerInput,
sdkEnv: Record<string, string | undefined>,
resumeAt?: string,
): Promise<{
newSessionId?: string;
lastAssistantUuid?: string;
closedDuringQuery: boolean;
}> {
const stream = new MessageStream();
stream.push(prompt);
// Poll IPC for follow-up messages and _close sentinel during the query
let ipcPolling = true;
let closedDuringQuery = false;
const pollIpcDuringQuery = () => {
if (!ipcPolling) return;
if (shouldClose()) {
log('Close sentinel detected during query, ending stream');
closedDuringQuery = true;
stream.end();
ipcPolling = false;
return;
}
const messages = drainIpcInput();
for (const text of messages) {
log(`Piping IPC message into active query (${text.length} chars)`);
stream.push(text);
}
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
};
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
let newSessionId: string | undefined;
let lastAssistantUuid: string | undefined;
let messageCount = 0;
let resultCount = 0;
// Load global CLAUDE.md as additional system context (shared across all groups)
const globalClaudeMdPath = '/workspace/global/CLAUDE.md';
let globalClaudeMd: string | undefined;
if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
}
// 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';
if (fs.existsSync(extraBase)) {
for (const entry of fs.readdirSync(extraBase)) {
const fullPath = path.join(extraBase, entry);
if (fs.statSync(fullPath).isDirectory()) {
extraDirs.push(fullPath);
}
}
}
if (extraDirs.length > 0) {
log(`Additional directories: ${extraDirs.join(', ')}`);
}
for await (const message of query({
prompt: stream,
options: {
cwd: '/workspace/group',
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
resume: sessionId,
resumeSessionAt: resumeAt,
systemPrompt: globalClaudeMd
? {
type: 'preset' as const,
preset: 'claude_code' as const,
append: globalClaudeMd,
}
: undefined,
allowedTools: [
'Bash',
'Read',
'Write',
'Edit',
'Glob',
'Grep',
'WebSearch',
'WebFetch',
'Task',
'TaskOutput',
'TaskStop',
'TeamCreate',
'TeamDelete',
'SendMessage',
'TodoWrite',
'ToolSearch',
'Skill',
'NotebookEdit',
'mcp__nanoclaw__*',
],
env: sdkEnv,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'],
mcpServers: {
nanoclaw: {
command: 'node',
args: [mcpServerPath],
env: {
NANOCLAW_CHAT_JID: containerInput.chatJid,
NANOCLAW_GROUP_FOLDER: containerInput.groupFolder,
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
},
},
},
hooks: {
PreCompact: [
{ hooks: [createPreCompactHook(containerInput.assistantName)] },
],
},
},
})) {
messageCount++;
const msgType =
message.type === 'system'
? `system/${(message as { subtype?: string }).subtype}`
: message.type;
log(`[msg #${messageCount}] type=${msgType}`);
if (message.type === 'assistant' && 'uuid' in message) {
lastAssistantUuid = (message as { uuid: string }).uuid;
}
if (message.type === 'system' && message.subtype === 'init') {
newSessionId = message.session_id;
log(`Session initialized: ${newSessionId}`);
}
if (
message.type === 'system' &&
(message as { subtype?: string }).subtype === 'task_notification'
) {
const tn = message as {
task_id: string;
status: string;
summary: string;
};
log(
`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`,
);
}
if (message.type === 'result') {
resultCount++;
const textResult =
'result' in message ? (message as { result?: string }).result : null;
log(
`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`,
);
writeOutput({
status: 'success',
result: textResult || null,
newSessionId,
});
}
}
ipcPolling = false;
log(
`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`,
);
return { newSessionId, lastAssistantUuid, closedDuringQuery };
}
interface ScriptResult {
wakeAgent: boolean;
data?: unknown;
}
const SCRIPT_TIMEOUT_MS = 30_000;
async function runScript(script: string): Promise<ScriptResult | null> {
const scriptPath = '/tmp/task-script.sh';
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
return new Promise((resolve) => {
execFile(
'bash',
[scriptPath],
{
timeout: SCRIPT_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
env: process.env,
},
(error, stdout, stderr) => {
if (stderr) {
log(`Script stderr: ${stderr.slice(0, 500)}`);
}
if (error) {
log(`Script error: ${error.message}`);
return resolve(null);
}
// Parse last non-empty line of stdout as JSON
const lines = stdout.trim().split('\n');
const lastLine = lines[lines.length - 1];
if (!lastLine) {
log('Script produced no output');
return resolve(null);
}
try {
const result = JSON.parse(lastLine);
if (typeof result.wakeAgent !== 'boolean') {
log(
`Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`,
);
return resolve(null);
}
resolve(result as ScriptResult);
} catch {
log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`);
resolve(null);
}
},
);
});
}
async function main(): Promise<void> {
let containerInput: ContainerInput;
try {
const stdinData = await readStdin();
containerInput = JSON.parse(stdinData);
try {
fs.unlinkSync('/tmp/input.json');
} catch {
/* may not exist */
}
log(`Received input for group: ${containerInput.groupFolder}`);
} catch (err) {
writeOutput({
status: 'error',
result: null,
error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`,
});
process.exit(1);
}
// Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL.
// No real secrets exist in the container environment.
const sdkEnv: Record<string, string | undefined> = {
...process.env,
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '165000',
};
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
let sessionId = containerInput.sessionId;
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
// Clean up stale _close sentinel from previous container runs
try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
// Build initial prompt (drain any pending IPC messages too)
let prompt = containerInput.prompt;
if (containerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`;
}
const pending = drainIpcInput();
if (pending.length > 0) {
log(`Draining ${pending.length} pending IPC messages into initial prompt`);
prompt += '\n' + pending.join('\n');
}
// Script phase: run script before waking agent
if (containerInput.script && containerInput.isScheduledTask) {
log('Running task script...');
const scriptResult = await runScript(containerInput.script);
if (!scriptResult || !scriptResult.wakeAgent) {
const reason = scriptResult
? 'wakeAgent=false'
: 'script error/no output';
log(`Script decided not to wake agent: ${reason}`);
writeOutput({
status: 'success',
result: null,
});
return;
}
// Script says wake agent — enrich prompt with script data
log(`Script wakeAgent=true, enriching prompt with data`);
prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`;
}
// Query loop: run query → wait for IPC message → run new query → repeat
let resumeAt: string | undefined;
try {
while (true) {
log(
`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`,
);
const queryResult = await runQuery(
prompt,
sessionId,
mcpServerPath,
containerInput,
sdkEnv,
resumeAt,
);
if (queryResult.newSessionId) {
sessionId = queryResult.newSessionId;
}
if (queryResult.lastAssistantUuid) {
resumeAt = queryResult.lastAssistantUuid;
}
// If _close was consumed during the query, exit immediately.
// Don't emit a session-update marker (it would reset the host's
// idle timer and cause a 30-min delay before the next _close).
if (queryResult.closedDuringQuery) {
log('Close sentinel consumed during query, exiting');
break;
}
// Emit session update so host can track it
writeOutput({ status: 'success', result: null, newSessionId: sessionId });
log('Query ended, waiting for next IPC message...');
// Wait for the next message or _close sentinel
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting');
break;
}
log(`Got new message (${nextMessage.length} chars), starting new query`);
prompt = nextMessage;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Agent error: ${errorMessage}`);
writeOutput({
status: 'error',
result: null,
newSessionId: sessionId,
error: errorMessage,
});
process.exit(1);
}
}
main();

View File

@@ -1,508 +0,0 @@
/**
* Stdio MCP Server for NanoClaw
* Standalone process that agent teams subagents can inherit.
* Reads context from environment variables, writes IPC files for the host.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import fs from 'fs';
import path from 'path';
import { CronExpressionParser } from 'cron-parser';
const IPC_DIR = '/workspace/ipc';
const MESSAGES_DIR = path.join(IPC_DIR, 'messages');
const TASKS_DIR = path.join(IPC_DIR, 'tasks');
// Context from environment variables (set by the agent runner)
const chatJid = process.env.NANOCLAW_CHAT_JID!;
const groupFolder = process.env.NANOCLAW_GROUP_FOLDER!;
const isMain = process.env.NANOCLAW_IS_MAIN === '1';
function writeIpcFile(dir: string, data: object): string {
fs.mkdirSync(dir, { recursive: true });
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
const filepath = path.join(dir, filename);
// Atomic write: temp file then rename
const tempPath = `${filepath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
fs.renameSync(tempPath, filepath);
return filename;
}
const server = new McpServer({
name: 'nanoclaw',
version: '1.0.0',
});
server.tool(
'send_message',
"Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times.",
{
text: z.string().describe('The message text to send'),
sender: z
.string()
.optional()
.describe(
'Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.',
),
},
async (args) => {
const data: Record<string, string | undefined> = {
type: 'message',
chatJid,
text: args.text,
sender: args.sender || undefined,
groupFolder,
timestamp: new Date().toISOString(),
};
writeIpcFile(MESSAGES_DIR, data);
return { content: [{ type: 'text' as const, text: 'Message sent.' }] };
},
);
server.tool(
'schedule_task',
`Schedule a recurring or one-time task. The task will run as a full agent with access to all tools. Returns the task ID for future reference. To modify an existing task, use update_task instead.
CONTEXT MODE - Choose based on task type:
\u2022 "group": Task runs in the group's conversation context, with access to chat history. Use for tasks that need context about ongoing discussions, user preferences, or recent interactions.
\u2022 "isolated": Task runs in a fresh session with no conversation history. Use for independent tasks that don't need prior context. When using isolated mode, include all necessary context in the prompt itself.
If unsure which mode to use, you can ask the user. Examples:
- "Remind me about our discussion" \u2192 group (needs conversation context)
- "Check the weather every morning" \u2192 isolated (self-contained task)
- "Follow up on my request" \u2192 group (needs to know what was requested)
- "Generate a daily report" \u2192 isolated (just needs instructions in prompt)
MESSAGING BEHAVIOR - The task agent's output is sent to the user or group. It can also use send_message for immediate delivery, or wrap output in <internal> tags to suppress it. Include guidance in the prompt about whether the agent should:
\u2022 Always send a message (e.g., reminders, daily briefings)
\u2022 Only send a message when there's something to report (e.g., "notify me if...")
\u2022 Never send a message (background maintenance tasks)
SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
\u2022 cron: Standard cron expression (e.g., "*/5 * * * *" for every 5 minutes, "0 9 * * *" for daily at 9am LOCAL time)
\u2022 interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour)
\u2022 once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`,
{
prompt: z
.string()
.describe(
'What the agent should do when the task runs. For isolated mode, include all necessary context here.',
),
schedule_type: z
.enum(['cron', 'interval', 'once'])
.describe(
'cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time',
),
schedule_value: z
.string()
.describe(
'cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)',
),
context_mode: z
.enum(['group', 'isolated'])
.default('group')
.describe(
'group=runs with chat history and memory, isolated=fresh session (include context in prompt)',
),
target_group_jid: z
.string()
.optional()
.describe(
'(Main group only) JID of the group to schedule the task for. Defaults to the current group.',
),
script: z
.string()
.optional()
.describe(
'Optional bash script to run before waking the agent. Script must output JSON on the last line of stdout: { "wakeAgent": boolean, "data"?: any }. If wakeAgent is false, the agent is not called. Test your script with bash -c "..." before scheduling.',
),
},
async (args) => {
// Validate schedule_value before writing IPC
if (args.schedule_type === 'cron') {
try {
CronExpressionParser.parse(args.schedule_value);
} catch {
return {
content: [
{
type: 'text' as const,
text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).`,
},
],
isError: true,
};
}
} else if (args.schedule_type === 'interval') {
const ms = parseInt(args.schedule_value, 10);
if (isNaN(ms) || ms <= 0) {
return {
content: [
{
type: 'text' as const,
text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).`,
},
],
isError: true,
};
}
} else if (args.schedule_type === 'once') {
if (
/[Zz]$/.test(args.schedule_value) ||
/[+-]\d{2}:\d{2}$/.test(args.schedule_value)
) {
return {
content: [
{
type: 'text' as const,
text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".`,
},
],
isError: true,
};
}
const date = new Date(args.schedule_value);
if (isNaN(date.getTime())) {
return {
content: [
{
type: 'text' as const,
text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".`,
},
],
isError: true,
};
}
}
// Non-main groups can only schedule for themselves
const targetJid =
isMain && args.target_group_jid ? args.target_group_jid : chatJid;
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const data = {
type: 'schedule_task',
taskId,
prompt: args.prompt,
script: args.script || undefined,
schedule_type: args.schedule_type,
schedule_value: args.schedule_value,
context_mode: args.context_mode || 'group',
targetJid,
createdBy: groupFolder,
timestamp: new Date().toISOString(),
};
writeIpcFile(TASKS_DIR, data);
return {
content: [
{
type: 'text' as const,
text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}`,
},
],
};
},
);
server.tool(
'list_tasks',
"List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.",
{},
async () => {
const tasksFile = path.join(IPC_DIR, 'current_tasks.json');
try {
if (!fs.existsSync(tasksFile)) {
return {
content: [
{ type: 'text' as const, text: 'No scheduled tasks found.' },
],
};
}
const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8'));
const tasks = isMain
? allTasks
: allTasks.filter(
(t: { groupFolder: string }) => t.groupFolder === groupFolder,
);
if (tasks.length === 0) {
return {
content: [
{ type: 'text' as const, text: 'No scheduled tasks found.' },
],
};
}
const formatted = tasks
.map(
(t: {
id: string;
prompt: string;
schedule_type: string;
schedule_value: string;
status: string;
next_run: string;
}) =>
`- [${t.id}] ${t.prompt.slice(0, 50)}... (${t.schedule_type}: ${t.schedule_value}) - ${t.status}, next: ${t.next_run || 'N/A'}`,
)
.join('\n');
return {
content: [
{ type: 'text' as const, text: `Scheduled tasks:\n${formatted}` },
],
};
} catch (err) {
return {
content: [
{
type: 'text' as const,
text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}`,
},
],
};
}
},
);
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) => {
const data = {
type: 'pause_task',
taskId: args.task_id,
groupFolder,
isMain,
timestamp: new Date().toISOString(),
};
writeIpcFile(TASKS_DIR, data);
return {
content: [
{
type: 'text' as const,
text: `Task ${args.task_id} pause requested.`,
},
],
};
},
);
server.tool(
'resume_task',
'Resume a paused task.',
{ task_id: z.string().describe('The task ID to resume') },
async (args) => {
const data = {
type: 'resume_task',
taskId: args.task_id,
groupFolder,
isMain,
timestamp: new Date().toISOString(),
};
writeIpcFile(TASKS_DIR, data);
return {
content: [
{
type: 'text' as const,
text: `Task ${args.task_id} resume requested.`,
},
],
};
},
);
server.tool(
'cancel_task',
'Cancel and delete a scheduled task.',
{ task_id: z.string().describe('The task ID to cancel') },
async (args) => {
const data = {
type: 'cancel_task',
taskId: args.task_id,
groupFolder,
isMain,
timestamp: new Date().toISOString(),
};
writeIpcFile(TASKS_DIR, data);
return {
content: [
{
type: 'text' as const,
text: `Task ${args.task_id} cancellation requested.`,
},
],
};
},
);
server.tool(
'update_task',
'Update an existing scheduled task. Only provided fields are changed; omitted fields stay the same.',
{
task_id: z.string().describe('The task ID to update'),
prompt: z.string().optional().describe('New prompt for the task'),
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)'),
script: z
.string()
.optional()
.describe(
'New script for the task. Set to empty string to remove the script.',
),
},
async (args) => {
// Validate schedule_value if provided
if (
args.schedule_type === 'cron' ||
(!args.schedule_type && args.schedule_value)
) {
if (args.schedule_value) {
try {
CronExpressionParser.parse(args.schedule_value);
} catch {
return {
content: [
{
type: 'text' as const,
text: `Invalid cron: "${args.schedule_value}".`,
},
],
isError: true,
};
}
}
}
if (args.schedule_type === 'interval' && args.schedule_value) {
const ms = parseInt(args.schedule_value, 10);
if (isNaN(ms) || ms <= 0) {
return {
content: [
{
type: 'text' as const,
text: `Invalid interval: "${args.schedule_value}".`,
},
],
isError: true,
};
}
}
const data: Record<string, string | undefined> = {
type: 'update_task',
taskId: args.task_id,
groupFolder,
isMain: String(isMain),
timestamp: new Date().toISOString(),
};
if (args.prompt !== undefined) data.prompt = args.prompt;
if (args.script !== undefined) data.script = args.script;
if (args.schedule_type !== undefined)
data.schedule_type = args.schedule_type;
if (args.schedule_value !== undefined)
data.schedule_value = args.schedule_value;
writeIpcFile(TASKS_DIR, data);
return {
content: [
{
type: 'text' as const,
text: `Task ${args.task_id} update requested.`,
},
],
};
},
);
server.tool(
'register_group',
`Register a new chat/group so the agent can respond to messages there. Main group only.
Use available_groups.json to find the JID for a group. The folder name must be channel-prefixed: "{channel}_{group-name}" (e.g., "whatsapp_family-chat", "telegram_dev-team", "discord_general"). Use lowercase with hyphens for the group name part.`,
{
jid: z
.string()
.describe(
'The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")',
),
name: z.string().describe('Display name for the group'),
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")'),
requiresTrigger: z
.boolean()
.optional()
.describe(
'Whether messages must start with the trigger word. Default: false (respond to all messages). Set to true for busy groups with many participants where you only want the agent to respond when explicitly mentioned.',
),
},
async (args) => {
if (!isMain) {
return {
content: [
{
type: 'text' as const,
text: 'Only the main group can register new groups.',
},
],
isError: true,
};
}
const data = {
type: 'register_group',
jid: args.jid,
name: args.name,
folder: args.folder,
trigger: args.trigger,
requiresTrigger: args.requiresTrigger ?? false,
timestamp: new Date().toISOString(),
};
writeIpcFile(TASKS_DIR, data);
return {
content: [
{
type: 'text' as const,
text: `Group "${args.name}" registered. It will start receiving messages immediately.`,
},
],
};
},
);
// Start the stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);

View File

@@ -1,81 +0,0 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { writeMessageOut } from './db/messages-out.js';
function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Start the MCP server with NanoClaw tools.
* Reads the session DB path from SESSION_DB_PATH env var.
* Routing context is passed via env vars from the poll loop.
*/
export async function startMcpServer(): Promise<void> {
const server = new Server({ name: 'nanoclaw', version: '2.0.0' }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'send_message',
description: 'Send a chat message to the current conversation or a specified destination.',
inputSchema: {
type: 'object' as const,
properties: {
text: { type: 'string', description: 'Message content' },
channel: { type: 'string', description: 'Target channel type (default: reply to origin)' },
platformId: { type: 'string', description: 'Target platform ID' },
threadId: { type: 'string', description: 'Target thread ID' },
},
required: ['text'],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'send_message') {
const text = args?.text as string;
if (!text) {
return { content: [{ type: 'text', text: 'Error: text is required' }] };
}
const id = generateId();
const platformId = (args?.platformId as string) || process.env.NANOCLAW_PLATFORM_ID || null;
const channelType = (args?.channel as string) || process.env.NANOCLAW_CHANNEL_TYPE || null;
const threadId = (args?.threadId as string) || process.env.NANOCLAW_THREAD_ID || null;
writeMessageOut({
id,
kind: 'chat',
platform_id: platformId,
channel_type: channelType,
thread_id: threadId,
content: JSON.stringify({ text }),
});
log(`send_message: ${id}${channelType || 'default'}/${platformId || 'default'}`);
return { content: [{ type: 'text', text: `Message sent (id: ${id})` }] };
}
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
});
const transport = new StdioServerTransport();
await server.connect(transport);
log('MCP server started');
}
// Run as standalone process
startMcpServer().catch((err) => {
log(`MCP server error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});

View File

@@ -10,5 +10,5 @@
"types": ["bun"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/v1/**/*"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}

View File

@@ -1 +0,0 @@
// v1 channel barrel — no-op (channels registered via separate skill branches)

View File

@@ -1,38 +0,0 @@
import { describe, it, expect } from 'vitest';
import { registerChannel, getChannelFactory, getRegisteredChannelNames } from './registry.js';
// The registry is module-level state, so we need a fresh module per test.
// We use dynamic import with cache-busting to isolate tests.
// However, since vitest runs each file in its own context and we control
// registration order, we can test the public API directly.
describe('channel registry', () => {
// Note: registry is shared module state across tests in this file.
// Tests are ordered to account for cumulative registrations.
it('getChannelFactory returns undefined for unknown channel', () => {
expect(getChannelFactory('nonexistent')).toBeUndefined();
});
it('registerChannel and getChannelFactory round-trip', () => {
const factory = () => null;
registerChannel('test-channel', factory);
expect(getChannelFactory('test-channel')).toBe(factory);
});
it('getRegisteredChannelNames includes registered channels', () => {
registerChannel('another-channel', () => null);
const names = getRegisteredChannelNames();
expect(names).toContain('test-channel');
expect(names).toContain('another-channel');
});
it('later registration overwrites earlier one', () => {
const factory1 = () => null;
const factory2 = () => null;
registerChannel('overwrite-test', factory1);
registerChannel('overwrite-test', factory2);
expect(getChannelFactory('overwrite-test')).toBe(factory2);
});
});

View File

@@ -1,23 +0,0 @@
import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from '../types.js';
export interface ChannelOpts {
onMessage: OnInboundMessage;
onChatMetadata: OnChatMetadata;
registeredGroups: () => Record<string, RegisteredGroup>;
}
export type ChannelFactory = (opts: ChannelOpts) => Channel | null;
const registry = new Map<string, ChannelFactory>();
export function registerChannel(name: string, factory: ChannelFactory): void {
registry.set(name, factory);
}
export function getChannelFactory(name: string): ChannelFactory | undefined {
return registry.get(name);
}
export function getRegisteredChannelNames(): string[] {
return [...registry.keys()];
}

View File

@@ -1,62 +0,0 @@
import os from 'os';
import path from 'path';
import { readEnvFile } from './env.js';
import { isValidTimezone } from './timezone.js';
// Read config values from .env (falls back to process.env).
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'TZ']);
export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
export const ASSISTANT_HAS_OWN_NUMBER =
(process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
export const POLL_INTERVAL = 2000;
export const SCHEDULER_POLL_INTERVAL = 60000;
// Absolute paths needed for container mounts
const PROJECT_ROOT = process.cwd();
const HOME_DIR = process.env.HOME || os.homedir();
// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
export const MOUNT_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'mount-allowlist.json');
export const SENDER_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'sender-allowlist.json');
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10);
export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default
export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL;
export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10);
export const IPC_POLL_INTERVAL = 1000;
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function buildTriggerPattern(trigger: string): RegExp {
return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i');
}
export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`;
export function getTriggerPattern(trigger?: string): RegExp {
const normalizedTrigger = trigger?.trim();
return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER);
}
export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER);
// Timezone for scheduled tasks, message formatting, etc.
// Validates each candidate is a real IANA identifier before accepting.
function resolveConfigTimezone(): string {
const candidates = [process.env.TZ, envConfig.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone];
for (const tz of candidates) {
if (tz && isValidTimezone(tz)) return tz;
}
return 'UTC';
}
export const TIMEZONE = resolveConfigTimezone();

View File

@@ -1,204 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import { PassThrough } from 'stream';
// Sentinel markers must match container-runner.ts
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
// Mock config
vi.mock('./config.js', () => ({
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
CONTAINER_TIMEOUT: 1800000, // 30min
DATA_DIR: '/tmp/nanoclaw-test-data',
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
IDLE_TIMEOUT: 1800000, // 30min
ONECLI_URL: 'http://localhost:10254',
TIMEZONE: 'America/Los_Angeles',
}));
// Mock logger
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock fs
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
default: {
...actual,
existsSync: vi.fn(() => false),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
readFileSync: vi.fn(() => ''),
readdirSync: vi.fn(() => []),
statSync: vi.fn(() => ({ isDirectory: () => false })),
copyFileSync: vi.fn(),
},
};
});
// Mock mount-security
vi.mock('./mount-security.js', () => ({
validateAdditionalMounts: vi.fn(() => []),
}));
// Mock container-runtime
vi.mock('./container-runtime.js', () => ({
CONTAINER_RUNTIME_BIN: 'docker',
hostGatewayArgs: () => [],
readonlyMountArgs: (h: string, c: string) => ['-v', `${h}:${c}:ro`],
stopContainer: vi.fn(),
}));
// Mock OneCLI SDK
vi.mock('@onecli-sh/sdk', () => ({
OneCLI: class {
applyContainerConfig = vi.fn().mockResolvedValue(true);
createAgent = vi.fn().mockResolvedValue({ id: 'test' });
ensureAgent = vi.fn().mockResolvedValue({ name: 'test', identifier: 'test', created: true });
},
}));
// Create a controllable fake ChildProcess
function createFakeProcess() {
const proc = new EventEmitter() as EventEmitter & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
kill: ReturnType<typeof vi.fn>;
pid: number;
};
proc.stdin = new PassThrough();
proc.stdout = new PassThrough();
proc.stderr = new PassThrough();
proc.kill = vi.fn();
proc.pid = 12345;
return proc;
}
let fakeProc: ReturnType<typeof createFakeProcess>;
// Mock child_process.spawn
vi.mock('child_process', async () => {
const actual = await vi.importActual<typeof import('child_process')>('child_process');
return {
...actual,
spawn: vi.fn(() => fakeProc),
exec: vi.fn((_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => {
if (cb) cb(null);
return new EventEmitter();
}),
};
});
import { runContainerAgent, ContainerOutput } from './container-runner.js';
import type { RegisteredGroup } from './types.js';
const testGroup: RegisteredGroup = {
name: 'Test Group',
folder: 'test-group',
trigger: '@Andy',
added_at: new Date().toISOString(),
};
const testInput = {
prompt: 'Hello',
groupFolder: 'test-group',
chatJid: 'test@g.us',
isMain: false,
};
function emitOutputMarker(proc: ReturnType<typeof createFakeProcess>, output: ContainerOutput) {
const json = JSON.stringify(output);
proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`);
}
describe('container-runner timeout behavior', () => {
beforeEach(() => {
vi.useFakeTimers();
fakeProc = createFakeProcess();
});
afterEach(() => {
vi.useRealTimers();
});
it('timeout after output resolves as success', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runContainerAgent(testGroup, testInput, () => {}, onOutput);
// Emit output with a result
emitOutputMarker(fakeProc, {
status: 'success',
result: 'Here is my response',
newSessionId: 'session-123',
});
// Let output processing settle
await vi.advanceTimersByTimeAsync(10);
// Fire the hard timeout (IDLE_TIMEOUT + 30s = 1830000ms)
await vi.advanceTimersByTimeAsync(1830000);
// Emit close event (as if container was stopped by the timeout)
fakeProc.emit('close', 137);
// Let the promise resolve
await vi.advanceTimersByTimeAsync(10);
const result = await resultPromise;
expect(result.status).toBe('success');
expect(result.newSessionId).toBe('session-123');
expect(onOutput).toHaveBeenCalledWith(expect.objectContaining({ result: 'Here is my response' }));
});
it('timeout with no output resolves as error', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runContainerAgent(testGroup, testInput, () => {}, onOutput);
// No output emitted — fire the hard timeout
await vi.advanceTimersByTimeAsync(1830000);
// Emit close event
fakeProc.emit('close', 137);
await vi.advanceTimersByTimeAsync(10);
const result = await resultPromise;
expect(result.status).toBe('error');
expect(result.error).toContain('timed out');
expect(onOutput).not.toHaveBeenCalled();
});
it('normal exit after output resolves as success', async () => {
const onOutput = vi.fn(async () => {});
const resultPromise = runContainerAgent(testGroup, testInput, () => {}, onOutput);
// Emit output
emitOutputMarker(fakeProc, {
status: 'success',
result: 'Done',
newSessionId: 'session-456',
});
await vi.advanceTimersByTimeAsync(10);
// Normal exit (no timeout)
fakeProc.emit('close', 0);
await vi.advanceTimersByTimeAsync(10);
const result = await resultPromise;
expect(result.status).toBe('success');
expect(result.newSessionId).toBe('session-456');
});
});

View File

@@ -1,677 +0,0 @@
/**
* Container Runner for NanoClaw
* Spawns agent execution in containers and handles IPC
*/
import { ChildProcess, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
CONTAINER_IMAGE,
CONTAINER_MAX_OUTPUT_SIZE,
CONTAINER_TIMEOUT,
DATA_DIR,
GROUPS_DIR,
IDLE_TIMEOUT,
ONECLI_URL,
TIMEZONE,
} from './config.js';
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js';
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { OneCLI } from '@onecli-sh/sdk';
import { validateAdditionalMounts } from './mount-security.js';
import { RegisteredGroup } from './types.js';
const onecli = new OneCLI({ url: ONECLI_URL });
// Sentinel markers for robust output parsing (must match agent-runner)
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
export interface ContainerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
script?: string;
}
export interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
interface VolumeMount {
hostPath: string;
containerPath: string;
readonly: boolean;
}
function buildVolumeMounts(group: RegisteredGroup, isMain: boolean): VolumeMount[] {
const mounts: VolumeMount[] = [];
const projectRoot = process.cwd();
const groupDir = resolveGroupFolderPath(group.folder);
if (isMain) {
// Main gets the project root read-only. Writable paths the agent needs
// (store, 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,
});
// Shadow .env so the agent cannot read secrets from the mounted project root.
// Credentials are injected by the OneCLI gateway, never exposed to containers.
const envFile = path.join(projectRoot, '.env');
if (fs.existsSync(envFile)) {
mounts.push({
hostPath: '/dev/null',
containerPath: '/workspace/project/.env',
readonly: true,
});
}
// Main gets writable access to the store (SQLite DB) so it can
// query and write to the database directly.
const storeDir = path.join(projectRoot, 'store');
mounts.push({
hostPath: storeDir,
containerPath: '/workspace/project/store',
readonly: false,
});
// Main also gets its group folder as the working directory
mounts.push({
hostPath: groupDir,
containerPath: '/workspace/group',
readonly: false,
});
// Global memory directory — writable for main so it can update shared context
const globalDir = path.join(GROUPS_DIR, 'global');
if (fs.existsSync(globalDir)) {
mounts.push({
hostPath: globalDir,
containerPath: '/workspace/global',
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
const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder, '.claude');
fs.mkdirSync(groupSessionsDir, { recursive: true });
const settingsFile = path.join(groupSessionsDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(
settingsFile,
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',
},
},
null,
2,
) + '\n',
);
}
// Sync skills from container/skills/ into each group's .claude/skills/
const skillsSrc = path.join(process.cwd(), 'container', 'skills');
const skillsDst = path.join(groupSessionsDir, 'skills');
if (fs.existsSync(skillsSrc)) {
for (const skillDir of fs.readdirSync(skillsSrc)) {
const srcDir = path.join(skillsSrc, skillDir);
if (!fs.statSync(srcDir).isDirectory()) continue;
const dstDir = path.join(skillsDst, skillDir);
fs.cpSync(srcDir, dstDir, { 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 = 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 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(agentRunnerSrc)) {
const srcIndex = path.join(agentRunnerSrc, 'index.ts');
const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts');
const needsCopy =
!fs.existsSync(groupAgentRunnerDir) ||
!fs.existsSync(cachedIndex) ||
(fs.existsSync(srcIndex) && fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs);
if (needsCopy) {
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;
}
async function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
agentIdentifier?: string,
): Promise<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}`);
// OneCLI gateway handles credential injection — containers never see real secrets.
// The gateway intercepts HTTPS traffic and injects API keys or OAuth tokens.
const onecliApplied = await onecli.applyContainerConfig(args, {
addHostMapping: false, // Nanoclaw already handles host gateway
agent: agentIdentifier,
});
if (onecliApplied) {
logger.info({ containerName }, 'OneCLI gateway config applied');
} else {
logger.warn({ containerName }, 'OneCLI gateway not reachable — container will have no credentials');
}
// Runtime-specific args for host gateway resolution
args.push(...hostGatewayArgs());
// 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) {
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}`);
}
}
args.push(CONTAINER_IMAGE);
return args;
}
export async function runContainerAgent(
group: RegisteredGroup,
input: ContainerInput,
onProcess: (proc: ChildProcess, containerName: string) => void,
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()}`;
// Main group uses the default OneCLI agent; others use their own agent.
const agentIdentifier = input.isMain ? undefined : group.folder.toLowerCase().replace(/_/g, '-');
const containerArgs = await buildContainerArgs(mounts, containerName, agentIdentifier);
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(groupDir, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
return new Promise((resolve) => {
const container = spawn(CONTAINER_RUNTIME_BIN, 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();
// 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) => {
const chunk = data.toString();
// Always accumulate for logging
if (!stdoutTruncated) {
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;
}
}
// Stream-parse for output markers
if (onOutput) {
parseBuffer += chunk;
let startIdx: number;
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
const jsonStr = parseBuffer.slice(startIdx + OUTPUT_START_MARKER.length, endIdx).trim();
parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
try {
const parsed: ContainerOutput = JSON.parse(jsonStr);
if (parsed.newSessionId) {
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({ group: group.name, error: err }, 'Failed to parse streamed output 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);
}
// 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;
}
});
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');
try {
stopContainer(containerName);
} catch (err) {
logger.warn({ group: group.name, containerName, err }, 'Graceful stop failed, force killing');
container.kill('SIGKILL');
}
};
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) => {
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}`,
`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)',
);
outputChain.then(() => {
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`,
});
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) {
// On error, log input metadata only — not the full prompt.
// Full input is only included at verbose level to avoid
// persisting user conversation content on every non-zero exit.
if (isVerbose) {
logLines.push(`=== Input ===`, JSON.stringify(input, null, 2), ``);
} else {
logLines.push(
`=== Input Summary ===`,
`Prompt length: ${input.prompt.length} chars`,
`Session ID: ${input.sessionId || 'new'}`,
``,
);
}
logLines.push(
`=== 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;
}
// 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)');
resolve({
status: 'success',
result: null,
newSessionId,
});
});
return;
}
// Legacy mode: parse the last output marker pair from accumulated 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',
);
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;
script?: string | null;
schedule_type: string;
schedule_value: string;
status: string;
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));
}
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 = 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(),
},
null,
2,
),
);
}

View File

@@ -1,147 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock logger
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock child_process — store the mock fn so tests can configure it
const mockExecSync = vi.fn();
vi.mock('child_process', () => ({
execSync: (...args: unknown[]) => mockExecSync(...args),
}));
import {
CONTAINER_RUNTIME_BIN,
readonlyMountArgs,
stopContainer,
ensureContainerRuntimeRunning,
cleanupOrphans,
} from './container-runtime.js';
import { logger } from './logger.js';
beforeEach(() => {
vi.clearAllMocks();
});
// --- Pure functions ---
describe('readonlyMountArgs', () => {
it('returns -v flag with :ro suffix', () => {
const args = readonlyMountArgs('/host/path', '/container/path');
expect(args).toEqual(['-v', '/host/path:/container/path:ro']);
});
});
describe('stopContainer', () => {
it('calls docker stop for valid container names', () => {
stopContainer('nanoclaw-test-123');
expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`, {
stdio: 'pipe',
});
});
it('rejects names with shell metacharacters', () => {
expect(() => stopContainer('foo; rm -rf /')).toThrow('Invalid container name');
expect(() => stopContainer('foo$(whoami)')).toThrow('Invalid container name');
expect(() => stopContainer('foo`id`')).toThrow('Invalid container name');
expect(mockExecSync).not.toHaveBeenCalled();
});
});
// --- ensureContainerRuntimeRunning ---
describe('ensureContainerRuntimeRunning', () => {
it('does nothing when runtime is already running', () => {
mockExecSync.mockReturnValueOnce('');
ensureContainerRuntimeRunning();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, {
stdio: 'pipe',
timeout: 10000,
});
expect(logger.debug).toHaveBeenCalledWith('Container runtime already running');
});
it('throws when docker info fails', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('Cannot connect to the Docker daemon');
});
expect(() => ensureContainerRuntimeRunning()).toThrow('Container runtime is required but failed to start');
expect(logger.error).toHaveBeenCalled();
});
});
// --- cleanupOrphans ---
describe('cleanupOrphans', () => {
it('stops orphaned nanoclaw containers', () => {
// docker ps returns container names, one per line
mockExecSync.mockReturnValueOnce('nanoclaw-group1-111\nnanoclaw-group2-222\n');
// stop calls succeed
mockExecSync.mockReturnValue('');
cleanupOrphans();
// ps + 2 stop calls
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(mockExecSync).toHaveBeenNthCalledWith(2, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group1-111`, {
stdio: 'pipe',
});
expect(mockExecSync).toHaveBeenNthCalledWith(3, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`, {
stdio: 'pipe',
});
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] },
'Stopped orphaned containers',
);
});
it('does nothing when no orphans exist', () => {
mockExecSync.mockReturnValueOnce('');
cleanupOrphans();
expect(mockExecSync).toHaveBeenCalledTimes(1);
expect(logger.info).not.toHaveBeenCalled();
});
it('warns and continues when ps fails', () => {
mockExecSync.mockImplementationOnce(() => {
throw new Error('docker not available');
});
cleanupOrphans(); // should not throw
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to clean up orphaned containers',
);
});
it('continues stopping remaining containers when one stop fails', () => {
mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n');
// First stop fails
mockExecSync.mockImplementationOnce(() => {
throw new Error('already stopped');
});
// Second stop succeeds
mockExecSync.mockReturnValueOnce('');
cleanupOrphans(); // should not throw
expect(mockExecSync).toHaveBeenCalledTimes(3);
expect(logger.info).toHaveBeenCalledWith(
{ count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] },
'Stopped orphaned containers',
);
});
});

View File

@@ -1,80 +0,0 @@
/**
* Container runtime abstraction for NanoClaw.
* All runtime-specific logic lives here so swapping runtimes means changing one file.
*/
import { execSync } from 'child_process';
import os from 'os';
import { logger } from './logger.js';
/** The container runtime binary name. */
export const CONTAINER_RUNTIME_BIN = 'docker';
/** CLI args needed for the container to resolve the host gateway. */
export function hostGatewayArgs(): string[] {
// On Linux, host.docker.internal isn't built-in — add it explicitly
if (os.platform() === 'linux') {
return ['--add-host=host.docker.internal:host-gateway'];
}
return [];
}
/** Returns CLI args for a readonly bind mount. */
export function readonlyMountArgs(hostPath: string, containerPath: string): string[] {
return ['-v', `${hostPath}:${containerPath}:ro`];
}
/** Stop a container by name. Uses execFileSync to avoid shell injection. */
export function stopContainer(name: string): void {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
throw new Error(`Invalid container name: ${name}`);
}
execSync(`${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`, { stdio: 'pipe' });
}
/** Ensure the container runtime is running, starting it if needed. */
export function ensureContainerRuntimeRunning(): void {
try {
execSync(`${CONTAINER_RUNTIME_BIN} info`, {
stdio: 'pipe',
timeout: 10000,
});
logger.debug('Container runtime already running');
} catch (err) {
logger.error({ err }, 'Failed to reach container runtime');
console.error('\n╔════════════════════════════════════════════════════════════════╗');
console.error('║ FATAL: Container runtime failed to start ║');
console.error('║ ║');
console.error('║ Agents cannot run without a container runtime. To fix: ║');
console.error('║ 1. Ensure Docker is installed and running ║');
console.error('║ 2. Run: docker info ║');
console.error('║ 3. Restart NanoClaw ║');
console.error('╚════════════════════════════════════════════════════════════════╝\n');
throw new Error('Container runtime is required but failed to start', {
cause: err,
});
}
}
/** Kill orphaned NanoClaw containers from previous runs. */
export function cleanupOrphans(): void {
try {
const output = execSync(`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, {
stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
});
const orphans = output.trim().split('\n').filter(Boolean);
for (const name of orphans) {
try {
stopContainer(name);
} catch {
/* already stopped */
}
}
if (orphans.length > 0) {
logger.info({ count: orphans.length, names: orphans }, 'Stopped orphaned containers');
}
} catch (err) {
logger.warn({ err }, 'Failed to clean up orphaned containers');
}
}

View File

@@ -1,60 +0,0 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it, vi } from 'vitest';
describe('database migrations', () => {
it('defaults Telegram backfill chats to direct messages', async () => {
const repoRoot = process.cwd();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-db-test-'));
try {
process.chdir(tempDir);
fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true });
const dbPath = path.join(tempDir, 'store', 'messages.db');
const legacyDb = new Database(dbPath);
legacyDb.exec(`
CREATE TABLE chats (
jid TEXT PRIMARY KEY,
name TEXT,
last_message_time TEXT
);
`);
legacyDb
.prepare(`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`)
.run('tg:12345', 'Telegram DM', '2024-01-01T00:00:00.000Z');
legacyDb
.prepare(`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`)
.run('tg:-10012345', 'Telegram Group', '2024-01-01T00:00:01.000Z');
legacyDb
.prepare(`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`)
.run('room@g.us', 'WhatsApp Group', '2024-01-01T00:00:02.000Z');
legacyDb.close();
vi.resetModules();
const { initDatabase, getAllChats, _closeDatabase } = await import('./db.js');
initDatabase();
const chats = getAllChats();
expect(chats.find((chat) => chat.jid === 'tg:12345')).toMatchObject({
channel: 'telegram',
is_group: 0,
});
expect(chats.find((chat) => chat.jid === 'tg:-10012345')).toMatchObject({
channel: 'telegram',
is_group: 0,
});
expect(chats.find((chat) => chat.jid === 'room@g.us')).toMatchObject({
channel: 'whatsapp',
is_group: 1,
});
_closeDatabase();
} finally {
process.chdir(repoRoot);
}
});
});

View File

@@ -1,591 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
_initTestDatabase,
createTask,
deleteTask,
getAllChats,
getAllRegisteredGroups,
getLastBotMessageTimestamp,
getMessagesSince,
getNewMessages,
getTaskById,
setRegisteredGroup,
storeChatMetadata,
storeMessage,
updateTask,
} from './db.js';
import { formatMessages } from './router.js';
beforeEach(() => {
_initTestDatabase();
});
// Helper to store a message using the normalized NewMessage interface
function store(overrides: {
id: string;
chat_jid: string;
sender: string;
sender_name: string;
content: string;
timestamp: string;
is_from_me?: boolean;
}) {
storeMessage({
id: overrides.id,
chat_jid: overrides.chat_jid,
sender: overrides.sender,
sender_name: overrides.sender_name,
content: overrides.content,
timestamp: overrides.timestamp,
is_from_me: overrides.is_from_me ?? false,
});
}
// --- storeMessage (NewMessage format) ---
describe('storeMessage', () => {
it('stores a message and retrieves it', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
store({
id: 'msg-1',
chat_jid: 'group@g.us',
sender: '123@s.whatsapp.net',
sender_name: 'Alice',
content: 'hello world',
timestamp: '2024-01-01T00:00:01.000Z',
});
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
expect(messages).toHaveLength(1);
expect(messages[0].id).toBe('msg-1');
expect(messages[0].sender).toBe('123@s.whatsapp.net');
expect(messages[0].sender_name).toBe('Alice');
expect(messages[0].content).toBe('hello world');
});
it('filters out empty content', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
store({
id: 'msg-2',
chat_jid: 'group@g.us',
sender: '111@s.whatsapp.net',
sender_name: 'Dave',
content: '',
timestamp: '2024-01-01T00:00:04.000Z',
});
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
expect(messages).toHaveLength(0);
});
it('stores is_from_me flag', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
store({
id: 'msg-3',
chat_jid: 'group@g.us',
sender: 'me@s.whatsapp.net',
sender_name: 'Me',
content: 'my message',
timestamp: '2024-01-01T00:00:05.000Z',
is_from_me: true,
});
// Message is stored (we can retrieve it — is_from_me doesn't affect retrieval)
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
expect(messages).toHaveLength(1);
});
it('upserts on duplicate id+chat_jid', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
store({
id: 'msg-dup',
chat_jid: 'group@g.us',
sender: '123@s.whatsapp.net',
sender_name: 'Alice',
content: 'original',
timestamp: '2024-01-01T00:00:01.000Z',
});
store({
id: 'msg-dup',
chat_jid: 'group@g.us',
sender: '123@s.whatsapp.net',
sender_name: 'Alice',
content: 'updated',
timestamp: '2024-01-01T00:00:01.000Z',
});
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe('updated');
});
});
// --- reply context persistence ---
describe('reply context', () => {
it('stores and retrieves reply_to fields', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
storeMessage({
id: 'reply-1',
chat_jid: 'group@g.us',
sender: '123',
sender_name: 'Alice',
content: 'Yes, on my way!',
timestamp: '2024-01-01T00:00:01.000Z',
reply_to_message_id: '42',
reply_to_message_content: 'Are you coming tonight?',
reply_to_sender_name: 'Bob',
});
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
expect(messages).toHaveLength(1);
expect(messages[0].reply_to_message_id).toBe('42');
expect(messages[0].reply_to_message_content).toBe('Are you coming tonight?');
expect(messages[0].reply_to_sender_name).toBe('Bob');
});
it('returns null for messages without reply context', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
store({
id: 'no-reply',
chat_jid: 'group@g.us',
sender: '123',
sender_name: 'Alice',
content: 'Just a normal message',
timestamp: '2024-01-01T00:00:01.000Z',
});
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
expect(messages).toHaveLength(1);
expect(messages[0].reply_to_message_id).toBeNull();
expect(messages[0].reply_to_message_content).toBeNull();
expect(messages[0].reply_to_sender_name).toBeNull();
});
it('retrieves reply context via getNewMessages', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
storeMessage({
id: 'reply-2',
chat_jid: 'group@g.us',
sender: '456',
sender_name: 'Carol',
content: 'Agreed',
timestamp: '2024-01-01T00:00:01.000Z',
reply_to_message_id: '99',
reply_to_message_content: 'We should meet',
reply_to_sender_name: 'Dave',
});
const { messages } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy');
expect(messages).toHaveLength(1);
expect(messages[0].reply_to_message_id).toBe('99');
expect(messages[0].reply_to_sender_name).toBe('Dave');
});
});
// --- getMessagesSince ---
describe('getMessagesSince', () => {
beforeEach(() => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
store({
id: 'm1',
chat_jid: 'group@g.us',
sender: 'Alice@s.whatsapp.net',
sender_name: 'Alice',
content: 'first',
timestamp: '2024-01-01T00:00:01.000Z',
});
store({
id: 'm2',
chat_jid: 'group@g.us',
sender: 'Bob@s.whatsapp.net',
sender_name: 'Bob',
content: 'second',
timestamp: '2024-01-01T00:00:02.000Z',
});
storeMessage({
id: 'm3',
chat_jid: 'group@g.us',
sender: 'Bot@s.whatsapp.net',
sender_name: 'Bot',
content: 'bot reply',
timestamp: '2024-01-01T00:00:03.000Z',
is_bot_message: true,
});
store({
id: 'm4',
chat_jid: 'group@g.us',
sender: 'Carol@s.whatsapp.net',
sender_name: 'Carol',
content: 'third',
timestamp: '2024-01-01T00:00:04.000Z',
});
});
it('returns messages after the given timestamp', () => {
const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:02.000Z', 'Andy');
// Should exclude m1, m2 (before/at timestamp), m3 (bot message)
expect(msgs).toHaveLength(1);
expect(msgs[0].content).toBe('third');
});
it('excludes bot messages via is_bot_message flag', () => {
const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy');
const botMsgs = msgs.filter((m) => m.content === 'bot reply');
expect(botMsgs).toHaveLength(0);
});
it('returns all non-bot messages when sinceTimestamp is empty', () => {
const msgs = getMessagesSince('group@g.us', '', 'Andy');
// 3 user messages (bot message excluded)
expect(msgs).toHaveLength(3);
});
it('recovers cursor from last bot reply when lastAgentTimestamp is missing', () => {
// beforeEach already inserts m3 (bot reply at 00:00:03) and m4 (user at 00:00:04)
// Add more old history before the bot reply
for (let i = 1; i <= 50; i++) {
store({
id: `history-${i}`,
chat_jid: 'group@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: `old message ${i}`,
timestamp: `2023-06-${String(i).padStart(2, '0')}T12:00:00.000Z`,
});
}
// New message after the bot reply (m3 at 00:00:03)
store({
id: 'new-1',
chat_jid: 'group@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: 'new message after bot reply',
timestamp: '2024-01-02T00:00:00.000Z',
});
// Recover cursor from the last bot message (m3 from beforeEach)
const recovered = getLastBotMessageTimestamp('group@g.us', 'Andy');
expect(recovered).toBe('2024-01-01T00:00:03.000Z');
// Using recovered cursor: only gets messages after the bot reply
const msgs = getMessagesSince('group@g.us', recovered!, 'Andy', 10);
// m4 (third, 00:00:04) + new-1 — skips all 50 old messages and m1/m2
expect(msgs).toHaveLength(2);
expect(msgs[0].content).toBe('third');
expect(msgs[1].content).toBe('new message after bot reply');
});
it('caps messages to configured limit even with recovered cursor', () => {
// beforeEach inserts m3 (bot at 00:00:03). Add 30 messages after it.
for (let i = 1; i <= 30; i++) {
store({
id: `pending-${i}`,
chat_jid: 'group@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: `pending message ${i}`,
timestamp: `2024-02-${String(i).padStart(2, '0')}T12:00:00.000Z`,
});
}
const recovered = getLastBotMessageTimestamp('group@g.us', 'Andy');
expect(recovered).toBe('2024-01-01T00:00:03.000Z');
// With limit=10, only the 10 most recent are returned
const msgs = getMessagesSince('group@g.us', recovered!, 'Andy', 10);
expect(msgs).toHaveLength(10);
// Most recent 10: pending-21 through pending-30
expect(msgs[0].content).toBe('pending message 21');
expect(msgs[9].content).toBe('pending message 30');
});
it('returns last N messages when no bot reply and no cursor exist', () => {
// Use a fresh group with no bot messages
storeChatMetadata('fresh@g.us', '2024-01-01T00:00:00.000Z');
for (let i = 1; i <= 20; i++) {
store({
id: `fresh-${i}`,
chat_jid: 'fresh@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: `message ${i}`,
timestamp: `2024-02-${String(i).padStart(2, '0')}T12:00:00.000Z`,
});
}
const recovered = getLastBotMessageTimestamp('fresh@g.us', 'Andy');
expect(recovered).toBeUndefined();
// No cursor → sinceTimestamp = '' but limit caps the result
const msgs = getMessagesSince('fresh@g.us', '', 'Andy', 10);
expect(msgs).toHaveLength(10);
const prompt = formatMessages(msgs, 'Asia/Jerusalem');
const messageTagCount = (prompt.match(/<message /g) || []).length;
expect(messageTagCount).toBe(10);
});
it('filters pre-migration bot messages via content prefix backstop', () => {
// Simulate a message written before migration: has prefix but is_bot_message = 0
store({
id: 'm5',
chat_jid: 'group@g.us',
sender: 'Bot@s.whatsapp.net',
sender_name: 'Bot',
content: 'Andy: old bot reply',
timestamp: '2024-01-01T00:00:05.000Z',
});
const msgs = getMessagesSince('group@g.us', '2024-01-01T00:00:04.000Z', 'Andy');
expect(msgs).toHaveLength(0);
});
});
// --- getNewMessages ---
describe('getNewMessages', () => {
beforeEach(() => {
storeChatMetadata('group1@g.us', '2024-01-01T00:00:00.000Z');
storeChatMetadata('group2@g.us', '2024-01-01T00:00:00.000Z');
store({
id: 'a1',
chat_jid: 'group1@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: 'g1 msg1',
timestamp: '2024-01-01T00:00:01.000Z',
});
store({
id: 'a2',
chat_jid: 'group2@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: 'g2 msg1',
timestamp: '2024-01-01T00:00:02.000Z',
});
storeMessage({
id: 'a3',
chat_jid: 'group1@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: 'bot reply',
timestamp: '2024-01-01T00:00:03.000Z',
is_bot_message: true,
});
store({
id: 'a4',
chat_jid: 'group1@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: 'g1 msg2',
timestamp: '2024-01-01T00:00:04.000Z',
});
});
it('returns new messages across multiple groups', () => {
const { messages, newTimestamp } = getNewMessages(
['group1@g.us', 'group2@g.us'],
'2024-01-01T00:00:00.000Z',
'Andy',
);
// Excludes bot message, returns 3 user messages
expect(messages).toHaveLength(3);
expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z');
});
it('filters by timestamp', () => {
const { messages } = getNewMessages(['group1@g.us', 'group2@g.us'], '2024-01-01T00:00:02.000Z', 'Andy');
// Only g1 msg2 (after ts, not bot)
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe('g1 msg2');
});
it('returns empty for no registered groups', () => {
const { messages, newTimestamp } = getNewMessages([], '', 'Andy');
expect(messages).toHaveLength(0);
expect(newTimestamp).toBe('');
});
});
// --- storeChatMetadata ---
describe('storeChatMetadata', () => {
it('stores chat with JID as default name', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
const chats = getAllChats();
expect(chats).toHaveLength(1);
expect(chats[0].jid).toBe('group@g.us');
expect(chats[0].name).toBe('group@g.us');
});
it('stores chat with explicit name', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z', 'My Group');
const chats = getAllChats();
expect(chats[0].name).toBe('My Group');
});
it('updates name on subsequent call with name', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Updated Name');
const chats = getAllChats();
expect(chats).toHaveLength(1);
expect(chats[0].name).toBe('Updated Name');
});
it('preserves newer timestamp on conflict', () => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:05.000Z');
storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z');
const chats = getAllChats();
expect(chats[0].last_message_time).toBe('2024-01-01T00:00:05.000Z');
});
});
// --- Task CRUD ---
describe('task CRUD', () => {
it('creates and retrieves a task', () => {
createTask({
id: 'task-1',
group_folder: 'main',
chat_jid: 'group@g.us',
prompt: 'do something',
schedule_type: 'once',
schedule_value: '2024-06-01T00:00:00.000Z',
context_mode: 'isolated',
next_run: '2024-06-01T00:00:00.000Z',
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
const task = getTaskById('task-1');
expect(task).toBeDefined();
expect(task!.prompt).toBe('do something');
expect(task!.status).toBe('active');
});
it('updates task status', () => {
createTask({
id: 'task-2',
group_folder: 'main',
chat_jid: 'group@g.us',
prompt: 'test',
schedule_type: 'once',
schedule_value: '2024-06-01T00:00:00.000Z',
context_mode: 'isolated',
next_run: null,
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
updateTask('task-2', { status: 'paused' });
expect(getTaskById('task-2')!.status).toBe('paused');
});
it('deletes a task and its run logs', () => {
createTask({
id: 'task-3',
group_folder: 'main',
chat_jid: 'group@g.us',
prompt: 'delete me',
schedule_type: 'once',
schedule_value: '2024-06-01T00:00:00.000Z',
context_mode: 'isolated',
next_run: null,
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
deleteTask('task-3');
expect(getTaskById('task-3')).toBeUndefined();
});
});
// --- LIMIT behavior ---
describe('message query LIMIT', () => {
beforeEach(() => {
storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
for (let i = 1; i <= 10; i++) {
store({
id: `lim-${i}`,
chat_jid: 'group@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: `message ${i}`,
timestamp: `2024-01-01T00:00:${String(i).padStart(2, '0')}.000Z`,
});
}
});
it('getNewMessages caps to limit and returns most recent in chronological order', () => {
const { messages, newTimestamp } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', 3);
expect(messages).toHaveLength(3);
expect(messages[0].content).toBe('message 8');
expect(messages[2].content).toBe('message 10');
// Chronological order preserved
expect(messages[1].timestamp > messages[0].timestamp).toBe(true);
// newTimestamp reflects latest returned row
expect(newTimestamp).toBe('2024-01-01T00:00:10.000Z');
});
it('getMessagesSince caps to limit and returns most recent in chronological order', () => {
const messages = getMessagesSince('group@g.us', '2024-01-01T00:00:00.000Z', 'Andy', 3);
expect(messages).toHaveLength(3);
expect(messages[0].content).toBe('message 8');
expect(messages[2].content).toBe('message 10');
expect(messages[1].timestamp > messages[0].timestamp).toBe(true);
});
it('returns all messages when count is under the limit', () => {
const { messages } = getNewMessages(['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', 50);
expect(messages).toHaveLength(10);
});
});
// --- RegisteredGroup isMain round-trip ---
describe('registered group isMain', () => {
it('persists isMain=true through set/get round-trip', () => {
setRegisteredGroup('main@s.whatsapp.net', {
name: 'Main Chat',
folder: 'whatsapp_main',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
isMain: true,
});
const groups = getAllRegisteredGroups();
const group = groups['main@s.whatsapp.net'];
expect(group).toBeDefined();
expect(group.isMain).toBe(true);
expect(group.folder).toBe('whatsapp_main');
});
it('omits isMain for non-main groups', () => {
setRegisteredGroup('group@g.us', {
name: 'Family Chat',
folder: 'whatsapp_family-chat',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
});
const groups = getAllRegisteredGroups();
const group = groups['group@g.us'];
expect(group).toBeDefined();
expect(group.isMain).toBeUndefined();
});
});

View File

@@ -1,658 +0,0 @@
import Database from 'better-sqlite3';
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;
function createSchema(database: Database.Database): void {
database.exec(`
CREATE TABLE IF NOT EXISTS chats (
jid TEXT PRIMARY KEY,
name TEXT,
last_message_time TEXT,
channel TEXT,
is_group INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT,
chat_jid TEXT,
sender TEXT,
sender_name TEXT,
content TEXT,
timestamp TEXT,
is_from_me INTEGER,
is_bot_message INTEGER DEFAULT 0,
PRIMARY KEY (id, chat_jid),
FOREIGN KEY (chat_jid) REFERENCES chats(jid)
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp);
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id TEXT PRIMARY KEY,
group_folder TEXT NOT NULL,
chat_jid TEXT NOT NULL,
prompt TEXT NOT NULL,
schedule_type TEXT NOT NULL,
schedule_value TEXT NOT NULL,
next_run TEXT,
last_run TEXT,
last_result TEXT,
status TEXT DEFAULT 'active',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_next_run ON scheduled_tasks(next_run);
CREATE INDEX IF NOT EXISTS idx_status ON scheduled_tasks(status);
CREATE TABLE IF NOT EXISTS task_run_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
run_at TEXT NOT NULL,
duration_ms INTEGER NOT NULL,
status TEXT NOT NULL,
result TEXT,
error TEXT,
FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id)
);
CREATE INDEX IF NOT EXISTS idx_task_run_logs ON task_run_logs(task_id, run_at);
CREATE TABLE IF NOT EXISTS router_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
group_folder TEXT PRIMARY KEY,
session_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
);
`);
// Add context_mode column if it doesn't exist (migration for existing DBs)
try {
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`);
} catch {
/* column already exists */
}
// Add script column if it doesn't exist (migration for existing DBs)
try {
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`);
} catch {
/* column already exists */
}
// Add is_bot_message column if it doesn't exist (migration for existing DBs)
try {
database.exec(`ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`);
// Backfill: mark existing bot messages that used the content prefix pattern
database.prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`).run(`${ASSISTANT_NAME}:%`);
} catch {
/* column already exists */
}
// Add is_main column if it doesn't exist (migration for existing DBs)
try {
database.exec(`ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`);
// Backfill: existing rows with folder = 'main' are the main group
database.exec(`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`);
} catch {
/* column already exists */
}
// Add channel and is_group columns if they don't exist (migration for existing DBs)
try {
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`);
// Backfill from JID patterns
database.exec(`UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`);
database.exec(`UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`);
database.exec(`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`);
database.exec(`UPDATE chats SET channel = 'telegram', is_group = 0 WHERE jid LIKE 'tg:%'`);
} catch {
/* columns already exist */
}
// Add reply context columns if they don't exist (migration for existing DBs)
try {
database.exec(`ALTER TABLE messages ADD COLUMN reply_to_message_id TEXT`);
database.exec(`ALTER TABLE messages ADD COLUMN reply_to_message_content TEXT`);
database.exec(`ALTER TABLE messages ADD COLUMN reply_to_sender_name TEXT`);
} catch {
/* columns already exist */
}
}
export function initDatabase(): void {
const dbPath = path.join(STORE_DIR, 'messages.db');
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
db = new Database(dbPath);
createSchema(db);
// Migrate from JSON files if they exist
migrateJsonState();
}
/** @internal - for tests only. Creates a fresh in-memory database. */
export function _initTestDatabase(): void {
db = new Database(':memory:');
createSchema(db);
}
/** @internal - for tests only. */
export function _closeDatabase(): void {
db.close();
}
/**
* Store chat metadata only (no message content).
* Used for all chats to enable group discovery without storing sensitive content.
*/
export function storeChatMetadata(
chatJid: string,
timestamp: string,
name?: string,
channel?: string,
isGroup?: boolean,
): void {
const ch = channel ?? null;
const group = isGroup === undefined ? null : isGroup ? 1 : 0;
if (name) {
// Update with name, preserving existing timestamp if newer
db.prepare(
`
INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(jid) DO UPDATE SET
name = excluded.name,
last_message_time = MAX(last_message_time, excluded.last_message_time),
channel = COALESCE(excluded.channel, channel),
is_group = COALESCE(excluded.is_group, is_group)
`,
).run(chatJid, name, timestamp, ch, group);
} else {
// Update timestamp only, preserve existing name if any
db.prepare(
`
INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(jid) DO UPDATE SET
last_message_time = MAX(last_message_time, excluded.last_message_time),
channel = COALESCE(excluded.channel, channel),
is_group = COALESCE(excluded.is_group, is_group)
`,
).run(chatJid, chatJid, timestamp, ch, group);
}
}
/**
* Update chat name without changing timestamp for existing chats.
* New chats get the current time as their initial timestamp.
* Used during group metadata sync.
*/
export function updateChatName(chatJid: string, name: string): void {
db.prepare(
`
INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)
ON CONFLICT(jid) DO UPDATE SET name = excluded.name
`,
).run(chatJid, name, new Date().toISOString());
}
export interface ChatInfo {
jid: string;
name: string;
last_message_time: string;
channel: string;
is_group: number;
}
/**
* Get all known chats, ordered by most recent activity.
*/
export function getAllChats(): ChatInfo[] {
return db
.prepare(
`
SELECT jid, name, last_message_time, channel, is_group
FROM chats
ORDER BY last_message_time DESC
`,
)
.all() as ChatInfo[];
}
/**
* Get timestamp of last group metadata sync.
*/
export function getLastGroupSync(): string | null {
// Store sync time in a special chat entry
const row = db.prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`).get() as
| { last_message_time: string }
| undefined;
return row?.last_message_time || null;
}
/**
* Record that group metadata was synced.
*/
export function setLastGroupSync(): void {
const now = new Date().toISOString();
db.prepare(
`INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`,
).run(now);
}
/**
* Store a message with full content.
* Only call this for registered groups where message history is needed.
*/
export function storeMessage(msg: NewMessage): void {
db.prepare(
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, reply_to_message_id, reply_to_message_content, reply_to_sender_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
msg.id,
msg.chat_jid,
msg.sender,
msg.sender_name,
msg.content,
msg.timestamp,
msg.is_from_me ? 1 : 0,
msg.is_bot_message ? 1 : 0,
msg.reply_to_message_id ?? null,
msg.reply_to_message_content ?? null,
msg.reply_to_sender_name ?? null,
);
}
/**
* Store a message directly.
*/
export function storeMessageDirect(msg: {
id: string;
chat_jid: string;
sender: string;
sender_name: string;
content: string;
timestamp: string;
is_from_me: boolean;
is_bot_message?: boolean;
}): void {
db.prepare(
`INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
msg.id,
msg.chat_jid,
msg.sender,
msg.sender_name,
msg.content,
msg.timestamp,
msg.is_from_me ? 1 : 0,
msg.is_bot_message ? 1 : 0,
);
}
export function getNewMessages(
jids: string[],
lastTimestamp: string,
botPrefix: string,
limit: number = 200,
): { messages: NewMessage[]; newTimestamp: string } {
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
const placeholders = jids.map(() => '?').join(',');
// Filter bot messages using both the is_bot_message flag AND the content
// prefix as a backstop for messages written before the migration ran.
// Subquery takes the N most recent, outer query re-sorts chronologically.
const sql = `
SELECT * FROM (
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me,
reply_to_message_id, reply_to_message_content, reply_to_sender_name
FROM messages
WHERE timestamp > ? AND chat_jid IN (${placeholders})
AND is_bot_message = 0 AND content NOT LIKE ?
AND content != '' AND content IS NOT NULL
ORDER BY timestamp DESC
LIMIT ?
) ORDER BY timestamp
`;
const rows = db.prepare(sql).all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[];
let newTimestamp = lastTimestamp;
for (const row of rows) {
if (row.timestamp > newTimestamp) newTimestamp = row.timestamp;
}
return { messages: rows, newTimestamp };
}
export function getMessagesSince(
chatJid: string,
sinceTimestamp: string,
botPrefix: string,
limit: number = 200,
): NewMessage[] {
// Filter bot messages using both the is_bot_message flag AND the content
// prefix as a backstop for messages written before the migration ran.
// Subquery takes the N most recent, outer query re-sorts chronologically.
const sql = `
SELECT * FROM (
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me,
reply_to_message_id, reply_to_message_content, reply_to_sender_name
FROM messages
WHERE chat_jid = ? AND timestamp > ?
AND is_bot_message = 0 AND content NOT LIKE ?
AND content != '' AND content IS NOT NULL
ORDER BY timestamp DESC
LIMIT ?
) ORDER BY timestamp
`;
return db.prepare(sql).all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
}
export function getLastBotMessageTimestamp(chatJid: string, botPrefix: string): string | undefined {
const row = db
.prepare(
`SELECT MAX(timestamp) as ts FROM messages
WHERE chat_jid = ? AND (is_bot_message = 1 OR content LIKE ?)`,
)
.get(chatJid, `${botPrefix}:%`) as { ts: string | null } | undefined;
return row?.ts ?? undefined;
}
export function createTask(task: Omit<ScheduledTask, 'last_run' | 'last_result'>): void {
db.prepare(
`
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
task.id,
task.group_folder,
task.chat_jid,
task.prompt,
task.script || null,
task.schedule_type,
task.schedule_value,
task.context_mode || 'isolated',
task.next_run,
task.status,
task.created_at,
);
}
export function getTaskById(id: string): ScheduledTask | undefined {
return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as ScheduledTask | undefined;
}
export function getTasksForGroup(groupFolder: string): ScheduledTask[] {
return db
.prepare('SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC')
.all(groupFolder) as ScheduledTask[];
}
export function getAllTasks(): ScheduledTask[] {
return db.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC').all() as ScheduledTask[];
}
export function updateTask(
id: string,
updates: Partial<
Pick<ScheduledTask, 'prompt' | 'script' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status'>
>,
): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.prompt !== undefined) {
fields.push('prompt = ?');
values.push(updates.prompt);
}
if (updates.script !== undefined) {
fields.push('script = ?');
values.push(updates.script || null);
}
if (updates.schedule_type !== undefined) {
fields.push('schedule_type = ?');
values.push(updates.schedule_type);
}
if (updates.schedule_value !== undefined) {
fields.push('schedule_value = ?');
values.push(updates.schedule_value);
}
if (updates.next_run !== undefined) {
fields.push('next_run = ?');
values.push(updates.next_run);
}
if (updates.status !== undefined) {
fields.push('status = ?');
values.push(updates.status);
}
if (fields.length === 0) return;
values.push(id);
db.prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
export function deleteTask(id: string): void {
// Delete child records first (FK constraint)
db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id);
db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
}
export function getDueTasks(): ScheduledTask[] {
const now = new Date().toISOString();
return db
.prepare(
`
SELECT * FROM scheduled_tasks
WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ?
ORDER BY next_run
`,
)
.all(now) as ScheduledTask[];
}
export function updateTaskAfterRun(id: string, nextRun: string | null, lastResult: string): void {
const now = new Date().toISOString();
db.prepare(
`
UPDATE scheduled_tasks
SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END
WHERE id = ?
`,
).run(nextRun, now, lastResult, nextRun, id);
}
export function logTaskRun(log: TaskRunLog): void {
db.prepare(
`
INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error)
VALUES (?, ?, ?, ?, ?, ?)
`,
).run(log.task_id, log.run_at, log.duration_ms, log.status, log.result, log.error);
}
// --- Router state accessors ---
export function getRouterState(key: string): string | undefined {
const row = db.prepare('SELECT value FROM router_state WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value;
}
export function setRouterState(key: string, value: string): void {
db.prepare('INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)').run(key, value);
}
// --- Session accessors ---
export function getSession(groupFolder: string): string | undefined {
const row = db.prepare('SELECT session_id FROM sessions WHERE group_folder = ?').get(groupFolder) as
| { session_id: string }
| undefined;
return row?.session_id;
}
export function setSession(groupFolder: string, sessionId: string): void {
db.prepare('INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)').run(groupFolder, sessionId);
}
export function deleteSession(groupFolder: string): void {
db.prepare('DELETE FROM sessions WHERE group_folder = ?').run(groupFolder);
}
export function getAllSessions(): Record<string, string> {
const rows = db.prepare('SELECT group_folder, session_id FROM sessions').all() as Array<{
group_folder: string;
session_id: string;
}>;
const result: Record<string, string> = {};
for (const row of rows) {
result[row.group_folder] = row.session_id;
}
return result;
}
// --- Registered group accessors ---
export function getRegisteredGroup(jid: string): (RegisteredGroup & { jid: string }) | undefined {
const row = db.prepare('SELECT * FROM registered_groups WHERE jid = ?').get(jid) as
| {
jid: string;
name: string;
folder: string;
trigger_pattern: string;
added_at: string;
container_config: string | null;
requires_trigger: number | null;
is_main: number | null;
}
| 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,
folder: row.folder,
trigger: row.trigger_pattern,
added_at: row.added_at,
containerConfig: row.container_config ? JSON.parse(row.container_config) : undefined,
requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1,
isMain: row.is_main === 1 ? true : undefined,
};
}
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, is_main)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
jid,
group.name,
group.folder,
group.trigger,
group.added_at,
group.containerConfig ? JSON.stringify(group.containerConfig) : null,
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
group.isMain ? 1 : 0,
);
}
export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{
jid: string;
name: string;
folder: string;
trigger_pattern: string;
added_at: string;
container_config: string | null;
requires_trigger: number | null;
is_main: number | null;
}>;
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,
trigger: row.trigger_pattern,
added_at: row.added_at,
containerConfig: row.container_config ? JSON.parse(row.container_config) : undefined,
requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1,
isMain: row.is_main === 1 ? true : undefined,
};
}
return result;
}
// --- JSON migration ---
function migrateJsonState(): void {
const migrateFile = (filename: string) => {
const filePath = path.join(DATA_DIR, filename);
if (!fs.existsSync(filePath)) return null;
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
fs.renameSync(filePath, `${filePath}.migrated`);
return data;
} catch {
return null;
}
};
// Migrate router_state.json
const routerState = migrateFile('router_state.json') as {
last_timestamp?: string;
last_agent_timestamp?: Record<string, string>;
} | null;
if (routerState) {
if (routerState.last_timestamp) {
setRouterState('last_timestamp', routerState.last_timestamp);
}
if (routerState.last_agent_timestamp) {
setRouterState('last_agent_timestamp', JSON.stringify(routerState.last_agent_timestamp));
}
}
// Migrate sessions.json
const sessions = migrateFile('sessions.json') as Record<string, string> | null;
if (sessions) {
for (const [folder, sessionId] of Object.entries(sessions)) {
setSession(folder, sessionId);
}
}
// Migrate registered_groups.json
const groups = migrateFile('registered_groups.json') as Record<string, RegisteredGroup> | null;
if (groups) {
for (const [jid, group] of Object.entries(groups)) {
try {
setRegisteredGroup(jid, group);
} catch (err) {
logger.warn({ jid, folder: group.folder, err }, 'Skipping migrated registered group with invalid folder');
}
}
}
}

View File

@@ -1,42 +0,0 @@
import fs from 'fs';
import path from 'path';
import { logger } from './logger.js';
/**
* Parse the .env file and return values for the requested keys.
* Does NOT load anything into process.env — callers decide what to
* do with the values. This keeps secrets out of the process environment
* so they don't leak to child processes.
*/
export function readEnvFile(keys: string[]): Record<string, string> {
const envFile = path.join(process.cwd(), '.env');
let content: string;
try {
content = fs.readFileSync(envFile, 'utf-8');
} catch (err) {
logger.debug({ err }, '.env file not found, using defaults');
return {};
}
const result: Record<string, string> = {};
const wanted = new Set(keys);
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
if (!wanted.has(key)) continue;
let value = trimmed.slice(eqIdx + 1).trim();
if (
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}
if (value) result[key] = value;
}
return result;
}

View File

@@ -1,316 +0,0 @@
import { describe, it, expect } from 'vitest';
import { ASSISTANT_NAME, getTriggerPattern, TRIGGER_PATTERN } from './config.js';
import { escapeXml, formatMessages, formatOutbound, stripInternalTags } from './router.js';
import { NewMessage } from './types.js';
function makeMsg(overrides: Partial<NewMessage> = {}): NewMessage {
return {
id: '1',
chat_jid: 'group@g.us',
sender: '123@s.whatsapp.net',
sender_name: 'Alice',
content: 'hello',
timestamp: '2024-01-01T00:00:00.000Z',
...overrides,
};
}
// --- escapeXml ---
describe('escapeXml', () => {
it('escapes ampersands', () => {
expect(escapeXml('a & b')).toBe('a &amp; b');
});
it('escapes less-than', () => {
expect(escapeXml('a < b')).toBe('a &lt; b');
});
it('escapes greater-than', () => {
expect(escapeXml('a > b')).toBe('a &gt; b');
});
it('escapes double quotes', () => {
expect(escapeXml('"hello"')).toBe('&quot;hello&quot;');
});
it('handles multiple special characters together', () => {
expect(escapeXml('a & b < c > d "e"')).toBe('a &amp; b &lt; c &gt; d &quot;e&quot;');
});
it('passes through strings with no special chars', () => {
expect(escapeXml('hello world')).toBe('hello world');
});
it('handles empty string', () => {
expect(escapeXml('')).toBe('');
});
});
// --- formatMessages ---
describe('formatMessages', () => {
const TZ = 'UTC';
it('formats a single message as XML with context header', () => {
const result = formatMessages([makeMsg()], TZ);
expect(result).toContain('<context timezone="UTC" />');
expect(result).toContain('<message sender="Alice"');
expect(result).toContain('>hello</message>');
expect(result).toContain('Jan 1, 2024');
});
it('formats multiple messages', () => {
const msgs = [
makeMsg({
id: '1',
sender_name: 'Alice',
content: 'hi',
timestamp: '2024-01-01T00:00:00.000Z',
}),
makeMsg({
id: '2',
sender_name: 'Bob',
content: 'hey',
timestamp: '2024-01-01T01:00:00.000Z',
}),
];
const result = formatMessages(msgs, TZ);
expect(result).toContain('sender="Alice"');
expect(result).toContain('sender="Bob"');
expect(result).toContain('>hi</message>');
expect(result).toContain('>hey</message>');
});
it('escapes special characters in sender names', () => {
const result = formatMessages([makeMsg({ sender_name: 'A & B <Co>' })], TZ);
expect(result).toContain('sender="A &amp; B &lt;Co&gt;"');
});
it('escapes special characters in content', () => {
const result = formatMessages([makeMsg({ content: '<script>alert("xss")</script>' })], TZ);
expect(result).toContain('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
});
it('handles empty array', () => {
const result = formatMessages([], TZ);
expect(result).toContain('<context timezone="UTC" />');
expect(result).toContain('<messages>\n\n</messages>');
});
it('renders reply context as quoted_message element', () => {
const result = formatMessages(
[
makeMsg({
content: 'Yes, on my way!',
reply_to_message_id: '42',
reply_to_message_content: 'Are you coming tonight?',
reply_to_sender_name: 'Bob',
}),
],
TZ,
);
expect(result).toContain('reply_to="42"');
expect(result).toContain('<quoted_message from="Bob">Are you coming tonight?</quoted_message>');
expect(result).toContain('Yes, on my way!</message>');
});
it('omits reply attributes when no reply context', () => {
const result = formatMessages([makeMsg()], TZ);
expect(result).not.toContain('reply_to');
expect(result).not.toContain('quoted_message');
});
it('omits quoted_message when content is missing but id is present', () => {
const result = formatMessages(
[
makeMsg({
reply_to_message_id: '42',
reply_to_sender_name: 'Bob',
}),
],
TZ,
);
expect(result).toContain('reply_to="42"');
expect(result).not.toContain('quoted_message');
});
it('escapes special characters in reply context', () => {
const result = formatMessages(
[
makeMsg({
reply_to_message_id: '1',
reply_to_message_content: '<script>alert("xss")</script>',
reply_to_sender_name: 'A & B',
}),
],
TZ,
);
expect(result).toContain('from="A &amp; B"');
expect(result).toContain('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
});
it('converts timestamps to local time for given timezone', () => {
// 2024-01-01T18:30:00Z in America/New_York (EST) = 1:30 PM
const result = formatMessages([makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })], 'America/New_York');
expect(result).toContain('1:30');
expect(result).toContain('PM');
expect(result).toContain('<context timezone="America/New_York" />');
});
});
// --- TRIGGER_PATTERN ---
describe('TRIGGER_PATTERN', () => {
const name = ASSISTANT_NAME;
const lower = name.toLowerCase();
const upper = name.toUpperCase();
it('matches @name at start of message', () => {
expect(TRIGGER_PATTERN.test(`@${name} hello`)).toBe(true);
});
it('matches case-insensitively', () => {
expect(TRIGGER_PATTERN.test(`@${lower} hello`)).toBe(true);
expect(TRIGGER_PATTERN.test(`@${upper} hello`)).toBe(true);
});
it('does not match when not at start of message', () => {
expect(TRIGGER_PATTERN.test(`hello @${name}`)).toBe(false);
});
it('does not match partial name like @NameExtra (word boundary)', () => {
expect(TRIGGER_PATTERN.test(`@${name}extra hello`)).toBe(false);
});
it('matches with word boundary before apostrophe', () => {
expect(TRIGGER_PATTERN.test(`@${name}'s thing`)).toBe(true);
});
it('matches @name alone (end of string is a word boundary)', () => {
expect(TRIGGER_PATTERN.test(`@${name}`)).toBe(true);
});
it('matches with leading whitespace after trim', () => {
// The actual usage trims before testing: TRIGGER_PATTERN.test(m.content.trim())
expect(TRIGGER_PATTERN.test(`@${name} hey`.trim())).toBe(true);
});
});
describe('getTriggerPattern', () => {
it('uses the configured per-group trigger when provided', () => {
const pattern = getTriggerPattern('@Claw');
expect(pattern.test('@Claw hello')).toBe(true);
expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(false);
});
it('falls back to the default trigger when group trigger is missing', () => {
const pattern = getTriggerPattern(undefined);
expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(true);
});
it('treats regex characters in custom triggers literally', () => {
const pattern = getTriggerPattern('@C.L.A.U.D.E');
expect(pattern.test('@C.L.A.U.D.E hello')).toBe(true);
expect(pattern.test('@CXLXAUXDXE hello')).toBe(false);
});
});
// --- Outbound formatting (internal tag stripping + prefix) ---
describe('stripInternalTags', () => {
it('strips single-line internal tags', () => {
expect(stripInternalTags('hello <internal>secret</internal> world')).toBe('hello world');
});
it('strips multi-line internal tags', () => {
expect(stripInternalTags('hello <internal>\nsecret\nstuff\n</internal> world')).toBe('hello world');
});
it('strips multiple internal tag blocks', () => {
expect(stripInternalTags('<internal>a</internal>hello<internal>b</internal>')).toBe('hello');
});
it('returns empty string when text is only internal tags', () => {
expect(stripInternalTags('<internal>only this</internal>')).toBe('');
});
});
describe('formatOutbound', () => {
it('returns text with internal tags stripped', () => {
expect(formatOutbound('hello world')).toBe('hello world');
});
it('returns empty string when all text is internal', () => {
expect(formatOutbound('<internal>hidden</internal>')).toBe('');
});
it('strips internal tags from remaining text', () => {
expect(formatOutbound('<internal>thinking</internal>The answer is 42')).toBe('The answer is 42');
});
});
// --- Trigger gating with requiresTrigger flag ---
describe('trigger gating (requiresTrigger interaction)', () => {
// Replicates the exact logic from processGroupMessages and startMessageLoop:
// if (!isMainGroup && group.requiresTrigger !== false) { check group.trigger }
function shouldRequireTrigger(isMainGroup: boolean, requiresTrigger: boolean | undefined): boolean {
return !isMainGroup && requiresTrigger !== false;
}
function shouldProcess(
isMainGroup: boolean,
requiresTrigger: boolean | undefined,
trigger: string | undefined,
messages: NewMessage[],
): boolean {
if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true;
const triggerPattern = getTriggerPattern(trigger);
return messages.some((m) => triggerPattern.test(m.content.trim()));
}
it('main group always processes (no trigger needed)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(true, undefined, undefined, msgs)).toBe(true);
});
it('main group processes even with requiresTrigger=true', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(true, true, undefined, msgs)).toBe(true);
});
it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, undefined, undefined, msgs)).toBe(false);
});
it('non-main group with requiresTrigger=true requires trigger', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, true, undefined, msgs)).toBe(false);
});
it('non-main group with requiresTrigger=true processes when trigger present', () => {
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
expect(shouldProcess(false, true, undefined, msgs)).toBe(true);
});
it('non-main group uses its per-group trigger instead of the default trigger', () => {
const msgs = [makeMsg({ content: '@Claw do something' })];
expect(shouldProcess(false, true, '@Claw', msgs)).toBe(true);
});
it('non-main group does not process when only the default trigger is present for a custom-trigger group', () => {
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
expect(shouldProcess(false, true, '@Claw', msgs)).toBe(false);
});
it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, false, undefined, msgs)).toBe(true);
});
});

View File

@@ -1,35 +0,0 @@
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();
});
});

View File

@@ -1,44 +0,0 @@
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;
}

View File

@@ -1,457 +0,0 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { GroupQueue } from './group-queue.js';
// Mock config to control concurrency limit
vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/nanoclaw-test-data',
MAX_CONCURRENT_CONTAINERS: 2,
}));
// Mock fs operations used by sendMessage/closeStdin
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
default: {
...actual,
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
renameSync: vi.fn(),
},
};
});
describe('GroupQueue', () => {
let queue: GroupQueue;
beforeEach(() => {
vi.useFakeTimers();
queue = new GroupQueue();
});
afterEach(() => {
vi.useRealTimers();
});
// --- Single group at a time ---
it('only runs one container per group at a time', async () => {
let concurrentCount = 0;
let maxConcurrent = 0;
const processMessages = vi.fn(async (_groupJid: string) => {
concurrentCount++;
maxConcurrent = Math.max(maxConcurrent, concurrentCount);
// Simulate async work
await new Promise((resolve) => setTimeout(resolve, 100));
concurrentCount--;
return true;
});
queue.setProcessMessagesFn(processMessages);
// Enqueue two messages for the same group
queue.enqueueMessageCheck('group1@g.us');
queue.enqueueMessageCheck('group1@g.us');
// Advance timers to let the first process complete
await vi.advanceTimersByTimeAsync(200);
// Second enqueue should have been queued, not concurrent
expect(maxConcurrent).toBe(1);
});
// --- Global concurrency limit ---
it('respects global concurrency limit', async () => {
let activeCount = 0;
let maxActive = 0;
const completionCallbacks: Array<() => void> = [];
const processMessages = vi.fn(async (_groupJid: string) => {
activeCount++;
maxActive = Math.max(maxActive, activeCount);
await new Promise<void>((resolve) => completionCallbacks.push(resolve));
activeCount--;
return true;
});
queue.setProcessMessagesFn(processMessages);
// Enqueue 3 groups (limit is 2)
queue.enqueueMessageCheck('group1@g.us');
queue.enqueueMessageCheck('group2@g.us');
queue.enqueueMessageCheck('group3@g.us');
// Let promises settle
await vi.advanceTimersByTimeAsync(10);
// Only 2 should be active (MAX_CONCURRENT_CONTAINERS = 2)
expect(maxActive).toBe(2);
expect(activeCount).toBe(2);
// Complete one — third should start
completionCallbacks[0]();
await vi.advanceTimersByTimeAsync(10);
expect(processMessages).toHaveBeenCalledTimes(3);
});
// --- Tasks prioritized over messages ---
it('drains tasks before messages for same group', async () => {
const executionOrder: string[] = [];
let resolveFirst: () => void;
const processMessages = vi.fn(async (_groupJid: string) => {
if (executionOrder.length === 0) {
// First call: block until we release it
await new Promise<void>((resolve) => {
resolveFirst = resolve;
});
}
executionOrder.push('messages');
return true;
});
queue.setProcessMessagesFn(processMessages);
// Start processing messages (takes the active slot)
queue.enqueueMessageCheck('group1@g.us');
await vi.advanceTimersByTimeAsync(10);
// While active, enqueue both a task and pending messages
const taskFn = vi.fn(async () => {
executionOrder.push('task');
});
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
queue.enqueueMessageCheck('group1@g.us');
// Release the first processing
resolveFirst!();
await vi.advanceTimersByTimeAsync(10);
// Task should have run before the second message check
expect(executionOrder[0]).toBe('messages'); // first call
expect(executionOrder[1]).toBe('task'); // task runs first in drain
// Messages would run after task completes
});
// --- Retry with backoff on failure ---
it('retries with exponential backoff on failure', async () => {
let callCount = 0;
const processMessages = vi.fn(async () => {
callCount++;
return false; // failure
});
queue.setProcessMessagesFn(processMessages);
queue.enqueueMessageCheck('group1@g.us');
// First call happens immediately
await vi.advanceTimersByTimeAsync(10);
expect(callCount).toBe(1);
// First retry after 5000ms (BASE_RETRY_MS * 2^0)
await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(10);
expect(callCount).toBe(2);
// Second retry after 10000ms (BASE_RETRY_MS * 2^1)
await vi.advanceTimersByTimeAsync(10000);
await vi.advanceTimersByTimeAsync(10);
expect(callCount).toBe(3);
});
// --- Shutdown prevents new enqueues ---
it('prevents new enqueues after shutdown', async () => {
const processMessages = vi.fn(async () => true);
queue.setProcessMessagesFn(processMessages);
await queue.shutdown(1000);
queue.enqueueMessageCheck('group1@g.us');
await vi.advanceTimersByTimeAsync(100);
expect(processMessages).not.toHaveBeenCalled();
});
// --- Max retries exceeded ---
it('stops retrying after MAX_RETRIES and resets', async () => {
let callCount = 0;
const processMessages = vi.fn(async () => {
callCount++;
return false; // always fail
});
queue.setProcessMessagesFn(processMessages);
queue.enqueueMessageCheck('group1@g.us');
// Run through all 5 retries (MAX_RETRIES = 5)
// Initial call
await vi.advanceTimersByTimeAsync(10);
expect(callCount).toBe(1);
// Retry 1: 5000ms, Retry 2: 10000ms, Retry 3: 20000ms, Retry 4: 40000ms, Retry 5: 80000ms
const retryDelays = [5000, 10000, 20000, 40000, 80000];
for (let i = 0; i < retryDelays.length; i++) {
await vi.advanceTimersByTimeAsync(retryDelays[i] + 10);
expect(callCount).toBe(i + 2);
}
// After 5 retries (6 total calls), should stop — no more retries
const countAfterMaxRetries = callCount;
await vi.advanceTimersByTimeAsync(200000); // Wait a long time
expect(callCount).toBe(countAfterMaxRetries);
});
// --- Waiting groups get drained when slots free up ---
it('drains waiting groups when active slots free up', async () => {
const processed: string[] = [];
const completionCallbacks: Array<() => void> = [];
const processMessages = vi.fn(async (groupJid: string) => {
processed.push(groupJid);
await new Promise<void>((resolve) => completionCallbacks.push(resolve));
return true;
});
queue.setProcessMessagesFn(processMessages);
// Fill both slots
queue.enqueueMessageCheck('group1@g.us');
queue.enqueueMessageCheck('group2@g.us');
await vi.advanceTimersByTimeAsync(10);
// Queue a third
queue.enqueueMessageCheck('group3@g.us');
await vi.advanceTimersByTimeAsync(10);
expect(processed).toEqual(['group1@g.us', 'group2@g.us']);
// Free up a slot
completionCallbacks[0]();
await vi.advanceTimersByTimeAsync(10);
expect(processed).toContain('group3@g.us');
});
// --- Running task dedup (Issue #138) ---
it('rejects duplicate enqueue of a currently-running task', async () => {
let resolveTask: () => void;
let taskCallCount = 0;
const taskFn = vi.fn(async () => {
taskCallCount++;
await new Promise<void>((resolve) => {
resolveTask = resolve;
});
});
// Start the task (runs immediately — slot available)
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
await vi.advanceTimersByTimeAsync(10);
expect(taskCallCount).toBe(1);
// Scheduler poll re-discovers the same task while it's running —
// this must be silently dropped
const dupFn = vi.fn(async () => {});
queue.enqueueTask('group1@g.us', 'task-1', dupFn);
await vi.advanceTimersByTimeAsync(10);
// Duplicate was NOT queued
expect(dupFn).not.toHaveBeenCalled();
// Complete the original task
resolveTask!();
await vi.advanceTimersByTimeAsync(10);
// Only one execution total
expect(taskCallCount).toBe(1);
});
// --- Idle preemption ---
it('does NOT preempt active container when not idle', async () => {
const fs = await import('fs');
let resolveProcess: () => void;
const processMessages = vi.fn(async () => {
await new Promise<void>((resolve) => {
resolveProcess = resolve;
});
return true;
});
queue.setProcessMessagesFn(processMessages);
// Start processing (takes the active slot)
queue.enqueueMessageCheck('group1@g.us');
await vi.advanceTimersByTimeAsync(10);
// Register a process so closeStdin has a groupFolder
queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group');
// Enqueue a task while container is active but NOT idle
const taskFn = vi.fn(async () => {});
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
// _close should NOT have been written (container is working, not idle)
const writeFileSync = vi.mocked(fs.default.writeFileSync);
const closeWrites = writeFileSync.mock.calls.filter(
(call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
);
expect(closeWrites).toHaveLength(0);
resolveProcess!();
await vi.advanceTimersByTimeAsync(10);
});
it('preempts idle container when task is enqueued', async () => {
const fs = await import('fs');
let resolveProcess: () => void;
const processMessages = vi.fn(async () => {
await new Promise<void>((resolve) => {
resolveProcess = resolve;
});
return true;
});
queue.setProcessMessagesFn(processMessages);
// Start processing
queue.enqueueMessageCheck('group1@g.us');
await vi.advanceTimersByTimeAsync(10);
// Register process and mark idle
queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group');
queue.notifyIdle('group1@g.us');
// Clear previous writes, then enqueue a task
const writeFileSync = vi.mocked(fs.default.writeFileSync);
writeFileSync.mockClear();
const taskFn = vi.fn(async () => {});
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
// _close SHOULD have been written (container is idle)
const closeWrites = writeFileSync.mock.calls.filter(
(call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
);
expect(closeWrites).toHaveLength(1);
resolveProcess!();
await vi.advanceTimersByTimeAsync(10);
});
it('sendMessage resets idleWaiting so a subsequent task enqueue does not preempt', async () => {
const fs = await import('fs');
let resolveProcess: () => void;
const processMessages = vi.fn(async () => {
await new Promise<void>((resolve) => {
resolveProcess = resolve;
});
return true;
});
queue.setProcessMessagesFn(processMessages);
queue.enqueueMessageCheck('group1@g.us');
await vi.advanceTimersByTimeAsync(10);
queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group');
// Container becomes idle
queue.notifyIdle('group1@g.us');
// A new user message arrives — resets idleWaiting
queue.sendMessage('group1@g.us', 'hello');
// Task enqueued after message reset — should NOT preempt (agent is working)
const writeFileSync = vi.mocked(fs.default.writeFileSync);
writeFileSync.mockClear();
const taskFn = vi.fn(async () => {});
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
const closeWrites = writeFileSync.mock.calls.filter(
(call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
);
expect(closeWrites).toHaveLength(0);
resolveProcess!();
await vi.advanceTimersByTimeAsync(10);
});
it('sendMessage returns false for task containers so user messages queue up', async () => {
let resolveTask: () => void;
const taskFn = vi.fn(async () => {
await new Promise<void>((resolve) => {
resolveTask = resolve;
});
});
// Start a task (sets isTaskContainer = true)
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
await vi.advanceTimersByTimeAsync(10);
queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group');
// sendMessage should return false — user messages must not go to task containers
const result = queue.sendMessage('group1@g.us', 'hello');
expect(result).toBe(false);
resolveTask!();
await vi.advanceTimersByTimeAsync(10);
});
it('preempts when idle arrives with pending tasks', async () => {
const fs = await import('fs');
let resolveProcess: () => void;
const processMessages = vi.fn(async () => {
await new Promise<void>((resolve) => {
resolveProcess = resolve;
});
return true;
});
queue.setProcessMessagesFn(processMessages);
// Start processing
queue.enqueueMessageCheck('group1@g.us');
await vi.advanceTimersByTimeAsync(10);
// Register process and enqueue a task (no idle yet — no preemption)
queue.registerProcess('group1@g.us', {} as any, 'container-1', 'test-group');
const writeFileSync = vi.mocked(fs.default.writeFileSync);
writeFileSync.mockClear();
const taskFn = vi.fn(async () => {});
queue.enqueueTask('group1@g.us', 'task-1', taskFn);
let closeWrites = writeFileSync.mock.calls.filter(
(call) => typeof call[0] === 'string' && call[0].endsWith('_close'),
);
expect(closeWrites).toHaveLength(0);
// Now container becomes idle — should preempt because task is pending
writeFileSync.mockClear();
queue.notifyIdle('group1@g.us');
closeWrites = writeFileSync.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].endsWith('_close'));
expect(closeWrites).toHaveLength(1);
resolveProcess!();
await vi.advanceTimersByTimeAsync(10);
});
});

View File

@@ -1,325 +0,0 @@
import { ChildProcess } from 'child_process';
import fs from 'fs';
import path from 'path';
import { DATA_DIR, MAX_CONCURRENT_CONTAINERS } from './config.js';
import { logger } from './logger.js';
interface QueuedTask {
id: string;
groupJid: string;
fn: () => Promise<void>;
}
const MAX_RETRIES = 5;
const BASE_RETRY_MS = 5000;
interface GroupState {
active: boolean;
idleWaiting: boolean;
isTaskContainer: boolean;
runningTaskId: string | null;
pendingMessages: boolean;
pendingTasks: QueuedTask[];
process: ChildProcess | null;
containerName: string | null;
groupFolder: string | null;
retryCount: number;
}
export class GroupQueue {
private groups = new Map<string, GroupState>();
private activeCount = 0;
private waitingGroups: string[] = [];
private processMessagesFn: ((groupJid: string) => Promise<boolean>) | null = null;
private shuttingDown = false;
private getGroup(groupJid: string): GroupState {
let state = this.groups.get(groupJid);
if (!state) {
state = {
active: false,
idleWaiting: false,
isTaskContainer: false,
runningTaskId: null,
pendingMessages: false,
pendingTasks: [],
process: null,
containerName: null,
groupFolder: null,
retryCount: 0,
};
this.groups.set(groupJid, state);
}
return state;
}
setProcessMessagesFn(fn: (groupJid: string) => Promise<boolean>): void {
this.processMessagesFn = fn;
}
enqueueMessageCheck(groupJid: string): void {
if (this.shuttingDown) return;
const state = this.getGroup(groupJid);
if (state.active) {
state.pendingMessages = true;
logger.debug({ groupJid }, 'Container active, message queued');
return;
}
if (this.activeCount >= MAX_CONCURRENT_CONTAINERS) {
state.pendingMessages = true;
if (!this.waitingGroups.includes(groupJid)) {
this.waitingGroups.push(groupJid);
}
logger.debug({ groupJid, activeCount: this.activeCount }, 'At concurrency limit, message queued');
return;
}
this.runForGroup(groupJid, 'messages').catch((err) =>
logger.error({ groupJid, err }, 'Unhandled error in runForGroup'),
);
}
enqueueTask(groupJid: string, taskId: string, fn: () => Promise<void>): void {
if (this.shuttingDown) return;
const state = this.getGroup(groupJid);
// Prevent double-queuing: check both pending and currently-running task
if (state.runningTaskId === taskId) {
logger.debug({ groupJid, taskId }, 'Task already running, skipping');
return;
}
if (state.pendingTasks.some((t) => t.id === taskId)) {
logger.debug({ groupJid, taskId }, 'Task already queued, skipping');
return;
}
if (state.active) {
state.pendingTasks.push({ id: taskId, groupJid, fn });
if (state.idleWaiting) {
this.closeStdin(groupJid);
}
logger.debug({ groupJid, taskId }, 'Container active, task queued');
return;
}
if (this.activeCount >= MAX_CONCURRENT_CONTAINERS) {
state.pendingTasks.push({ id: taskId, groupJid, fn });
if (!this.waitingGroups.includes(groupJid)) {
this.waitingGroups.push(groupJid);
}
logger.debug({ groupJid, taskId, activeCount: this.activeCount }, 'At concurrency limit, task queued');
return;
}
// Run immediately
this.runTask(groupJid, { id: taskId, groupJid, fn }).catch((err) =>
logger.error({ groupJid, taskId, err }, 'Unhandled error in runTask'),
);
}
registerProcess(groupJid: string, proc: ChildProcess, containerName: string, groupFolder?: string): void {
const state = this.getGroup(groupJid);
state.process = proc;
state.containerName = containerName;
if (groupFolder) state.groupFolder = groupFolder;
}
/**
* Mark the container as idle-waiting (finished work, waiting for IPC input).
* If tasks are pending, preempt the idle container immediately.
*/
notifyIdle(groupJid: string): void {
const state = this.getGroup(groupJid);
state.idleWaiting = true;
if (state.pendingTasks.length > 0) {
this.closeStdin(groupJid);
}
}
/**
* Send a follow-up message to the active container via IPC file.
* Returns true if the message was written, false if no active container.
*/
sendMessage(groupJid: string, text: string): boolean {
const state = this.getGroup(groupJid);
if (!state.active || !state.groupFolder || state.isTaskContainer) return false;
state.idleWaiting = false; // Agent is about to receive work, no longer idle
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
try {
fs.mkdirSync(inputDir, { recursive: true });
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`;
const filepath = path.join(inputDir, filename);
const tempPath = `${filepath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text }));
fs.renameSync(tempPath, filepath);
return true;
} catch {
return false;
}
}
/**
* Signal the active container to wind down by writing a close sentinel.
*/
closeStdin(groupJid: string): void {
const state = this.getGroup(groupJid);
if (!state.active || !state.groupFolder) return;
const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input');
try {
fs.mkdirSync(inputDir, { recursive: true });
fs.writeFileSync(path.join(inputDir, '_close'), '');
} catch {
// ignore
}
}
private async runForGroup(groupJid: string, reason: 'messages' | 'drain'): Promise<void> {
const state = this.getGroup(groupJid);
state.active = true;
state.idleWaiting = false;
state.isTaskContainer = false;
state.pendingMessages = false;
this.activeCount++;
logger.debug({ groupJid, reason, activeCount: this.activeCount }, 'Starting container for group');
try {
if (this.processMessagesFn) {
const success = await this.processMessagesFn(groupJid);
if (success) {
state.retryCount = 0;
} else {
this.scheduleRetry(groupJid, state);
}
}
} catch (err) {
logger.error({ groupJid, err }, 'Error processing messages for group');
this.scheduleRetry(groupJid, state);
} finally {
state.active = false;
state.process = null;
state.containerName = null;
state.groupFolder = null;
this.activeCount--;
this.drainGroup(groupJid);
}
}
private async runTask(groupJid: string, task: QueuedTask): Promise<void> {
const state = this.getGroup(groupJid);
state.active = true;
state.idleWaiting = false;
state.isTaskContainer = true;
state.runningTaskId = task.id;
this.activeCount++;
logger.debug({ groupJid, taskId: task.id, activeCount: this.activeCount }, 'Running queued task');
try {
await task.fn();
} catch (err) {
logger.error({ groupJid, taskId: task.id, err }, 'Error running task');
} finally {
state.active = false;
state.isTaskContainer = false;
state.runningTaskId = null;
state.process = null;
state.containerName = null;
state.groupFolder = null;
this.activeCount--;
this.drainGroup(groupJid);
}
}
private scheduleRetry(groupJid: string, state: GroupState): void {
state.retryCount++;
if (state.retryCount > MAX_RETRIES) {
logger.error(
{ groupJid, retryCount: state.retryCount },
'Max retries exceeded, dropping messages (will retry on next incoming message)',
);
state.retryCount = 0;
return;
}
const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1);
logger.info({ groupJid, retryCount: state.retryCount, delayMs }, 'Scheduling retry with backoff');
setTimeout(() => {
if (!this.shuttingDown) {
this.enqueueMessageCheck(groupJid);
}
}, delayMs);
}
private drainGroup(groupJid: string): void {
if (this.shuttingDown) return;
const state = this.getGroup(groupJid);
// Tasks first (they won't be re-discovered from SQLite like messages)
if (state.pendingTasks.length > 0) {
const task = state.pendingTasks.shift()!;
this.runTask(groupJid, task).catch((err) =>
logger.error({ groupJid, taskId: task.id, err }, 'Unhandled error in runTask (drain)'),
);
return;
}
// Then pending messages
if (state.pendingMessages) {
this.runForGroup(groupJid, 'drain').catch((err) =>
logger.error({ groupJid, err }, 'Unhandled error in runForGroup (drain)'),
);
return;
}
// Nothing pending for this group; check if other groups are waiting for a slot
this.drainWaiting();
}
private drainWaiting(): void {
while (this.waitingGroups.length > 0 && this.activeCount < MAX_CONCURRENT_CONTAINERS) {
const nextJid = this.waitingGroups.shift()!;
const state = this.getGroup(nextJid);
// Prioritize tasks over messages
if (state.pendingTasks.length > 0) {
const task = state.pendingTasks.shift()!;
this.runTask(nextJid, task).catch((err) =>
logger.error({ groupJid: nextJid, taskId: task.id, err }, 'Unhandled error in runTask (waiting)'),
);
} else if (state.pendingMessages) {
this.runForGroup(nextJid, 'drain').catch((err) =>
logger.error({ groupJid: nextJid, err }, 'Unhandled error in runForGroup (waiting)'),
);
}
// If neither pending, skip this group
}
}
async shutdown(_gracePeriodMs: number): Promise<void> {
this.shuttingDown = true;
// Count active containers but don't kill them — they'll finish on their own
// via idle timeout or container timeout. The --rm flag cleans them up on exit.
// This prevents WhatsApp reconnection restarts from killing working agents.
const activeContainers: string[] = [];
for (const [_jid, state] of this.groups) {
if (state.process && !state.process.killed && state.containerName) {
activeContainers.push(state.containerName);
}
}
logger.info(
{ activeCount: this.activeCount, detachedContainers: activeContainers },
'GroupQueue shutting down (containers detached, not killed)',
);
}
}

View File

@@ -1,647 +0,0 @@
import fs from 'fs';
import path from 'path';
import { OneCLI } from '@onecli-sh/sdk';
import {
ASSISTANT_NAME,
DEFAULT_TRIGGER,
getTriggerPattern,
GROUPS_DIR,
IDLE_TIMEOUT,
MAX_MESSAGES_PER_PROMPT,
ONECLI_URL,
POLL_INTERVAL,
TIMEZONE,
} from './config.js';
import './channels/index.js';
import { getChannelFactory, getRegisteredChannelNames } from './channels/registry.js';
import { ContainerOutput, runContainerAgent, writeGroupsSnapshot, writeTasksSnapshot } from './container-runner.js';
import { cleanupOrphans, ensureContainerRuntimeRunning } from './container-runtime.js';
import {
getAllChats,
getAllRegisteredGroups,
getAllSessions,
deleteSession,
getAllTasks,
getLastBotMessageTimestamp,
getMessagesSince,
getNewMessages,
getRouterState,
initDatabase,
setRegisteredGroup,
setRouterState,
setSession,
storeChatMetadata,
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 { restoreRemoteControl, startRemoteControl, stopRemoteControl } from './remote-control.js';
import { isSenderAllowed, isTriggerAllowed, loadSenderAllowlist, shouldDropMessage } from './sender-allowlist.js';
import { startSessionCleanup } from './session-cleanup.js';
import { startSchedulerLoop } from './task-scheduler.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
// Re-export for backwards compatibility during refactor
export { escapeXml, formatMessages } from './router.js';
let lastTimestamp = '';
let sessions: Record<string, string> = {};
let registeredGroups: Record<string, RegisteredGroup> = {};
let lastAgentTimestamp: Record<string, string> = {};
let messageLoopRunning = false;
const channels: Channel[] = [];
const queue = new GroupQueue();
const onecli = new OneCLI({ url: ONECLI_URL });
function ensureOneCLIAgent(jid: string, group: RegisteredGroup): void {
if (group.isMain) return;
const identifier = group.folder.toLowerCase().replace(/_/g, '-');
onecli.ensureAgent({ name: group.name, identifier }).then(
(res) => {
logger.info({ jid, identifier, created: res.created }, 'OneCLI agent ensured');
},
(err) => {
logger.debug({ jid, identifier, err: String(err) }, 'OneCLI agent ensure skipped');
},
);
}
function loadState(): void {
lastTimestamp = getRouterState('last_timestamp') || '';
const agentTs = getRouterState('last_agent_timestamp');
try {
lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
} catch {
logger.warn('Corrupted last_agent_timestamp in DB, resetting');
lastAgentTimestamp = {};
}
sessions = getAllSessions();
registeredGroups = getAllRegisteredGroups();
logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded');
}
/**
* Return the message cursor for a group, recovering from the last bot reply
* if lastAgentTimestamp is missing (new group, corrupted state, restart).
*/
function getOrRecoverCursor(chatJid: string): string {
const existing = lastAgentTimestamp[chatJid];
if (existing) return existing;
const botTs = getLastBotMessageTimestamp(chatJid, ASSISTANT_NAME);
if (botTs) {
logger.info({ chatJid, recoveredFrom: botTs }, 'Recovered message cursor from last bot reply');
lastAgentTimestamp[chatJid] = botTs;
saveState();
return botTs;
}
return '';
}
function saveState(): void {
setRouterState('last_timestamp', lastTimestamp);
setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
}
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
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
// Copy CLAUDE.md template into the new group folder so agents have
// identity and instructions from the first run. (Fixes #1391)
const groupMdFile = path.join(groupDir, 'CLAUDE.md');
if (!fs.existsSync(groupMdFile)) {
const templateFile = path.join(GROUPS_DIR, group.isMain ? 'main' : 'global', 'CLAUDE.md');
if (fs.existsSync(templateFile)) {
let content = fs.readFileSync(templateFile, 'utf-8');
if (ASSISTANT_NAME !== 'Andy') {
content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`);
content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`);
}
fs.writeFileSync(groupMdFile, content);
logger.info({ folder: group.folder }, 'Created CLAUDE.md from template');
}
}
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
ensureOneCLIAgent(jid, group);
logger.info({ jid, name: group.name, folder: group.folder }, 'Group registered');
}
/**
* Get available groups list for the agent.
* Returns groups ordered by most recent activity.
*/
export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
const chats = getAllChats();
const registeredJids = new Set(Object.keys(registeredGroups));
return chats
.filter((c) => c.jid !== '__group_sync__' && c.is_group)
.map((c) => ({
jid: c.jid,
name: c.name,
lastActivity: c.last_message_time,
isRegistered: registeredJids.has(c.jid),
}));
}
/** @internal - exported for testing */
export function _setRegisteredGroups(groups: Record<string, RegisteredGroup>): void {
registeredGroups = groups;
}
/**
* Process all pending messages for a group.
* Called by the GroupQueue when it's this group's turn.
*/
async function processGroupMessages(chatJid: string): Promise<boolean> {
const group = registeredGroups[chatJid];
if (!group) return true;
const channel = findChannel(channels, chatJid);
if (!channel) {
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
return true;
}
const isMainGroup = group.isMain === true;
const missedMessages = getMessagesSince(
chatJid,
getOrRecoverCursor(chatJid),
ASSISTANT_NAME,
MAX_MESSAGES_PER_PROMPT,
);
if (missedMessages.length === 0) return true;
// For non-main groups, check if trigger is required and present
if (!isMainGroup && group.requiresTrigger !== false) {
const triggerPattern = getTriggerPattern(group.trigger);
const allowlistCfg = loadSenderAllowlist();
const hasTrigger = missedMessages.some(
(m) =>
triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) return true;
}
const prompt = formatMessages(missedMessages, TIMEZONE);
// Advance cursor so the piping path in startMessageLoop won't re-fetch
// these messages. Save the old cursor so we can roll back on error.
const previousCursor = lastAgentTimestamp[chatJid] || '';
lastAgentTimestamp[chatJid] = missedMessages[missedMessages.length - 1].timestamp;
saveState();
logger.info({ group: group.name, messageCount: missedMessages.length }, 'Processing messages');
// Track idle timer for closing stdin when agent is idle
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const resetIdleTimer = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
logger.debug({ group: group.name }, 'Idle timeout, closing container stdin');
queue.closeStdin(chatJid);
}, IDLE_TIMEOUT);
};
await channel.setTyping?.(chatJid, true);
let hadError = false;
let outputSentToUser = false;
const output = await runAgent(group, prompt, chatJid, async (result) => {
// Streaming output callback — called for each agent result
if (result.result) {
const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result);
// Strip <internal>...</internal> blocks — agent uses these for internal reasoning
const text = raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
logger.info({ group: group.name }, `Agent output: ${raw.length} chars`);
if (text) {
await channel.sendMessage(chatJid, text);
outputSentToUser = true;
}
// Only reset idle timer on actual results, not session-update markers (result: null)
resetIdleTimer();
}
if (result.status === 'success') {
queue.notifyIdle(chatJid);
}
if (result.status === 'error') {
hadError = true;
}
});
await channel.setTyping?.(chatJid, false);
if (idleTimer) clearTimeout(idleTimer);
if (output === 'error' || hadError) {
// If we already sent output to the user, don't roll back the cursor —
// the user got their response and re-processing would send duplicates.
if (outputSentToUser) {
logger.warn(
{ group: group.name },
'Agent error after output was sent, skipping cursor rollback to prevent duplicates',
);
return true;
}
// Roll back cursor so retries can re-process these messages
lastAgentTimestamp[chatJid] = previousCursor;
saveState();
logger.warn({ group: group.name }, 'Agent error, rolled back message cursor for retry');
return false;
}
return true;
}
async function runAgent(
group: RegisteredGroup,
prompt: string,
chatJid: string,
onOutput?: (output: ContainerOutput) => Promise<void>,
): Promise<'success' | 'error'> {
const isMain = group.isMain === true;
const sessionId = sessions[group.folder];
// Update tasks snapshot for container to read (filtered by group)
const tasks = getAllTasks();
writeTasksSnapshot(
group.folder,
isMain,
tasks.map((t) => ({
id: t.id,
groupFolder: t.group_folder,
prompt: t.prompt,
script: t.script || undefined,
schedule_type: t.schedule_type,
schedule_value: t.schedule_value,
status: t.status,
next_run: t.next_run,
})),
);
// Update available groups snapshot (main group only can see all groups)
const availableGroups = getAvailableGroups();
writeGroupsSnapshot(group.folder, isMain, availableGroups, new Set(Object.keys(registeredGroups)));
// Wrap onOutput to track session ID from streamed results
const wrappedOnOutput = onOutput
? async (output: ContainerOutput) => {
if (output.newSessionId) {
sessions[group.folder] = output.newSessionId;
setSession(group.folder, output.newSessionId);
}
await onOutput(output);
}
: undefined;
try {
const output = await runContainerAgent(
group,
{
prompt,
sessionId,
groupFolder: group.folder,
chatJid,
isMain,
assistantName: ASSISTANT_NAME,
},
(proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder),
wrappedOnOutput,
);
if (output.newSessionId) {
sessions[group.folder] = output.newSessionId;
setSession(group.folder, output.newSessionId);
}
if (output.status === 'error') {
// Detect stale/corrupt session — clear it so the next retry starts fresh.
// The session .jsonl can go missing after a crash mid-write, manual
// deletion, or disk-full. The existing backoff in group-queue.ts
// handles the retry; we just need to remove the broken session ID.
const isStaleSession =
sessionId && output.error && /no conversation found|ENOENT.*\.jsonl|session.*not found/i.test(output.error);
if (isStaleSession) {
logger.warn(
{ group: group.name, staleSessionId: sessionId, error: output.error },
'Stale session detected — clearing for next retry',
);
delete sessions[group.folder];
deleteSession(group.folder);
}
logger.error({ group: group.name, error: output.error }, 'Container agent error');
return 'error';
}
return 'success';
} catch (err) {
logger.error({ group: group.name, err }, 'Agent error');
return 'error';
}
}
async function startMessageLoop(): Promise<void> {
if (messageLoopRunning) {
logger.debug('Message loop already running, skipping duplicate start');
return;
}
messageLoopRunning = true;
logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`);
while (true) {
try {
const jids = Object.keys(registeredGroups);
const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp, ASSISTANT_NAME);
if (messages.length > 0) {
logger.info({ count: messages.length }, 'New messages');
// Advance the "seen" cursor for all messages immediately
lastTimestamp = newTimestamp;
saveState();
// Deduplicate by group
const messagesByGroup = new Map<string, NewMessage[]>();
for (const msg of messages) {
const existing = messagesByGroup.get(msg.chat_jid);
if (existing) {
existing.push(msg);
} else {
messagesByGroup.set(msg.chat_jid, [msg]);
}
}
for (const [chatJid, groupMessages] of messagesByGroup) {
const group = registeredGroups[chatJid];
if (!group) continue;
const channel = findChannel(channels, chatJid);
if (!channel) {
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
continue;
}
const isMainGroup = group.isMain === true;
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
// For non-main groups, only act on trigger messages.
// Non-trigger messages accumulate in DB and get pulled as
// context when a trigger eventually arrives.
if (needsTrigger) {
const triggerPattern = getTriggerPattern(group.trigger);
const allowlistCfg = loadSenderAllowlist();
const hasTrigger = groupMessages.some(
(m) =>
triggerPattern.test(m.content.trim()) &&
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) continue;
}
// Pull all messages since lastAgentTimestamp so non-trigger
// context that accumulated between triggers is included.
const allPending = getMessagesSince(
chatJid,
getOrRecoverCursor(chatJid),
ASSISTANT_NAME,
MAX_MESSAGES_PER_PROMPT,
);
const messagesToSend = allPending.length > 0 ? allPending : groupMessages;
const formatted = formatMessages(messagesToSend, TIMEZONE);
if (queue.sendMessage(chatJid, formatted)) {
logger.debug({ chatJid, count: messagesToSend.length }, 'Piped messages to active container');
lastAgentTimestamp[chatJid] = messagesToSend[messagesToSend.length - 1].timestamp;
saveState();
// Show typing indicator while the container processes the piped message
channel
.setTyping?.(chatJid, true)
?.catch((err) => logger.warn({ chatJid, err }, 'Failed to set typing indicator'));
} else {
// No active container — enqueue for a new one
queue.enqueueMessageCheck(chatJid);
}
}
}
} catch (err) {
logger.error({ err }, 'Error in message loop');
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
}
}
/**
* Startup recovery: check for unprocessed messages in registered groups.
* Handles crash between advancing lastTimestamp and processing messages.
*/
function recoverPendingMessages(): void {
for (const [chatJid, group] of Object.entries(registeredGroups)) {
const pending = getMessagesSince(chatJid, getOrRecoverCursor(chatJid), ASSISTANT_NAME, MAX_MESSAGES_PER_PROMPT);
if (pending.length > 0) {
logger.info({ group: group.name, pendingCount: pending.length }, 'Recovery: found unprocessed messages');
queue.enqueueMessageCheck(chatJid);
}
}
}
function ensureContainerSystemRunning(): void {
ensureContainerRuntimeRunning();
cleanupOrphans();
}
async function main(): Promise<void> {
ensureContainerSystemRunning();
initDatabase();
logger.info('Database initialized');
loadState();
// Ensure OneCLI agents exist for all registered groups.
// Recovers from missed creates (e.g. OneCLI was down at registration time).
for (const [jid, group] of Object.entries(registeredGroups)) {
ensureOneCLIAgent(jid, group);
}
restoreRemoteControl();
// Graceful shutdown handlers
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
await queue.shutdown(10000);
for (const ch of channels) await ch.disconnect();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Handle /remote-control and /remote-control-end commands
async function handleRemoteControl(command: string, chatJid: string, msg: NewMessage): Promise<void> {
const group = registeredGroups[chatJid];
if (!group?.isMain) {
logger.warn({ chatJid, sender: msg.sender }, 'Remote control rejected: not main group');
return;
}
const channel = findChannel(channels, chatJid);
if (!channel) return;
if (command === '/remote-control') {
const result = await startRemoteControl(msg.sender, chatJid, process.cwd());
if (result.ok) {
await channel.sendMessage(chatJid, result.url);
} else {
await channel.sendMessage(chatJid, `Remote Control failed: ${result.error}`);
}
} else {
const result = stopRemoteControl();
if (result.ok) {
await channel.sendMessage(chatJid, 'Remote Control session ended.');
} else {
await channel.sendMessage(chatJid, result.error);
}
}
}
// Channel callbacks (shared by all channels)
const channelOpts = {
onMessage: (chatJid: string, msg: NewMessage) => {
// Remote control commands — intercept before storage
const trimmed = msg.content.trim();
if (trimmed === '/remote-control' || trimmed === '/remote-control-end') {
handleRemoteControl(trimmed, chatJid, msg).catch((err) =>
logger.error({ err, chatJid }, 'Remote control command error'),
);
return;
}
// Sender allowlist drop mode: discard messages from denied senders before storing
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
const cfg = loadSenderAllowlist();
if (shouldDropMessage(chatJid, cfg) && !isSenderAllowed(chatJid, msg.sender, cfg)) {
if (cfg.logDenied) {
logger.debug({ chatJid, sender: msg.sender }, 'sender-allowlist: dropping message (drop mode)');
}
return;
}
}
storeMessage(msg);
},
onChatMetadata: (chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean) =>
storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
registeredGroups: () => registeredGroups,
};
// Create and connect all registered channels.
// Each channel self-registers via the barrel import above.
// Factories return null when credentials are missing, so unconfigured channels are skipped.
for (const channelName of getRegisteredChannelNames()) {
const factory = getChannelFactory(channelName)!;
const channel = factory(channelOpts);
if (!channel) {
logger.warn(
{ channel: channelName },
'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
);
continue;
}
channels.push(channel);
await channel.connect();
}
if (channels.length === 0) {
logger.fatal('No channels connected');
process.exit(1);
}
// Start subsystems (independently of connection handler)
startSchedulerLoop({
registeredGroups: () => registeredGroups,
getSessions: () => sessions,
queue,
onProcess: (groupJid, proc, containerName, groupFolder) =>
queue.registerProcess(groupJid, proc, containerName, groupFolder),
sendMessage: async (jid, rawText) => {
const channel = findChannel(channels, jid);
if (!channel) {
logger.warn({ jid }, 'No channel owns JID, cannot send message');
return;
}
const text = formatOutbound(rawText);
if (text) await channel.sendMessage(jid, text);
},
});
startIpcWatcher({
sendMessage: (jid, text) => {
const channel = findChannel(channels, jid);
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
},
registeredGroups: () => registeredGroups,
registerGroup,
syncGroups: async (force: boolean) => {
await Promise.all(channels.filter((ch) => ch.syncGroups).map((ch) => ch.syncGroups!(force)));
},
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj),
onTasksChanged: () => {
const tasks = getAllTasks();
const taskRows = tasks.map((t) => ({
id: t.id,
groupFolder: t.group_folder,
prompt: t.prompt,
script: t.script || undefined,
schedule_type: t.schedule_type,
schedule_value: t.schedule_value,
status: t.status,
next_run: t.next_run,
}));
for (const group of Object.values(registeredGroups)) {
writeTasksSnapshot(group.folder, group.isMain === true, taskRows);
}
},
});
startSessionCleanup();
queue.setProcessMessagesFn(processGroupMessages);
recoverPendingMessages();
startMessageLoop().catch((err) => {
logger.fatal({ err }, 'Message loop crashed unexpectedly');
process.exit(1);
});
}
// Guard: only run when executed directly, not when imported by tests
const isDirectRun =
process.argv[1] && new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname;
if (isDirectRun) {
main().catch((err) => {
logger.error({ err }, 'Failed to start NanoClaw');
process.exit(1);
});
}

View File

@@ -1,613 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
_initTestDatabase,
createTask,
getAllTasks,
getRegisteredGroup,
getTaskById,
setRegisteredGroup,
} from './db.js';
import { processTaskIpc, IpcDeps } from './ipc.js';
import { RegisteredGroup } from './types.js';
// Set up registered groups used across tests
const MAIN_GROUP: RegisteredGroup = {
name: 'Main',
folder: 'whatsapp_main',
trigger: 'always',
added_at: '2024-01-01T00:00:00.000Z',
isMain: true,
};
const OTHER_GROUP: RegisteredGroup = {
name: 'Other',
folder: 'other-group',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
};
const THIRD_GROUP: RegisteredGroup = {
name: 'Third',
folder: 'third-group',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
};
let groups: Record<string, RegisteredGroup>;
let deps: IpcDeps;
beforeEach(() => {
_initTestDatabase();
groups = {
'main@g.us': MAIN_GROUP,
'other@g.us': OTHER_GROUP,
'third@g.us': THIRD_GROUP,
};
// Populate DB as well
setRegisteredGroup('main@g.us', MAIN_GROUP);
setRegisteredGroup('other@g.us', OTHER_GROUP);
setRegisteredGroup('third@g.us', THIRD_GROUP);
deps = {
sendMessage: async () => {},
registeredGroups: () => groups,
registerGroup: (jid, group) => {
groups[jid] = group;
setRegisteredGroup(jid, group);
// Mock the fs.mkdirSync that registerGroup does
},
syncGroups: async () => {},
getAvailableGroups: () => [],
writeGroupsSnapshot: () => {},
onTasksChanged: () => {},
};
});
// --- schedule_task authorization ---
describe('schedule_task authorization', () => {
it('main group can schedule for another group', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'do something',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
// Verify task was created in DB for the other group
const allTasks = getAllTasks();
expect(allTasks.length).toBe(1);
expect(allTasks[0].group_folder).toBe('other-group');
});
it('non-main group can schedule for itself', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'self task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us',
},
'other-group',
false,
deps,
);
const allTasks = getAllTasks();
expect(allTasks.length).toBe(1);
expect(allTasks[0].group_folder).toBe('other-group');
});
it('non-main group cannot schedule for another group', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'unauthorized',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
targetJid: 'main@g.us',
},
'other-group',
false,
deps,
);
const allTasks = getAllTasks();
expect(allTasks.length).toBe(0);
});
it('rejects schedule_task for unregistered target JID', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'no target',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
targetJid: 'unknown@g.us',
},
'whatsapp_main',
true,
deps,
);
const allTasks = getAllTasks();
expect(allTasks.length).toBe(0);
});
});
// --- pause_task authorization ---
describe('pause_task authorization', () => {
beforeEach(() => {
createTask({
id: 'task-main',
group_folder: 'whatsapp_main',
chat_jid: 'main@g.us',
prompt: 'main task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
createTask({
id: 'task-other',
group_folder: 'other-group',
chat_jid: 'other@g.us',
prompt: 'other task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
});
it('main group can pause any task', async () => {
await processTaskIpc({ type: 'pause_task', taskId: 'task-other' }, 'whatsapp_main', true, deps);
expect(getTaskById('task-other')!.status).toBe('paused');
});
it('non-main group can pause its own task', async () => {
await processTaskIpc({ type: 'pause_task', taskId: 'task-other' }, 'other-group', false, deps);
expect(getTaskById('task-other')!.status).toBe('paused');
});
it('non-main group cannot pause another groups task', async () => {
await processTaskIpc({ type: 'pause_task', taskId: 'task-main' }, 'other-group', false, deps);
expect(getTaskById('task-main')!.status).toBe('active');
});
});
// --- resume_task authorization ---
describe('resume_task authorization', () => {
beforeEach(() => {
createTask({
id: 'task-paused',
group_folder: 'other-group',
chat_jid: 'other@g.us',
prompt: 'paused task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'paused',
created_at: '2024-01-01T00:00:00.000Z',
});
});
it('main group can resume any task', async () => {
await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'whatsapp_main', true, deps);
expect(getTaskById('task-paused')!.status).toBe('active');
});
it('non-main group can resume its own task', async () => {
await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'other-group', false, deps);
expect(getTaskById('task-paused')!.status).toBe('active');
});
it('non-main group cannot resume another groups task', async () => {
await processTaskIpc({ type: 'resume_task', taskId: 'task-paused' }, 'third-group', false, deps);
expect(getTaskById('task-paused')!.status).toBe('paused');
});
});
// --- cancel_task authorization ---
describe('cancel_task authorization', () => {
it('main group can cancel any task', async () => {
createTask({
id: 'task-to-cancel',
group_folder: 'other-group',
chat_jid: 'other@g.us',
prompt: 'cancel me',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: null,
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
await processTaskIpc({ type: 'cancel_task', taskId: 'task-to-cancel' }, 'whatsapp_main', true, deps);
expect(getTaskById('task-to-cancel')).toBeUndefined();
});
it('non-main group can cancel its own task', async () => {
createTask({
id: 'task-own',
group_folder: 'other-group',
chat_jid: 'other@g.us',
prompt: 'my task',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: null,
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
await processTaskIpc({ type: 'cancel_task', taskId: 'task-own' }, 'other-group', false, deps);
expect(getTaskById('task-own')).toBeUndefined();
});
it('non-main group cannot cancel another groups task', async () => {
createTask({
id: 'task-foreign',
group_folder: 'whatsapp_main',
chat_jid: 'main@g.us',
prompt: 'not yours',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: null,
status: 'active',
created_at: '2024-01-01T00:00:00.000Z',
});
await processTaskIpc({ type: 'cancel_task', taskId: 'task-foreign' }, 'other-group', false, deps);
expect(getTaskById('task-foreign')).toBeDefined();
});
});
// --- register_group authorization ---
describe('register_group authorization', () => {
it('non-main group cannot register a group', async () => {
await processTaskIpc(
{
type: 'register_group',
jid: 'new@g.us',
name: 'New Group',
folder: 'new-group',
trigger: '@Andy',
},
'other-group',
false,
deps,
);
// 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',
},
'whatsapp_main',
true,
deps,
);
expect(groups['new@g.us']).toBeUndefined();
});
});
// --- refresh_groups authorization ---
describe('refresh_groups authorization', () => {
it('non-main group cannot trigger refresh', async () => {
// This should be silently blocked (no crash, no effect)
await processTaskIpc({ type: 'refresh_groups' }, 'other-group', false, deps);
// If we got here without error, the auth gate worked
});
});
// --- IPC message authorization ---
// Tests the authorization pattern from startIpcWatcher (ipc.ts).
// The logic: isMain || (targetGroup && targetGroup.folder === sourceGroup)
describe('IPC message authorization', () => {
// Replicate the exact check from the IPC watcher
function isMessageAuthorized(
sourceGroup: string,
isMain: boolean,
targetChatJid: string,
registeredGroups: Record<string, RegisteredGroup>,
): boolean {
const targetGroup = registeredGroups[targetChatJid];
return isMain || (!!targetGroup && targetGroup.folder === sourceGroup);
}
it('main group can send to any group', () => {
expect(isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups)).toBe(true);
expect(isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups)).toBe(true);
});
it('non-main group can send to its own chat', () => {
expect(isMessageAuthorized('other-group', false, 'other@g.us', groups)).toBe(true);
});
it('non-main group cannot send to another groups chat', () => {
expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe(false);
expect(isMessageAuthorized('other-group', false, 'third@g.us', groups)).toBe(false);
});
it('non-main group cannot send to unregistered JID', () => {
expect(isMessageAuthorized('other-group', false, 'unknown@g.us', groups)).toBe(false);
});
it('main group can send to unregistered JID', () => {
// Main is always authorized regardless of target
expect(isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups)).toBe(true);
});
});
// --- schedule_task with cron and interval types ---
describe('schedule_task schedule types', () => {
it('creates task with cron schedule and computes next_run', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'cron task',
schedule_type: 'cron',
schedule_value: '0 9 * * *', // every day at 9am
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks).toHaveLength(1);
expect(tasks[0].schedule_type).toBe('cron');
expect(tasks[0].next_run).toBeTruthy();
// next_run should be a valid ISO date in the future
expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan(Date.now() - 60000);
});
it('rejects invalid cron expression', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'bad cron',
schedule_type: 'cron',
schedule_value: 'not a cron',
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
expect(getAllTasks()).toHaveLength(0);
});
it('creates task with interval schedule', async () => {
const before = Date.now();
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'interval task',
schedule_type: 'interval',
schedule_value: '3600000', // 1 hour
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks).toHaveLength(1);
expect(tasks[0].schedule_type).toBe('interval');
// next_run should be ~1 hour from now
const nextRun = new Date(tasks[0].next_run!).getTime();
expect(nextRun).toBeGreaterThanOrEqual(before + 3600000 - 1000);
expect(nextRun).toBeLessThanOrEqual(Date.now() + 3600000 + 1000);
});
it('rejects invalid interval (non-numeric)', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'bad interval',
schedule_type: 'interval',
schedule_value: 'abc',
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
expect(getAllTasks()).toHaveLength(0);
});
it('rejects invalid interval (zero)', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'zero interval',
schedule_type: 'interval',
schedule_value: '0',
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
expect(getAllTasks()).toHaveLength(0);
});
it('rejects invalid once timestamp', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'bad once',
schedule_type: 'once',
schedule_value: 'not-a-date',
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
expect(getAllTasks()).toHaveLength(0);
});
});
// --- context_mode defaulting ---
describe('schedule_task context_mode', () => {
it('accepts context_mode=group', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'group context',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
context_mode: 'group',
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks[0].context_mode).toBe('group');
});
it('accepts context_mode=isolated', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'isolated context',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks[0].context_mode).toBe('isolated');
});
it('defaults invalid context_mode to isolated', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'bad context',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
context_mode: 'bogus' as any,
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks[0].context_mode).toBe('isolated');
});
it('defaults missing context_mode to isolated', async () => {
await processTaskIpc(
{
type: 'schedule_task',
prompt: 'no context mode',
schedule_type: 'once',
schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us',
},
'whatsapp_main',
true,
deps,
);
const tasks = getAllTasks();
expect(tasks[0].context_mode).toBe('isolated');
});
});
// --- register_group success path ---
describe('register_group success', () => {
it('main group can register a new group', async () => {
await processTaskIpc(
{
type: 'register_group',
jid: 'new@g.us',
name: 'New Group',
folder: 'new-group',
trigger: '@Andy',
},
'whatsapp_main',
true,
deps,
);
// Verify group was registered in DB
const group = getRegisteredGroup('new@g.us');
expect(group).toBeDefined();
expect(group!.name).toBe('New Group');
expect(group!.folder).toBe('new-group');
expect(group!.trigger).toBe('@Andy');
});
it('register_group rejects request with missing fields', async () => {
await processTaskIpc(
{
type: 'register_group',
jid: 'partial@g.us',
name: 'Partial',
// missing folder and trigger
},
'whatsapp_main',
true,
deps,
);
expect(getRegisteredGroup('partial@g.us')).toBeUndefined();
});
});

View File

@@ -1,356 +0,0 @@
import fs from 'fs';
import path from 'path';
import { CronExpressionParser } from 'cron-parser';
import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } 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';
export interface IpcDeps {
sendMessage: (jid: string, text: string) => Promise<void>;
registeredGroups: () => Record<string, RegisteredGroup>;
registerGroup: (jid: string, group: RegisteredGroup) => void;
syncGroups: (force: boolean) => Promise<void>;
getAvailableGroups: () => AvailableGroup[];
writeGroupsSnapshot: (
groupFolder: string,
isMain: boolean,
availableGroups: AvailableGroup[],
registeredJids: Set<string>,
) => void;
onTasksChanged: () => void;
}
let ipcWatcherRunning = false;
export function startIpcWatcher(deps: IpcDeps): void {
if (ipcWatcherRunning) {
logger.debug('IPC watcher already running, skipping duplicate start');
return;
}
ipcWatcherRunning = true;
const ipcBaseDir = path.join(DATA_DIR, 'ipc');
fs.mkdirSync(ipcBaseDir, { recursive: true });
const processIpcFiles = async () => {
// Scan all group IPC directories (identity determined by directory)
let groupFolders: string[];
try {
groupFolders = fs.readdirSync(ipcBaseDir).filter((f) => {
const stat = fs.statSync(path.join(ipcBaseDir, f));
return stat.isDirectory() && f !== 'errors';
});
} catch (err) {
logger.error({ err }, 'Error reading IPC base directory');
setTimeout(processIpcFiles, IPC_POLL_INTERVAL);
return;
}
const registeredGroups = deps.registeredGroups();
// Build folder→isMain lookup from registered groups
const folderIsMain = new Map<string, boolean>();
for (const group of Object.values(registeredGroups)) {
if (group.isMain) folderIsMain.set(group.folder, true);
}
for (const sourceGroup of groupFolders) {
const isMain = folderIsMain.get(sourceGroup) === true;
const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
// Process messages from this group's IPC directory
try {
if (fs.existsSync(messagesDir)) {
const messageFiles = fs.readdirSync(messagesDir).filter((f) => f.endsWith('.json'));
for (const file of messageFiles) {
const filePath = path.join(messagesDir, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
if (data.type === 'message' && data.chatJid && data.text) {
// Authorization: verify this group can send to this chatJid
const targetGroup = registeredGroups[data.chatJid];
if (isMain || (targetGroup && targetGroup.folder === sourceGroup)) {
await deps.sendMessage(data.chatJid, data.text);
logger.info({ chatJid: data.chatJid, sourceGroup }, 'IPC message sent');
} else {
logger.warn({ chatJid: data.chatJid, sourceGroup }, 'Unauthorized IPC message attempt blocked');
}
}
fs.unlinkSync(filePath);
} catch (err) {
logger.error({ file, sourceGroup, err }, 'Error processing IPC message');
const errorDir = path.join(ipcBaseDir, 'errors');
fs.mkdirSync(errorDir, { recursive: true });
fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
}
}
}
} catch (err) {
logger.error({ err, sourceGroup }, 'Error reading IPC messages directory');
}
// Process tasks from this group's IPC directory
try {
if (fs.existsSync(tasksDir)) {
const taskFiles = fs.readdirSync(tasksDir).filter((f) => f.endsWith('.json'));
for (const file of taskFiles) {
const filePath = path.join(tasksDir, file);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
// Pass source group identity to processTaskIpc for authorization
await processTaskIpc(data, sourceGroup, isMain, deps);
fs.unlinkSync(filePath);
} catch (err) {
logger.error({ file, sourceGroup, err }, 'Error processing IPC task');
const errorDir = path.join(ipcBaseDir, 'errors');
fs.mkdirSync(errorDir, { recursive: true });
fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
}
}
}
} catch (err) {
logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory');
}
}
setTimeout(processIpcFiles, IPC_POLL_INTERVAL);
};
processIpcFiles();
logger.info('IPC watcher started (per-group namespaces)');
}
export async function processTaskIpc(
data: {
type: string;
taskId?: string;
prompt?: string;
schedule_type?: string;
schedule_value?: string;
context_mode?: string;
script?: string;
groupFolder?: string;
chatJid?: string;
targetJid?: string;
// For register_group
jid?: string;
name?: string;
folder?: string;
trigger?: string;
requiresTrigger?: boolean;
containerConfig?: RegisteredGroup['containerConfig'];
},
sourceGroup: string, // Verified identity from IPC directory
isMain: boolean, // Verified from directory path
deps: IpcDeps,
): Promise<void> {
const registeredGroups = deps.registeredGroups();
switch (data.type) {
case 'schedule_task':
if (data.prompt && data.schedule_type && data.schedule_value && data.targetJid) {
// Resolve the target group from JID
const targetJid = data.targetJid as string;
const targetGroupEntry = registeredGroups[targetJid];
if (!targetGroupEntry) {
logger.warn({ targetJid }, 'Cannot schedule task: target group not registered');
break;
}
const targetFolder = targetGroupEntry.folder;
// Authorization: non-main groups can only schedule for themselves
if (!isMain && targetFolder !== sourceGroup) {
logger.warn({ sourceGroup, targetFolder }, 'Unauthorized schedule_task attempt blocked');
break;
}
const scheduleType = data.schedule_type as 'cron' | 'interval' | 'once';
let nextRun: string | null = null;
if (scheduleType === 'cron') {
try {
const interval = CronExpressionParser.parse(data.schedule_value, {
tz: TIMEZONE,
});
nextRun = interval.next().toISOString();
} catch {
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid cron expression');
break;
}
} else if (scheduleType === 'interval') {
const ms = parseInt(data.schedule_value, 10);
if (isNaN(ms) || ms <= 0) {
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid interval');
break;
}
nextRun = new Date(Date.now() + ms).toISOString();
} else if (scheduleType === 'once') {
const date = new Date(data.schedule_value);
if (isNaN(date.getTime())) {
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid timestamp');
break;
}
nextRun = date.toISOString();
}
const taskId = data.taskId || `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const contextMode =
data.context_mode === 'group' || data.context_mode === 'isolated' ? data.context_mode : 'isolated';
createTask({
id: taskId,
group_folder: targetFolder,
chat_jid: targetJid,
prompt: data.prompt,
script: data.script || null,
schedule_type: scheduleType,
schedule_value: data.schedule_value,
context_mode: contextMode,
next_run: nextRun,
status: 'active',
created_at: new Date().toISOString(),
});
logger.info({ taskId, sourceGroup, targetFolder, contextMode }, 'Task created via IPC');
deps.onTasksChanged();
}
break;
case 'pause_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
updateTask(data.taskId, { status: 'paused' });
logger.info({ taskId: data.taskId, sourceGroup }, 'Task paused via IPC');
deps.onTasksChanged();
} else {
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt');
}
}
break;
case 'resume_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
updateTask(data.taskId, { status: 'active' });
logger.info({ taskId: data.taskId, sourceGroup }, 'Task resumed via IPC');
deps.onTasksChanged();
} else {
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt');
}
}
break;
case 'cancel_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (task && (isMain || task.group_folder === sourceGroup)) {
deleteTask(data.taskId);
logger.info({ taskId: data.taskId, sourceGroup }, 'Task cancelled via IPC');
deps.onTasksChanged();
} else {
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt');
}
}
break;
case 'update_task':
if (data.taskId) {
const task = getTaskById(data.taskId);
if (!task) {
logger.warn({ taskId: data.taskId, sourceGroup }, 'Task not found for update');
break;
}
if (!isMain && task.group_folder !== sourceGroup) {
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task update attempt');
break;
}
const updates: Parameters<typeof updateTask>[1] = {};
if (data.prompt !== undefined) updates.prompt = data.prompt;
if (data.script !== undefined) updates.script = data.script || null;
if (data.schedule_type !== undefined)
updates.schedule_type = data.schedule_type as 'cron' | 'interval' | 'once';
if (data.schedule_value !== undefined) updates.schedule_value = data.schedule_value;
// Recompute next_run if schedule changed
if (data.schedule_type || data.schedule_value) {
const updatedTask = {
...task,
...updates,
};
if (updatedTask.schedule_type === 'cron') {
try {
const interval = CronExpressionParser.parse(updatedTask.schedule_value, { tz: TIMEZONE });
updates.next_run = interval.next().toISOString();
} catch {
logger.warn({ taskId: data.taskId, value: updatedTask.schedule_value }, 'Invalid cron in task update');
break;
}
} else if (updatedTask.schedule_type === 'interval') {
const ms = parseInt(updatedTask.schedule_value, 10);
if (!isNaN(ms) && ms > 0) {
updates.next_run = new Date(Date.now() + ms).toISOString();
}
}
}
updateTask(data.taskId, updates);
logger.info({ taskId: data.taskId, sourceGroup, updates }, 'Task updated via IPC');
deps.onTasksChanged();
}
break;
case 'refresh_groups':
// Only main group can request a refresh
if (isMain) {
logger.info({ sourceGroup }, 'Group metadata refresh requested via IPC');
await deps.syncGroups(true);
// Write updated snapshot immediately
const availableGroups = deps.getAvailableGroups();
deps.writeGroupsSnapshot(sourceGroup, true, availableGroups, new Set(Object.keys(registeredGroups)));
} else {
logger.warn({ sourceGroup }, 'Unauthorized refresh_groups attempt blocked');
}
break;
case 'register_group':
// Only main group can register new groups
if (!isMain) {
logger.warn({ sourceGroup }, 'Unauthorized register_group attempt blocked');
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;
}
// Defense in depth: agent cannot set isMain via IPC.
// Preserve isMain from the existing registration so IPC config
// updates (e.g. adding additionalMounts) don't strip the flag.
const existingGroup = registeredGroups[data.jid];
deps.registerGroup(data.jid, {
name: data.name,
folder: data.folder,
trigger: data.trigger,
added_at: new Date().toISOString(),
containerConfig: data.containerConfig,
requiresTrigger: data.requiresTrigger,
isMain: existingGroup?.isMain,
});
} else {
logger.warn({ data }, 'Invalid register_group request - missing required fields');
}
break;
default:
logger.warn({ type: data.type }, 'Unknown IPC task type');
}
}

View File

@@ -1,69 +0,0 @@
const LEVELS = { debug: 20, info: 30, warn: 40, error: 50, fatal: 60 } as const;
type Level = keyof typeof LEVELS;
const COLORS: Record<Level, string> = {
debug: '\x1b[34m',
info: '\x1b[32m',
warn: '\x1b[33m',
error: '\x1b[31m',
fatal: '\x1b[41m\x1b[37m',
};
const KEY_COLOR = '\x1b[35m';
const MSG_COLOR = '\x1b[36m';
const RESET = '\x1b[39m';
const FULL_RESET = '\x1b[0m';
const threshold = LEVELS[(process.env.LOG_LEVEL as Level) || 'info'] ?? LEVELS.info;
function formatErr(err: unknown): string {
if (err instanceof Error) {
return `{\n "type": "${err.constructor.name}",\n "message": "${err.message}",\n "stack":\n ${err.stack}\n }`;
}
return JSON.stringify(err);
}
function formatData(data: Record<string, unknown>): string {
let out = '';
for (const [k, v] of Object.entries(data)) {
if (k === 'err') {
out += `\n ${KEY_COLOR}err${RESET}: ${formatErr(v)}`;
} else {
out += `\n ${KEY_COLOR}${k}${RESET}: ${JSON.stringify(v)}`;
}
}
return out;
}
function ts(): string {
const d = new Date();
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
function log(level: Level, dataOrMsg: Record<string, unknown> | string, msg?: string): void {
if (LEVELS[level] < threshold) return;
const tag = `${COLORS[level]}${level.toUpperCase()}${level === 'fatal' ? FULL_RESET : RESET}`;
const stream = LEVELS[level] >= LEVELS.warn ? process.stderr : process.stdout;
if (typeof dataOrMsg === 'string') {
stream.write(`[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${dataOrMsg}${RESET}\n`);
} else {
stream.write(`[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${msg}${RESET}${formatData(dataOrMsg)}\n`);
}
}
export const logger = {
debug: (dataOrMsg: Record<string, unknown> | string, msg?: string) => log('debug', dataOrMsg, msg),
info: (dataOrMsg: Record<string, unknown> | string, msg?: string) => log('info', dataOrMsg, msg),
warn: (dataOrMsg: Record<string, unknown> | string, msg?: string) => log('warn', dataOrMsg, msg),
error: (dataOrMsg: Record<string, unknown> | string, msg?: string) => log('error', dataOrMsg, msg),
fatal: (dataOrMsg: Record<string, unknown> | string, msg?: string) => log('fatal', dataOrMsg, msg),
};
// Route uncaught errors through logger so they get timestamps in stderr
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'Uncaught exception');
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
logger.error({ err: reason }, 'Unhandled rejection');
});

View File

@@ -1,405 +0,0 @@
/**
* 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 { logger } from './logger.js';
import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js';
// 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.
logger.warn(
{ path: MOUNT_ALLOWLIST_PATH },
'Mount allowlist not found - additional mounts will be BLOCKED. ' +
'Create the file to enable additional mounts.',
);
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');
}
if (typeof allowlist.nonMainReadOnly !== 'boolean') {
throw new Error('nonMainReadOnly must be a boolean');
}
// Merge with default blocked patterns
const mergedBlockedPatterns = [...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns])];
allowlist.blockedPatterns = mergedBlockedPatterns;
cachedAllowlist = allowlist;
logger.info(
{
path: MOUNT_ALLOWLIST_PATH,
allowedRoots: allowlist.allowedRoots.length,
blockedPatterns: allowlist.blockedPatterns.length,
},
'Mount allowlist loaded successfully',
);
return cachedAllowlist;
} catch (err) {
allowlistLoadError = err instanceof Error ? err.message : String(err);
logger.error(
{
path: MOUNT_ALLOWLIST_PATH,
error: allowlistLoadError,
},
'Failed to load mount allowlist - additional mounts will be BLOCKED',
);
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, isMain: boolean): 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
const requestedReadWrite = mount.readonly === false;
let effectiveReadonly = true; // Default to readonly
if (requestedReadWrite) {
if (!isMain && allowlist.nonMainReadOnly) {
// Non-main groups forced to read-only
effectiveReadonly = true;
logger.info(
{
mount: mount.hostPath,
},
'Mount forced to read-only for non-main group',
);
} else if (!allowedRoot.allowReadWrite) {
// Root doesn't allow read-write
effectiveReadonly = true;
logger.info(
{
mount: mount.hostPath,
root: allowedRoot.path,
},
'Mount forced to read-only - root does not allow read-write',
);
} else {
// Read-write allowed
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,
isMain: boolean,
): 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, isMain);
if (result.allowed) {
validatedMounts.push({
hostPath: result.realHostPath!,
containerPath: `/workspace/extra/${result.resolvedContainerPath}`,
readonly: result.effectiveReadonly!,
});
logger.debug(
{
group: groupName,
hostPath: result.realHostPath,
containerPath: result.resolvedContainerPath,
readonly: result.effectiveReadonly,
reason: result.reason,
},
'Mount validated successfully',
);
} else {
logger.warn(
{
group: groupName,
requestedPath: mount.hostPath,
containerPath: mount.containerPath,
reason: result.reason,
},
'Additional mount REJECTED',
);
}
}
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',
],
nonMainReadOnly: true,
};
return JSON.stringify(template, null, 2);
}

View File

@@ -1,379 +0,0 @@
import fs from 'fs';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Mock config before importing the module under test
vi.mock('./config.js', () => ({
DATA_DIR: '/tmp/nanoclaw-rc-test',
}));
// Mock child_process
const spawnMock = vi.fn();
vi.mock('child_process', () => ({
spawn: (...args: any[]) => spawnMock(...args),
}));
import {
startRemoteControl,
stopRemoteControl,
restoreRemoteControl,
getActiveSession,
_resetForTesting,
_getStateFilePath,
} from './remote-control.js';
// --- Helpers ---
function createMockProcess(pid = 12345) {
return {
pid,
unref: vi.fn(),
kill: vi.fn(),
stdin: { write: vi.fn(), end: vi.fn() },
};
}
describe('remote-control', () => {
const STATE_FILE = _getStateFilePath();
let readFileSyncSpy: ReturnType<typeof vi.spyOn>;
let writeFileSyncSpy: ReturnType<typeof vi.spyOn>;
let unlinkSyncSpy: ReturnType<typeof vi.spyOn>;
let _mkdirSyncSpy: ReturnType<typeof vi.spyOn>;
let openSyncSpy: ReturnType<typeof vi.spyOn>;
let closeSyncSpy: ReturnType<typeof vi.spyOn>;
// Track what readFileSync should return for the stdout file
let stdoutFileContent: string;
beforeEach(() => {
_resetForTesting();
spawnMock.mockReset();
stdoutFileContent = '';
// Default fs mocks
_mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any);
writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any);
closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {});
// readFileSync: return stdoutFileContent for the stdout file, state file, etc.
readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((p: string) => {
if (p.endsWith('remote-control.stdout')) return stdoutFileContent;
if (p.endsWith('remote-control.json')) {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
}
return '';
}) as any);
});
afterEach(() => {
_resetForTesting();
vi.restoreAllMocks();
});
// --- startRemoteControl ---
describe('startRemoteControl', () => {
it('spawns claude remote-control and returns the URL', async () => {
const proc = createMockProcess();
spawnMock.mockReturnValue(proc);
// Simulate URL appearing in stdout file on first poll
stdoutFileContent = 'Session URL: https://claude.ai/code?bridge=env_abc123\n';
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
const result = await startRemoteControl('user1', 'tg:123', '/project');
expect(result).toEqual({
ok: true,
url: 'https://claude.ai/code?bridge=env_abc123',
});
expect(spawnMock).toHaveBeenCalledWith(
'claude',
['remote-control', '--name', 'NanoClaw Remote'],
expect.objectContaining({ cwd: '/project', detached: true }),
);
expect(proc.unref).toHaveBeenCalled();
});
it('uses file descriptors for stdout/stderr (not pipes)', async () => {
const proc = createMockProcess();
spawnMock.mockReturnValue(proc);
stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n';
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
await startRemoteControl('user1', 'tg:123', '/project');
const spawnCall = spawnMock.mock.calls[0];
const options = spawnCall[2];
// stdio[0] is 'pipe' so we can write 'y' to accept the prompt
expect(options.stdio[0]).toBe('pipe');
expect(typeof options.stdio[1]).toBe('number');
expect(typeof options.stdio[2]).toBe('number');
});
it('closes file descriptors in parent after spawn', async () => {
const proc = createMockProcess();
spawnMock.mockReturnValue(proc);
stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n';
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
await startRemoteControl('user1', 'tg:123', '/project');
// Two openSync calls (stdout + stderr), two closeSync calls
expect(openSyncSpy).toHaveBeenCalledTimes(2);
expect(closeSyncSpy).toHaveBeenCalledTimes(2);
});
it('saves state to disk after capturing URL', async () => {
const proc = createMockProcess(99999);
spawnMock.mockReturnValue(proc);
stdoutFileContent = 'https://claude.ai/code?bridge=env_save\n';
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
await startRemoteControl('user1', 'tg:123', '/project');
expect(writeFileSyncSpy).toHaveBeenCalledWith(STATE_FILE, expect.stringContaining('"pid":99999'));
});
it('returns existing URL if session is already active', async () => {
const proc = createMockProcess();
spawnMock.mockReturnValue(proc);
stdoutFileContent = 'https://claude.ai/code?bridge=env_existing\n';
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
await startRemoteControl('user1', 'tg:123', '/project');
// Second call should return existing URL without spawning
const result = await startRemoteControl('user2', 'tg:456', '/project');
expect(result).toEqual({
ok: true,
url: 'https://claude.ai/code?bridge=env_existing',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('starts new session if existing process is dead', async () => {
const proc1 = createMockProcess(11111);
const proc2 = createMockProcess(22222);
spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2);
// First start: process alive, URL found
const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n';
await startRemoteControl('user1', 'tg:123', '/project');
// Old process (11111) is dead, new process (22222) is alive
killSpy.mockImplementation(((pid: number, sig: any) => {
if (pid === 11111 && (sig === 0 || sig === undefined)) {
throw new Error('ESRCH');
}
return true;
}) as any);
stdoutFileContent = 'https://claude.ai/code?bridge=env_second\n';
const result = await startRemoteControl('user1', 'tg:123', '/project');
expect(result).toEqual({
ok: true,
url: 'https://claude.ai/code?bridge=env_second',
});
expect(spawnMock).toHaveBeenCalledTimes(2);
});
it('returns error if process exits before URL', async () => {
const proc = createMockProcess(33333);
spawnMock.mockReturnValue(proc);
stdoutFileContent = '';
// Process is dead (poll will detect this)
vi.spyOn(process, 'kill').mockImplementation((() => {
throw new Error('ESRCH');
}) as any);
const result = await startRemoteControl('user1', 'tg:123', '/project');
expect(result).toEqual({
ok: false,
error: 'Process exited before producing URL',
});
});
it('times out if URL never appears', async () => {
vi.useFakeTimers();
const proc = createMockProcess(44444);
spawnMock.mockReturnValue(proc);
stdoutFileContent = 'no url here';
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
const promise = startRemoteControl('user1', 'tg:123', '/project');
// Advance past URL_TIMEOUT_MS (30s), with enough steps for polls
for (let i = 0; i < 160; i++) {
await vi.advanceTimersByTimeAsync(200);
}
const result = await promise;
expect(result).toEqual({
ok: false,
error: 'Timed out waiting for Remote Control URL',
});
vi.useRealTimers();
});
it('returns error if spawn throws', async () => {
spawnMock.mockImplementation(() => {
throw new Error('ENOENT');
});
const result = await startRemoteControl('user1', 'tg:123', '/project');
expect(result).toEqual({
ok: false,
error: 'Failed to start: ENOENT',
});
});
});
// --- stopRemoteControl ---
describe('stopRemoteControl', () => {
it('kills the process and clears state', async () => {
const proc = createMockProcess(55555);
spawnMock.mockReturnValue(proc);
stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n';
const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
await startRemoteControl('user1', 'tg:123', '/project');
const result = stopRemoteControl();
expect(result).toEqual({ ok: true });
expect(killSpy).toHaveBeenCalledWith(55555, 'SIGTERM');
expect(unlinkSyncSpy).toHaveBeenCalledWith(STATE_FILE);
expect(getActiveSession()).toBeNull();
});
it('returns error when no session is active', () => {
const result = stopRemoteControl();
expect(result).toEqual({
ok: false,
error: 'No active Remote Control session',
});
});
});
// --- restoreRemoteControl ---
describe('restoreRemoteControl', () => {
it('restores session if state file exists and process is alive', () => {
const session = {
pid: 77777,
url: 'https://claude.ai/code?bridge=env_restored',
startedBy: 'user1',
startedInChat: 'tg:123',
startedAt: '2026-01-01T00:00:00.000Z',
};
readFileSyncSpy.mockImplementation(((p: string) => {
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
return '';
}) as any);
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
restoreRemoteControl();
const active = getActiveSession();
expect(active).not.toBeNull();
expect(active!.pid).toBe(77777);
expect(active!.url).toBe('https://claude.ai/code?bridge=env_restored');
});
it('clears state if process is dead', () => {
const session = {
pid: 88888,
url: 'https://claude.ai/code?bridge=env_dead',
startedBy: 'user1',
startedInChat: 'tg:123',
startedAt: '2026-01-01T00:00:00.000Z',
};
readFileSyncSpy.mockImplementation(((p: string) => {
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
return '';
}) as any);
vi.spyOn(process, 'kill').mockImplementation((() => {
throw new Error('ESRCH');
}) as any);
restoreRemoteControl();
expect(getActiveSession()).toBeNull();
expect(unlinkSyncSpy).toHaveBeenCalled();
});
it('does nothing if no state file exists', () => {
// readFileSyncSpy default throws ENOENT for .json
restoreRemoteControl();
expect(getActiveSession()).toBeNull();
});
it('clears state on corrupted JSON', () => {
readFileSyncSpy.mockImplementation(((p: string) => {
if (p.endsWith('remote-control.json')) return 'not json{{{';
return '';
}) as any);
restoreRemoteControl();
expect(getActiveSession()).toBeNull();
expect(unlinkSyncSpy).toHaveBeenCalled();
});
// ** This is the key integration test: restore → stop must work **
it('stopRemoteControl works after restoreRemoteControl', () => {
const session = {
pid: 77777,
url: 'https://claude.ai/code?bridge=env_restored',
startedBy: 'user1',
startedInChat: 'tg:123',
startedAt: '2026-01-01T00:00:00.000Z',
};
readFileSyncSpy.mockImplementation(((p: string) => {
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
return '';
}) as any);
const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
restoreRemoteControl();
expect(getActiveSession()).not.toBeNull();
const result = stopRemoteControl();
expect(result).toEqual({ ok: true });
expect(killSpy).toHaveBeenCalledWith(77777, 'SIGTERM');
expect(unlinkSyncSpy).toHaveBeenCalled();
expect(getActiveSession()).toBeNull();
});
it('startRemoteControl returns restored URL without spawning', () => {
const session = {
pid: 77777,
url: 'https://claude.ai/code?bridge=env_restored',
startedBy: 'user1',
startedInChat: 'tg:123',
startedAt: '2026-01-01T00:00:00.000Z',
};
readFileSyncSpy.mockImplementation(((p: string) => {
if (p.endsWith('remote-control.json')) return JSON.stringify(session);
return '';
}) as any);
vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
restoreRemoteControl();
return startRemoteControl('user2', 'tg:456', '/project').then((result) => {
expect(result).toEqual({
ok: true,
url: 'https://claude.ai/code?bridge=env_restored',
});
expect(spawnMock).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -1,218 +0,0 @@
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { DATA_DIR } from './config.js';
import { logger } from './logger.js';
interface RemoteControlSession {
pid: number;
url: string;
startedBy: string;
startedInChat: string;
startedAt: string;
}
let activeSession: RemoteControlSession | null = null;
const URL_REGEX = /https:\/\/claude\.ai\/code\S+/;
const URL_TIMEOUT_MS = 30_000;
const URL_POLL_MS = 200;
const STATE_FILE = path.join(DATA_DIR, 'remote-control.json');
const STDOUT_FILE = path.join(DATA_DIR, 'remote-control.stdout');
const STDERR_FILE = path.join(DATA_DIR, 'remote-control.stderr');
function saveState(session: RemoteControlSession): void {
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
fs.writeFileSync(STATE_FILE, JSON.stringify(session));
}
function clearState(): void {
try {
fs.unlinkSync(STATE_FILE);
} catch {
// ignore
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Restore session from disk on startup.
* If the process is still alive, adopt it. Otherwise, clean up.
*/
export function restoreRemoteControl(): void {
let data: string;
try {
data = fs.readFileSync(STATE_FILE, 'utf-8');
} catch {
return;
}
try {
const session: RemoteControlSession = JSON.parse(data);
if (session.pid && isProcessAlive(session.pid)) {
activeSession = session;
logger.info({ pid: session.pid, url: session.url }, 'Restored Remote Control session from previous run');
} else {
clearState();
}
} catch {
clearState();
}
}
export function getActiveSession(): RemoteControlSession | null {
return activeSession;
}
/** @internal — exported for testing only */
export function _resetForTesting(): void {
activeSession = null;
}
/** @internal — exported for testing only */
export function _getStateFilePath(): string {
return STATE_FILE;
}
export async function startRemoteControl(
sender: string,
chatJid: string,
cwd: string,
): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
if (activeSession) {
// Verify the process is still alive
if (isProcessAlive(activeSession.pid)) {
return { ok: true, url: activeSession.url };
}
// Process died — clean up and start a new one
activeSession = null;
clearState();
}
// Redirect stdout/stderr to files so the process has no pipes to the parent.
// This prevents SIGPIPE when NanoClaw restarts.
fs.mkdirSync(DATA_DIR, { recursive: true });
const stdoutFd = fs.openSync(STDOUT_FILE, 'w');
const stderrFd = fs.openSync(STDERR_FILE, 'w');
let proc;
try {
proc = spawn('claude', ['remote-control', '--name', 'NanoClaw Remote'], {
cwd,
stdio: ['pipe', stdoutFd, stderrFd],
detached: true,
});
} catch (err: any) {
fs.closeSync(stdoutFd);
fs.closeSync(stderrFd);
return { ok: false, error: `Failed to start: ${err.message}` };
}
// Auto-accept the "Enable Remote Control?" prompt
if (proc.stdin) {
proc.stdin.write('y\n');
proc.stdin.end();
}
// Close FDs in the parent — the child inherited copies
fs.closeSync(stdoutFd);
fs.closeSync(stderrFd);
// Fully detach from parent
proc.unref();
const pid = proc.pid;
if (!pid) {
return { ok: false, error: 'Failed to get process PID' };
}
// Poll the stdout file for the URL
return new Promise((resolve) => {
const startTime = Date.now();
const poll = () => {
// Check if process died
if (!isProcessAlive(pid)) {
resolve({ ok: false, error: 'Process exited before producing URL' });
return;
}
// Check for URL in stdout file
let content = '';
try {
content = fs.readFileSync(STDOUT_FILE, 'utf-8');
} catch {
// File might not have content yet
}
const match = content.match(URL_REGEX);
if (match) {
const session: RemoteControlSession = {
pid,
url: match[0],
startedBy: sender,
startedInChat: chatJid,
startedAt: new Date().toISOString(),
};
activeSession = session;
saveState(session);
logger.info({ url: match[0], pid, sender, chatJid }, 'Remote Control session started');
resolve({ ok: true, url: match[0] });
return;
}
// Timeout check
if (Date.now() - startTime >= URL_TIMEOUT_MS) {
try {
process.kill(-pid, 'SIGTERM');
} catch {
try {
process.kill(pid, 'SIGTERM');
} catch {
// already dead
}
}
resolve({
ok: false,
error: 'Timed out waiting for Remote Control URL',
});
return;
}
setTimeout(poll, URL_POLL_MS);
};
poll();
});
}
export function stopRemoteControl():
| {
ok: true;
}
| { ok: false; error: string } {
if (!activeSession) {
return { ok: false, error: 'No active Remote Control session' };
}
const { pid } = activeSession;
try {
process.kill(pid, 'SIGTERM');
} catch {
// already dead
}
activeSession = null;
clearState();
logger.info({ pid }, 'Remote Control session stopped');
return { ok: true };
}

View File

@@ -1,43 +0,0 @@
import { Channel, NewMessage } from './types.js';
import { formatLocalTime } from './timezone.js';
export function escapeXml(s: string): string {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export function formatMessages(messages: NewMessage[], timezone: string): string {
const lines = messages.map((m) => {
const displayTime = formatLocalTime(m.timestamp, timezone);
const replyAttr = m.reply_to_message_id ? ` reply_to="${escapeXml(m.reply_to_message_id)}"` : '';
const replySnippet =
m.reply_to_message_content && m.reply_to_sender_name
? `\n <quoted_message from="${escapeXml(m.reply_to_sender_name)}">${escapeXml(m.reply_to_message_content)}</quoted_message>`
: '';
return `<message sender="${escapeXml(m.sender_name)}" time="${escapeXml(displayTime)}"${replyAttr}>${replySnippet}${escapeXml(m.content)}</message>`;
});
const header = `<context timezone="${escapeXml(timezone)}" />\n`;
return `${header}<messages>\n${lines.join('\n')}\n</messages>`;
}
export function stripInternalTags(text: string): string {
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}
export function formatOutbound(rawText: string): string {
const text = stripInternalTags(rawText);
if (!text) return '';
return text;
}
export function routeOutbound(channels: Channel[], jid: string, text: string): Promise<void> {
const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected());
if (!channel) throw new Error(`No channel for JID: ${jid}`);
return channel.sendMessage(jid, text);
}
export function findChannel(channels: Channel[], jid: string): Channel | undefined {
return channels.find((c) => c.ownsJid(jid));
}

View File

@@ -1,100 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { _initTestDatabase, storeChatMetadata } from './db.js';
import { getAvailableGroups, _setRegisteredGroups } from './index.js';
beforeEach(() => {
_initTestDatabase();
_setRegisteredGroups({});
});
// --- JID ownership patterns ---
describe('JID ownership patterns', () => {
// These test the patterns that will become ownsJid() on the Channel interface
it('WhatsApp group JID: ends with @g.us', () => {
const jid = '12345678@g.us';
expect(jid.endsWith('@g.us')).toBe(true);
});
it('WhatsApp DM JID: ends with @s.whatsapp.net', () => {
const jid = '12345678@s.whatsapp.net';
expect(jid.endsWith('@s.whatsapp.net')).toBe(true);
});
});
// --- getAvailableGroups ---
describe('getAvailableGroups', () => {
it('returns only groups, excludes DMs', () => {
storeChatMetadata('group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true);
storeChatMetadata('user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false);
storeChatMetadata('group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(2);
expect(groups.map((g) => g.jid)).toContain('group1@g.us');
expect(groups.map((g) => g.jid)).toContain('group2@g.us');
expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net');
});
it('excludes __group_sync__ sentinel', () => {
storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z');
storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('group@g.us');
});
it('marks registered groups correctly', () => {
storeChatMetadata('reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true);
storeChatMetadata('unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true);
_setRegisteredGroups({
'reg@g.us': {
name: 'Registered',
folder: 'registered',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
},
});
const groups = getAvailableGroups();
const reg = groups.find((g) => g.jid === 'reg@g.us');
const unreg = groups.find((g) => g.jid === 'unreg@g.us');
expect(reg?.isRegistered).toBe(true);
expect(unreg?.isRegistered).toBe(false);
});
it('returns groups ordered by most recent activity', () => {
storeChatMetadata('old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true);
storeChatMetadata('new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true);
storeChatMetadata('mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups[0].jid).toBe('new@g.us');
expect(groups[1].jid).toBe('mid@g.us');
expect(groups[2].jid).toBe('old@g.us');
});
it('excludes non-group chats regardless of JID format', () => {
// Unknown JID format stored without is_group should not appear
storeChatMetadata('unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown');
// Explicitly non-group with unusual JID
storeChatMetadata('custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false);
// A real group for contrast
storeChatMetadata('group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true);
const groups = getAvailableGroups();
expect(groups).toHaveLength(1);
expect(groups[0].jid).toBe('group@g.us');
});
it('returns empty array when no chats exist', () => {
const groups = getAvailableGroups();
expect(groups).toHaveLength(0);
});
});

View File

@@ -1,216 +0,0 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
isSenderAllowed,
isTriggerAllowed,
loadSenderAllowlist,
SenderAllowlistConfig,
shouldDropMessage,
} from './sender-allowlist.js';
let tmpDir: string;
function cfgPath(name = 'sender-allowlist.json'): string {
return path.join(tmpDir, name);
}
function writeConfig(config: unknown, name?: string): string {
const p = cfgPath(name);
fs.writeFileSync(p, JSON.stringify(config));
return p;
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('loadSenderAllowlist', () => {
it('returns allow-all defaults when file is missing', () => {
const cfg = loadSenderAllowlist(cfgPath());
expect(cfg.default.allow).toBe('*');
expect(cfg.default.mode).toBe('trigger');
expect(cfg.logDenied).toBe(true);
});
it('loads allow=* config', () => {
const p = writeConfig({
default: { allow: '*', mode: 'trigger' },
chats: {},
logDenied: false,
});
const cfg = loadSenderAllowlist(p);
expect(cfg.default.allow).toBe('*');
expect(cfg.logDenied).toBe(false);
});
it('loads allow=[] (deny all)', () => {
const p = writeConfig({
default: { allow: [], mode: 'trigger' },
chats: {},
});
const cfg = loadSenderAllowlist(p);
expect(cfg.default.allow).toEqual([]);
});
it('loads allow=[list]', () => {
const p = writeConfig({
default: { allow: ['alice', 'bob'], mode: 'drop' },
chats: {},
});
const cfg = loadSenderAllowlist(p);
expect(cfg.default.allow).toEqual(['alice', 'bob']);
expect(cfg.default.mode).toBe('drop');
});
it('per-chat override beats default', () => {
const p = writeConfig({
default: { allow: '*', mode: 'trigger' },
chats: { 'group-a': { allow: ['alice'], mode: 'drop' } },
});
const cfg = loadSenderAllowlist(p);
expect(cfg.chats['group-a'].allow).toEqual(['alice']);
expect(cfg.chats['group-a'].mode).toBe('drop');
});
it('returns allow-all on invalid JSON', () => {
const p = cfgPath();
fs.writeFileSync(p, '{ not valid json }}}');
const cfg = loadSenderAllowlist(p);
expect(cfg.default.allow).toBe('*');
});
it('returns allow-all on invalid schema', () => {
const p = writeConfig({ default: { oops: true } });
const cfg = loadSenderAllowlist(p);
expect(cfg.default.allow).toBe('*');
});
it('rejects non-string allow array items', () => {
const p = writeConfig({
default: { allow: [123, null, true], mode: 'trigger' },
chats: {},
});
const cfg = loadSenderAllowlist(p);
expect(cfg.default.allow).toBe('*'); // falls back to default
});
it('skips invalid per-chat entries', () => {
const p = writeConfig({
default: { allow: '*', mode: 'trigger' },
chats: {
good: { allow: ['alice'], mode: 'trigger' },
bad: { allow: 123 },
},
});
const cfg = loadSenderAllowlist(p);
expect(cfg.chats['good']).toBeDefined();
expect(cfg.chats['bad']).toBeUndefined();
});
});
describe('isSenderAllowed', () => {
it('allow=* allows any sender', () => {
const cfg: SenderAllowlistConfig = {
default: { allow: '*', mode: 'trigger' },
chats: {},
logDenied: true,
};
expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(true);
});
it('allow=[] denies any sender', () => {
const cfg: SenderAllowlistConfig = {
default: { allow: [], mode: 'trigger' },
chats: {},
logDenied: true,
};
expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(false);
});
it('allow=[list] allows exact match only', () => {
const cfg: SenderAllowlistConfig = {
default: { allow: ['alice', 'bob'], mode: 'trigger' },
chats: {},
logDenied: true,
};
expect(isSenderAllowed('g1', 'alice', cfg)).toBe(true);
expect(isSenderAllowed('g1', 'eve', cfg)).toBe(false);
});
it('uses per-chat entry over default', () => {
const cfg: SenderAllowlistConfig = {
default: { allow: '*', mode: 'trigger' },
chats: { g1: { allow: ['alice'], mode: 'trigger' } },
logDenied: true,
};
expect(isSenderAllowed('g1', 'bob', cfg)).toBe(false);
expect(isSenderAllowed('g2', 'bob', cfg)).toBe(true);
});
});
describe('shouldDropMessage', () => {
it('returns false for trigger mode', () => {
const cfg: SenderAllowlistConfig = {
default: { allow: '*', mode: 'trigger' },
chats: {},
logDenied: true,
};
expect(shouldDropMessage('g1', cfg)).toBe(false);
});
it('returns true for drop mode', () => {
const cfg: SenderAllowlistConfig = {
default: { allow: '*', mode: 'drop' },
chats: {},
logDenied: true,
};
expect(shouldDropMessage('g1', cfg)).toBe(true);
});
it('per-chat mode override', () => {
const cfg: SenderAllowlistConfig = {
default: { allow: '*', mode: 'trigger' },
chats: { g1: { allow: '*', mode: 'drop' } },
logDenied: true,
};
expect(shouldDropMessage('g1', cfg)).toBe(true);
expect(shouldDropMessage('g2', cfg)).toBe(false);
});
});
describe('isTriggerAllowed', () => {
it('allows trigger for allowed sender', () => {
const cfg: SenderAllowlistConfig = {
default: { allow: ['alice'], mode: 'trigger' },
chats: {},
logDenied: false,
};
expect(isTriggerAllowed('g1', 'alice', cfg)).toBe(true);
});
it('denies trigger for disallowed sender', () => {
const cfg: SenderAllowlistConfig = {
default: { allow: ['alice'], mode: 'trigger' },
chats: {},
logDenied: false,
};
expect(isTriggerAllowed('g1', 'eve', cfg)).toBe(false);
});
it('logs when logDenied is true', () => {
const cfg: SenderAllowlistConfig = {
default: { allow: ['alice'], mode: 'trigger' },
chats: {},
logDenied: true,
};
isTriggerAllowed('g1', 'eve', cfg);
// Logger.debug is called — we just verify no crash; logger is a real pino instance
});
});

View File

@@ -1,96 +0,0 @@
import fs from 'fs';
import { SENDER_ALLOWLIST_PATH } from './config.js';
import { logger } from './logger.js';
export interface ChatAllowlistEntry {
allow: '*' | string[];
mode: 'trigger' | 'drop';
}
export interface SenderAllowlistConfig {
default: ChatAllowlistEntry;
chats: Record<string, ChatAllowlistEntry>;
logDenied: boolean;
}
const DEFAULT_CONFIG: SenderAllowlistConfig = {
default: { allow: '*', mode: 'trigger' },
chats: {},
logDenied: true,
};
function isValidEntry(entry: unknown): entry is ChatAllowlistEntry {
if (!entry || typeof entry !== 'object') return false;
const e = entry as Record<string, unknown>;
const validAllow = e.allow === '*' || (Array.isArray(e.allow) && e.allow.every((v) => typeof v === 'string'));
const validMode = e.mode === 'trigger' || e.mode === 'drop';
return validAllow && validMode;
}
export function loadSenderAllowlist(pathOverride?: string): SenderAllowlistConfig {
const filePath = pathOverride ?? SENDER_ALLOWLIST_PATH;
let raw: string;
try {
raw = fs.readFileSync(filePath, 'utf-8');
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return DEFAULT_CONFIG;
logger.warn({ err, path: filePath }, 'sender-allowlist: cannot read config');
return DEFAULT_CONFIG;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
logger.warn({ path: filePath }, 'sender-allowlist: invalid JSON');
return DEFAULT_CONFIG;
}
const obj = parsed as Record<string, unknown>;
if (!isValidEntry(obj.default)) {
logger.warn({ path: filePath }, 'sender-allowlist: invalid or missing default entry');
return DEFAULT_CONFIG;
}
const chats: Record<string, ChatAllowlistEntry> = {};
if (obj.chats && typeof obj.chats === 'object') {
for (const [jid, entry] of Object.entries(obj.chats as Record<string, unknown>)) {
if (isValidEntry(entry)) {
chats[jid] = entry;
} else {
logger.warn({ jid, path: filePath }, 'sender-allowlist: skipping invalid chat entry');
}
}
}
return {
default: obj.default as ChatAllowlistEntry,
chats,
logDenied: obj.logDenied !== false,
};
}
function getEntry(chatJid: string, cfg: SenderAllowlistConfig): ChatAllowlistEntry {
return cfg.chats[chatJid] ?? cfg.default;
}
export function isSenderAllowed(chatJid: string, sender: string, cfg: SenderAllowlistConfig): boolean {
const entry = getEntry(chatJid, cfg);
if (entry.allow === '*') return true;
return entry.allow.includes(sender);
}
export function shouldDropMessage(chatJid: string, cfg: SenderAllowlistConfig): boolean {
return getEntry(chatJid, cfg).mode === 'drop';
}
export function isTriggerAllowed(chatJid: string, sender: string, cfg: SenderAllowlistConfig): boolean {
const allowed = isSenderAllowed(chatJid, sender, cfg);
if (!allowed && cfg.logDenied) {
logger.debug({ chatJid, sender }, 'sender-allowlist: trigger denied for sender');
}
return allowed;
}

View File

@@ -1,25 +0,0 @@
import { execFile } from 'child_process';
import path from 'path';
import { logger } from './logger.js';
const CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
const SCRIPT_PATH = path.resolve(process.cwd(), 'scripts/cleanup-sessions.sh');
function runCleanup(): void {
execFile('/bin/bash', [SCRIPT_PATH], { timeout: 60_000 }, (err, stdout) => {
if (err) {
logger.error({ err }, 'Session cleanup failed');
return;
}
const summary = stdout.trim().split('\n').pop();
if (summary) logger.info(summary);
});
}
export function startSessionCleanup(): void {
// Run once at startup (delayed 30s to not compete with init)
setTimeout(runCleanup, 30_000);
// Then every 24 hours
setInterval(runCleanup, CLEANUP_INTERVAL);
}

View File

@@ -1,122 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { _initTestDatabase, createTask, getTaskById } from './db.js';
import { _resetSchedulerLoopForTests, computeNextRun, 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');
});
it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
const task = {
id: 'drift-test',
group_folder: 'test',
chat_jid: 'test@g.us',
prompt: 'test',
schedule_type: 'interval' as const,
schedule_value: '60000', // 1 minute
context_mode: 'isolated' as const,
next_run: scheduledTime,
last_run: null,
last_result: null,
status: 'active' as const,
created_at: '2026-01-01T00:00:00.000Z',
};
const nextRun = computeNextRun(task);
expect(nextRun).not.toBeNull();
// Should be anchored to scheduledTime + 60s, NOT Date.now() + 60s
const expected = new Date(scheduledTime).getTime() + 60000;
expect(new Date(nextRun!).getTime()).toBe(expected);
});
it('computeNextRun returns null for once-tasks', () => {
const task = {
id: 'once-test',
group_folder: 'test',
chat_jid: 'test@g.us',
prompt: 'test',
schedule_type: 'once' as const,
schedule_value: '2026-01-01T00:00:00.000Z',
context_mode: 'isolated' as const,
next_run: new Date(Date.now() - 1000).toISOString(),
last_run: null,
last_result: null,
status: 'active' as const,
created_at: '2026-01-01T00:00:00.000Z',
};
expect(computeNextRun(task)).toBeNull();
});
it('computeNextRun skips missed intervals without infinite loop', () => {
// Task was due 10 intervals ago (missed)
const ms = 60000;
const missedBy = ms * 10;
const scheduledTime = new Date(Date.now() - missedBy).toISOString();
const task = {
id: 'skip-test',
group_folder: 'test',
chat_jid: 'test@g.us',
prompt: 'test',
schedule_type: 'interval' as const,
schedule_value: String(ms),
context_mode: 'isolated' as const,
next_run: scheduledTime,
last_run: null,
last_result: null,
status: 'active' as const,
created_at: '2026-01-01T00:00:00.000Z',
};
const nextRun = computeNextRun(task);
expect(nextRun).not.toBeNull();
// Must be in the future
expect(new Date(nextRun!).getTime()).toBeGreaterThan(Date.now());
// Must be aligned to the original schedule grid
const offset = (new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms;
expect(offset).toBe(0);
});
});

View File

@@ -1,240 +0,0 @@
import { ChildProcess } from 'child_process';
import { CronExpressionParser } from 'cron-parser';
import fs from 'fs';
import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js';
import { ContainerOutput, runContainerAgent, writeTasksSnapshot } from './container-runner.js';
import { getAllTasks, 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';
/**
* Compute the next run time for a recurring task, anchored to the
* task's scheduled time rather than Date.now() to prevent cumulative
* drift on interval-based tasks.
*
* Co-authored-by: @community-pr-601
*/
export function computeNextRun(task: ScheduledTask): string | null {
if (task.schedule_type === 'once') return null;
const now = Date.now();
if (task.schedule_type === 'cron') {
const interval = CronExpressionParser.parse(task.schedule_value, {
tz: TIMEZONE,
});
return interval.next().toISOString();
}
if (task.schedule_type === 'interval') {
const ms = parseInt(task.schedule_value, 10);
if (!ms || ms <= 0) {
// Guard against malformed interval that would cause an infinite loop
logger.warn({ taskId: task.id, value: task.schedule_value }, 'Invalid interval value');
return new Date(now + 60_000).toISOString();
}
// Anchor to the scheduled time, not now, to prevent drift.
// Skip past any missed intervals so we always land in the future.
let next = new Date(task.next_run!).getTime() + ms;
while (next <= now) {
next += ms;
}
return new Date(next).toISOString();
}
return null;
}
export interface SchedulerDependencies {
registeredGroups: () => Record<string, RegisteredGroup>;
getSessions: () => Record<string, string>;
queue: GroupQueue;
onProcess: (groupJid: string, proc: ChildProcess, containerName: string, groupFolder: string) => void;
sendMessage: (jid: string, text: string) => Promise<void>;
}
async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promise<void> {
const startTime = Date.now();
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({ taskId: task.id, group: task.group_folder }, 'Running scheduled task');
const groups = deps.registeredGroups();
const group = Object.values(groups).find((g) => g.folder === task.group_folder);
if (!group) {
logger.error({ taskId: task.id, groupFolder: task.group_folder }, 'Group not found for task');
logTaskRun({
task_id: task.id,
run_at: new Date().toISOString(),
duration_ms: Date.now() - startTime,
status: 'error',
result: null,
error: `Group not found: ${task.group_folder}`,
});
return;
}
// Update tasks snapshot for container to read (filtered by group)
const isMain = group.isMain === true;
const tasks = getAllTasks();
writeTasksSnapshot(
task.group_folder,
isMain,
tasks.map((t) => ({
id: t.id,
groupFolder: t.group_folder,
prompt: t.prompt,
script: t.script,
schedule_type: t.schedule_type,
schedule_value: t.schedule_value,
status: t.status,
next_run: t.next_run,
})),
);
let result: string | null = null;
let error: string | null = null;
// For group context mode, use the group's current session
const sessions = deps.getSessions();
const sessionId = task.context_mode === 'group' ? sessions[task.group_folder] : undefined;
// After the task produces a result, close the container promptly.
// Tasks are single-turn — no need to wait IDLE_TIMEOUT (30 min) for the
// query loop to time out. A short delay handles any final MCP calls.
const TASK_CLOSE_DELAY_MS = 10000;
let closeTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleClose = () => {
if (closeTimer) return; // already scheduled
closeTimer = setTimeout(() => {
logger.debug({ taskId: task.id }, 'Closing task container after result');
deps.queue.closeStdin(task.chat_jid);
}, TASK_CLOSE_DELAY_MS);
};
try {
const output = await runContainerAgent(
group,
{
prompt: task.prompt,
sessionId,
groupFolder: task.group_folder,
chatJid: task.chat_jid,
isMain,
isScheduledTask: true,
assistantName: ASSISTANT_NAME,
script: task.script || undefined,
},
(proc, containerName) => deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),
async (streamedOutput: ContainerOutput) => {
if (streamedOutput.result) {
result = streamedOutput.result;
// Forward result to user (sendMessage handles formatting)
await deps.sendMessage(task.chat_jid, streamedOutput.result);
scheduleClose();
}
if (streamedOutput.status === 'success') {
deps.queue.notifyIdle(task.chat_jid);
scheduleClose(); // Close promptly even when result is null (e.g. IPC-only tasks)
}
if (streamedOutput.status === 'error') {
error = streamedOutput.error || 'Unknown error';
}
},
);
if (closeTimer) clearTimeout(closeTimer);
if (output.status === 'error') {
error = output.error || 'Unknown error';
} else if (output.result) {
// Result was already forwarded to the user via the streaming callback above
result = output.result;
}
logger.info({ taskId: task.id, durationMs: Date.now() - startTime }, 'Task completed');
} catch (err) {
if (closeTimer) clearTimeout(closeTimer);
error = err instanceof Error ? err.message : String(err);
logger.error({ taskId: task.id, error }, 'Task failed');
}
const durationMs = Date.now() - startTime;
logTaskRun({
task_id: task.id,
run_at: new Date().toISOString(),
duration_ms: durationMs,
status: error ? 'error' : 'success',
result,
error,
});
const nextRun = computeNextRun(task);
const resultSummary = error ? `Error: ${error}` : result ? result.slice(0, 200) : 'Completed';
updateTaskAfterRun(task.id, nextRun, resultSummary);
}
let schedulerRunning = false;
export function startSchedulerLoop(deps: SchedulerDependencies): void {
if (schedulerRunning) {
logger.debug('Scheduler loop already running, skipping duplicate start');
return;
}
schedulerRunning = true;
logger.info('Scheduler loop started');
const loop = async () => {
try {
const dueTasks = getDueTasks();
if (dueTasks.length > 0) {
logger.info({ count: dueTasks.length }, 'Found due tasks');
}
for (const task of dueTasks) {
// Re-check task status in case it was paused/cancelled
const currentTask = getTaskById(task.id);
if (!currentTask || currentTask.status !== 'active') {
continue;
}
deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () => runTask(currentTask, deps));
}
} catch (err) {
logger.error({ err }, 'Error in scheduler loop');
}
setTimeout(loop, SCHEDULER_POLL_INTERVAL);
};
loop();
}
/** @internal - for tests only. */
export function _resetSchedulerLoopForTests(): void {
schedulerRunning = false;
}

View File

@@ -1,64 +0,0 @@
import { describe, it, expect } from 'vitest';
import { formatLocalTime, isValidTimezone, resolveTimezone } from './timezone.js';
// --- formatLocalTime ---
describe('formatLocalTime', () => {
it('converts UTC to local time display', () => {
// 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM
const result = formatLocalTime('2026-02-04T18:30:00.000Z', 'America/New_York');
expect(result).toContain('1:30');
expect(result).toContain('PM');
expect(result).toContain('Feb');
expect(result).toContain('2026');
});
it('handles different timezones', () => {
// Same UTC time should produce different local times
const utc = '2026-06-15T12:00:00.000Z';
const ny = formatLocalTime(utc, 'America/New_York');
const tokyo = formatLocalTime(utc, 'Asia/Tokyo');
// NY is UTC-4 in summer (EDT), Tokyo is UTC+9
expect(ny).toContain('8:00');
expect(tokyo).toContain('9:00');
});
it('does not throw on invalid timezone, falls back to UTC', () => {
expect(() => formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2')).not.toThrow();
const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2');
// Should format as UTC (noon UTC = 12:00 PM)
expect(result).toContain('12:00');
expect(result).toContain('PM');
});
});
describe('isValidTimezone', () => {
it('accepts valid IANA identifiers', () => {
expect(isValidTimezone('America/New_York')).toBe(true);
expect(isValidTimezone('UTC')).toBe(true);
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
expect(isValidTimezone('Asia/Jerusalem')).toBe(true);
});
it('rejects invalid timezone strings', () => {
expect(isValidTimezone('IST-2')).toBe(false);
expect(isValidTimezone('XYZ+3')).toBe(false);
});
it('rejects empty and garbage strings', () => {
expect(isValidTimezone('')).toBe(false);
expect(isValidTimezone('NotATimezone')).toBe(false);
});
});
describe('resolveTimezone', () => {
it('returns the timezone if valid', () => {
expect(resolveTimezone('America/New_York')).toBe('America/New_York');
});
it('falls back to UTC for invalid timezone', () => {
expect(resolveTimezone('IST-2')).toBe('UTC');
expect(resolveTimezone('')).toBe('UTC');
});
});

View File

@@ -1,37 +0,0 @@
/**
* Check whether a timezone string is a valid IANA identifier
* that Intl.DateTimeFormat can use.
*/
export function isValidTimezone(tz: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
return true;
} catch {
return false;
}
}
/**
* Return the given timezone if valid IANA, otherwise fall back to UTC.
*/
export function resolveTimezone(tz: string): string {
return isValidTimezone(tz) ? tz : 'UTC';
}
/**
* Convert a UTC ISO timestamp to a localized display string.
* Uses the Intl API (no external dependencies).
* Falls back to UTC if the timezone is invalid.
*/
export function formatLocalTime(utcIso: string, timezone: string): string {
const date = new Date(utcIso);
return date.toLocaleString('en-US', {
timeZone: resolveTimezone(timezone),
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
}

View File

@@ -1,112 +0,0 @@
export interface AdditionalMount {
hostPath: string; // Absolute path on host (supports ~ for home)
containerPath?: string; // Optional — defaults to basename of hostPath. Mounted at /workspace/extra/{value}
readonly?: boolean; // Default: true for safety
}
/**
* Mount Allowlist - Security configuration for additional mounts
* This file should be stored at ~/.config/nanoclaw/mount-allowlist.json
* and is NOT mounted into any container, making it tamper-proof from agents.
*/
export interface MountAllowlist {
// Directories that can be mounted into containers
allowedRoots: AllowedRoot[];
// Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg")
blockedPatterns: string[];
// If true, non-main groups can only mount read-only regardless of config
nonMainReadOnly: boolean;
}
export interface AllowedRoot {
// Absolute path or ~ for home (e.g., "~/projects", "/var/repos")
path: string;
// Whether read-write mounts are allowed under this root
allowReadWrite: boolean;
// Optional description for documentation
description?: string;
}
export interface ContainerConfig {
additionalMounts?: AdditionalMount[];
timeout?: number; // Default: 300000 (5 minutes)
}
export interface RegisteredGroup {
name: string;
folder: string;
trigger: string;
added_at: string;
containerConfig?: ContainerConfig;
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
}
export interface NewMessage {
id: string;
chat_jid: string;
sender: string;
sender_name: string;
content: string;
timestamp: string;
is_from_me?: boolean;
is_bot_message?: boolean;
thread_id?: string;
reply_to_message_id?: string;
reply_to_message_content?: string;
reply_to_sender_name?: string;
}
export interface ScheduledTask {
id: string;
group_folder: string;
chat_jid: string;
prompt: string;
script?: string | null;
schedule_type: 'cron' | 'interval' | 'once';
schedule_value: string;
context_mode: 'group' | 'isolated';
next_run: string | null;
last_run: string | null;
last_result: string | null;
status: 'active' | 'paused' | 'completed';
created_at: string;
}
export interface TaskRunLog {
task_id: string;
run_at: string;
duration_ms: number;
status: 'success' | 'error';
result: string | null;
error: string | null;
}
// --- Channel abstraction ---
export interface Channel {
name: string;
connect(): Promise<void>;
sendMessage(jid: string, text: string): Promise<void>;
isConnected(): boolean;
ownsJid(jid: string): boolean;
disconnect(): Promise<void>;
// Optional: typing indicator. Channels that support it implement it.
setTyping?(jid: string, isTyping: boolean): Promise<void>;
// Optional: sync group/chat names from the platform.
syncGroups?(force: boolean): Promise<void>;
}
// Callback type that channels use to deliver inbound messages
export type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
// Callback for chat metadata discovery.
// name is optional — channels that deliver names inline (Telegram) pass it here;
// channels that sync names separately (via syncGroups) omit it.
export type OnChatMetadata = (
chatJid: string,
timestamp: string,
name?: string,
channel?: string,
isGroup?: boolean,
) => void;