From 86becf8dea383e2a975801f8acbe4890b0140dcd Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 18 Apr 2026 14:23:47 +0300 Subject: [PATCH 1/3] 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) --- CLAUDE.md | 2 - container/agent-runner/src/v1/index.ts | 736 ------------------ .../agent-runner/src/v1/ipc-mcp-stdio.ts | 508 ------------ container/agent-runner/src/v1/mcp-tools.ts | 81 -- container/agent-runner/tsconfig.json | 2 +- src/v1/channels/index.ts | 1 - src/v1/channels/registry.test.ts | 38 - src/v1/channels/registry.ts | 23 - src/v1/config.ts | 62 -- src/v1/container-runner.test.ts | 204 ----- src/v1/container-runner.ts | 677 ---------------- src/v1/container-runtime.test.ts | 147 ---- src/v1/container-runtime.ts | 80 -- src/v1/db-migration.test.ts | 60 -- src/v1/db.test.ts | 591 -------------- src/v1/db.ts | 658 ---------------- src/v1/env.ts | 42 - src/v1/formatting.test.ts | 316 -------- src/v1/group-folder.test.ts | 35 - src/v1/group-folder.ts | 44 -- src/v1/group-queue.test.ts | 457 ----------- src/v1/group-queue.ts | 325 -------- src/v1/index.ts | 647 --------------- src/v1/ipc-auth.test.ts | 613 --------------- src/v1/ipc.ts | 356 --------- src/v1/logger.ts | 69 -- src/v1/mount-security.ts | 405 ---------- src/v1/remote-control.test.ts | 379 --------- src/v1/remote-control.ts | 218 ------ src/v1/router.ts | 43 - src/v1/routing.test.ts | 100 --- src/v1/sender-allowlist.test.ts | 216 ----- src/v1/sender-allowlist.ts | 96 --- src/v1/session-cleanup.ts | 25 - src/v1/task-scheduler.test.ts | 122 --- src/v1/task-scheduler.ts | 240 ------ src/v1/timezone.test.ts | 64 -- src/v1/timezone.ts | 37 - src/v1/types.ts | 112 --- 39 files changed, 1 insertion(+), 8830 deletions(-) delete mode 100644 container/agent-runner/src/v1/index.ts delete mode 100644 container/agent-runner/src/v1/ipc-mcp-stdio.ts delete mode 100644 container/agent-runner/src/v1/mcp-tools.ts delete mode 100644 src/v1/channels/index.ts delete mode 100644 src/v1/channels/registry.test.ts delete mode 100644 src/v1/channels/registry.ts delete mode 100644 src/v1/config.ts delete mode 100644 src/v1/container-runner.test.ts delete mode 100644 src/v1/container-runner.ts delete mode 100644 src/v1/container-runtime.test.ts delete mode 100644 src/v1/container-runtime.ts delete mode 100644 src/v1/db-migration.test.ts delete mode 100644 src/v1/db.test.ts delete mode 100644 src/v1/db.ts delete mode 100644 src/v1/env.ts delete mode 100644 src/v1/formatting.test.ts delete mode 100644 src/v1/group-folder.test.ts delete mode 100644 src/v1/group-folder.ts delete mode 100644 src/v1/group-queue.test.ts delete mode 100644 src/v1/group-queue.ts delete mode 100644 src/v1/index.ts delete mode 100644 src/v1/ipc-auth.test.ts delete mode 100644 src/v1/ipc.ts delete mode 100644 src/v1/logger.ts delete mode 100644 src/v1/mount-security.ts delete mode 100644 src/v1/remote-control.test.ts delete mode 100644 src/v1/remote-control.ts delete mode 100644 src/v1/router.ts delete mode 100644 src/v1/routing.test.ts delete mode 100644 src/v1/sender-allowlist.test.ts delete mode 100644 src/v1/sender-allowlist.ts delete mode 100644 src/v1/session-cleanup.ts delete mode 100644 src/v1/task-scheduler.test.ts delete mode 100644 src/v1/task-scheduler.ts delete mode 100644 src/v1/timezone.test.ts delete mode 100644 src/v1/timezone.ts delete mode 100644 src/v1/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index 24341ad..ec06a64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``` diff --git a/container/agent-runner/src/v1/index.ts b/container/agent-runner/src/v1/index.ts deleted file mode 100644 index 7e739c7..0000000 --- a/container/agent-runner/src/v1/index.ts +++ /dev/null @@ -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 { - while (true) { - while (this.queue.length > 0) { - yield this.queue.shift()!; - } - if (this.done) return; - await new Promise((r) => { - this.waiting = r; - }); - this.waiting = null; - } - } -} - -async function readStdin(): Promise { - 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 { - 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, - 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 { - 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 { - 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 = { - ...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(); diff --git a/container/agent-runner/src/v1/ipc-mcp-stdio.ts b/container/agent-runner/src/v1/ipc-mcp-stdio.ts deleted file mode 100644 index 989891b..0000000 --- a/container/agent-runner/src/v1/ipc-mcp-stdio.ts +++ /dev/null @@ -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 = { - 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 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 = { - 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); diff --git a/container/agent-runner/src/v1/mcp-tools.ts b/container/agent-runner/src/v1/mcp-tools.ts deleted file mode 100644 index e56d6a8..0000000 --- a/container/agent-runner/src/v1/mcp-tools.ts +++ /dev/null @@ -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 { - 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); -}); diff --git a/container/agent-runner/tsconfig.json b/container/agent-runner/tsconfig.json index 2a0b579..6ca456d 100644 --- a/container/agent-runner/tsconfig.json +++ b/container/agent-runner/tsconfig.json @@ -10,5 +10,5 @@ "types": ["bun"] }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/v1/**/*"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } diff --git a/src/v1/channels/index.ts b/src/v1/channels/index.ts deleted file mode 100644 index 09d8e35..0000000 --- a/src/v1/channels/index.ts +++ /dev/null @@ -1 +0,0 @@ -// v1 channel barrel — no-op (channels registered via separate skill branches) diff --git a/src/v1/channels/registry.test.ts b/src/v1/channels/registry.test.ts deleted file mode 100644 index 501ae5c..0000000 --- a/src/v1/channels/registry.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/v1/channels/registry.ts b/src/v1/channels/registry.ts deleted file mode 100644 index e70f85d..0000000 --- a/src/v1/channels/registry.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from '../types.js'; - -export interface ChannelOpts { - onMessage: OnInboundMessage; - onChatMetadata: OnChatMetadata; - registeredGroups: () => Record; -} - -export type ChannelFactory = (opts: ChannelOpts) => Channel | null; - -const registry = new Map(); - -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()]; -} diff --git a/src/v1/config.ts b/src/v1/config.ts deleted file mode 100644 index ef1ba9e..0000000 --- a/src/v1/config.ts +++ /dev/null @@ -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(); diff --git a/src/v1/container-runner.test.ts b/src/v1/container-runner.test.ts deleted file mode 100644 index 292deb2..0000000 --- a/src/v1/container-runner.test.ts +++ /dev/null @@ -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('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; - 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; - -// Mock child_process.spawn -vi.mock('child_process', async () => { - const actual = await vi.importActual('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, 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'); - }); -}); diff --git a/src/v1/container-runner.ts b/src/v1/container-runner.ts deleted file mode 100644 index b04cc28..0000000 --- a/src/v1/container-runner.ts +++ /dev/null @@ -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 { - 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, -): Promise { - 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, -): 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, - ), - ); -} diff --git a/src/v1/container-runtime.test.ts b/src/v1/container-runtime.test.ts deleted file mode 100644 index 94e14e9..0000000 --- a/src/v1/container-runtime.test.ts +++ /dev/null @@ -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', - ); - }); -}); diff --git a/src/v1/container-runtime.ts b/src/v1/container-runtime.ts deleted file mode 100644 index 678a708..0000000 --- a/src/v1/container-runtime.ts +++ /dev/null @@ -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'); - } -} diff --git a/src/v1/db-migration.test.ts b/src/v1/db-migration.test.ts deleted file mode 100644 index d15ba85..0000000 --- a/src/v1/db-migration.test.ts +++ /dev/null @@ -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); - } - }); -}); diff --git a/src/v1/db.test.ts b/src/v1/db.test.ts deleted file mode 100644 index 74d0093..0000000 --- a/src/v1/db.test.ts +++ /dev/null @@ -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(/ { - // 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(); - }); -}); diff --git a/src/v1/db.ts b/src/v1/db.ts deleted file mode 100644 index d1484c7..0000000 --- a/src/v1/db.ts +++ /dev/null @@ -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): 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 - >, -): 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 { - const rows = db.prepare('SELECT group_folder, session_id FROM sessions').all() as Array<{ - group_folder: string; - session_id: string; - }>; - const result: Record = {}; - 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 { - 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 = {}; - 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; - } | 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 | 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 | 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'); - } - } - } -} diff --git a/src/v1/env.ts b/src/v1/env.ts deleted file mode 100644 index 064e6f8..0000000 --- a/src/v1/env.ts +++ /dev/null @@ -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 { - 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 = {}; - 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; -} diff --git a/src/v1/formatting.test.ts b/src/v1/formatting.test.ts deleted file mode 100644 index d0b361a..0000000 --- a/src/v1/formatting.test.ts +++ /dev/null @@ -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 { - 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 & b'); - }); - - it('escapes less-than', () => { - expect(escapeXml('a < b')).toBe('a < b'); - }); - - it('escapes greater-than', () => { - expect(escapeXml('a > b')).toBe('a > b'); - }); - - it('escapes double quotes', () => { - expect(escapeXml('"hello"')).toBe('"hello"'); - }); - - it('handles multiple special characters together', () => { - expect(escapeXml('a & b < c > d "e"')).toBe('a & b < c > d "e"'); - }); - - 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(''); - expect(result).toContain('hello'); - 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'); - expect(result).toContain('>hey'); - }); - - it('escapes special characters in sender names', () => { - const result = formatMessages([makeMsg({ sender_name: 'A & B ' })], TZ); - expect(result).toContain('sender="A & B <Co>"'); - }); - - it('escapes special characters in content', () => { - const result = formatMessages([makeMsg({ content: '' })], TZ); - expect(result).toContain('<script>alert("xss")</script>'); - }); - - it('handles empty array', () => { - const result = formatMessages([], TZ); - expect(result).toContain(''); - expect(result).toContain('\n\n'); - }); - - 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('Are you coming tonight?'); - expect(result).toContain('Yes, on my way!'); - }); - - 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: '', - reply_to_sender_name: 'A & B', - }), - ], - TZ, - ); - expect(result).toContain('from="A & B"'); - expect(result).toContain('<script>alert("xss")</script>'); - }); - - 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(''); - }); -}); - -// --- 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 secret world')).toBe('hello world'); - }); - - it('strips multi-line internal tags', () => { - expect(stripInternalTags('hello \nsecret\nstuff\n world')).toBe('hello world'); - }); - - it('strips multiple internal tag blocks', () => { - expect(stripInternalTags('ahellob')).toBe('hello'); - }); - - it('returns empty string when text is only internal tags', () => { - expect(stripInternalTags('only this')).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('hidden')).toBe(''); - }); - - it('strips internal tags from remaining text', () => { - expect(formatOutbound('thinkingThe 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); - }); -}); diff --git a/src/v1/group-folder.test.ts b/src/v1/group-folder.test.ts deleted file mode 100644 index cc77210..0000000 --- a/src/v1/group-folder.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/v1/group-folder.ts b/src/v1/group-folder.ts deleted file mode 100644 index 5745954..0000000 --- a/src/v1/group-folder.ts +++ /dev/null @@ -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; -} diff --git a/src/v1/group-queue.test.ts b/src/v1/group-queue.test.ts deleted file mode 100644 index a7aa286..0000000 --- a/src/v1/group-queue.test.ts +++ /dev/null @@ -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('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((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((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((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((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((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((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((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((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((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); - }); -}); diff --git a/src/v1/group-queue.ts b/src/v1/group-queue.ts deleted file mode 100644 index 5b73e6a..0000000 --- a/src/v1/group-queue.ts +++ /dev/null @@ -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; -} - -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(); - private activeCount = 0; - private waitingGroups: string[] = []; - private processMessagesFn: ((groupJid: string) => Promise) | 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): 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 { - 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 { - 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 { - 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 { - 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)', - ); - } -} diff --git a/src/v1/index.ts b/src/v1/index.ts deleted file mode 100644 index ded6b94..0000000 --- a/src/v1/index.ts +++ /dev/null @@ -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 = {}; -let registeredGroups: Record = {}; -let lastAgentTimestamp: Record = {}; -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): 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 { - 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 | 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 ... blocks — agent uses these for internal reasoning - const text = raw.replace(/[\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, -): 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 { - 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(); - 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 { - 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 { - 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); - }); -} diff --git a/src/v1/ipc-auth.test.ts b/src/v1/ipc-auth.test.ts deleted file mode 100644 index c5bcd51..0000000 --- a/src/v1/ipc-auth.test.ts +++ /dev/null @@ -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; -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, - ): 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(); - }); -}); diff --git a/src/v1/ipc.ts b/src/v1/ipc.ts deleted file mode 100644 index badccb4..0000000 --- a/src/v1/ipc.ts +++ /dev/null @@ -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; - registeredGroups: () => Record; - registerGroup: (jid: string, group: RegisteredGroup) => void; - syncGroups: (force: boolean) => Promise; - getAvailableGroups: () => AvailableGroup[]; - writeGroupsSnapshot: ( - groupFolder: string, - isMain: boolean, - availableGroups: AvailableGroup[], - registeredJids: Set, - ) => 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(); - 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 { - 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[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'); - } -} diff --git a/src/v1/logger.ts b/src/v1/logger.ts deleted file mode 100644 index df2511c..0000000 --- a/src/v1/logger.ts +++ /dev/null @@ -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 = { - 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 { - 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, 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, msg?: string) => log('debug', dataOrMsg, msg), - info: (dataOrMsg: Record | string, msg?: string) => log('info', dataOrMsg, msg), - warn: (dataOrMsg: Record | string, msg?: string) => log('warn', dataOrMsg, msg), - error: (dataOrMsg: Record | string, msg?: string) => log('error', dataOrMsg, msg), - fatal: (dataOrMsg: Record | 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'); -}); diff --git a/src/v1/mount-security.ts b/src/v1/mount-security.ts deleted file mode 100644 index c44620c..0000000 --- a/src/v1/mount-security.ts +++ /dev/null @@ -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); -} diff --git a/src/v1/remote-control.test.ts b/src/v1/remote-control.test.ts deleted file mode 100644 index da8f05d..0000000 --- a/src/v1/remote-control.test.ts +++ /dev/null @@ -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; - let writeFileSyncSpy: ReturnType; - let unlinkSyncSpy: ReturnType; - let _mkdirSyncSpy: ReturnType; - let openSyncSpy: ReturnType; - let closeSyncSpy: ReturnType; - - // 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(); - }); - }); - }); -}); diff --git a/src/v1/remote-control.ts b/src/v1/remote-control.ts deleted file mode 100644 index 2a6799a..0000000 --- a/src/v1/remote-control.ts +++ /dev/null @@ -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 }; -} diff --git a/src/v1/router.ts b/src/v1/router.ts deleted file mode 100644 index 4c7dd38..0000000 --- a/src/v1/router.ts +++ /dev/null @@ -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, '&').replace(//g, '>').replace(/"/g, '"'); -} - -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 ${escapeXml(m.reply_to_message_content)}` - : ''; - return `${replySnippet}${escapeXml(m.content)}`; - }); - - const header = `\n`; - - return `${header}\n${lines.join('\n')}\n`; -} - -export function stripInternalTags(text: string): string { - return text.replace(/[\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 { - 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)); -} diff --git a/src/v1/routing.test.ts b/src/v1/routing.test.ts deleted file mode 100644 index 9276f48..0000000 --- a/src/v1/routing.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/v1/sender-allowlist.test.ts b/src/v1/sender-allowlist.test.ts deleted file mode 100644 index 5bb8569..0000000 --- a/src/v1/sender-allowlist.test.ts +++ /dev/null @@ -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 - }); -}); diff --git a/src/v1/sender-allowlist.ts b/src/v1/sender-allowlist.ts deleted file mode 100644 index 7a7a0fe..0000000 --- a/src/v1/sender-allowlist.ts +++ /dev/null @@ -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; - 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; - 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; - - if (!isValidEntry(obj.default)) { - logger.warn({ path: filePath }, 'sender-allowlist: invalid or missing default entry'); - return DEFAULT_CONFIG; - } - - const chats: Record = {}; - if (obj.chats && typeof obj.chats === 'object') { - for (const [jid, entry] of Object.entries(obj.chats as Record)) { - 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; -} diff --git a/src/v1/session-cleanup.ts b/src/v1/session-cleanup.ts deleted file mode 100644 index feb507c..0000000 --- a/src/v1/session-cleanup.ts +++ /dev/null @@ -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); -} diff --git a/src/v1/task-scheduler.test.ts b/src/v1/task-scheduler.test.ts deleted file mode 100644 index f6eb004..0000000 --- a/src/v1/task-scheduler.test.ts +++ /dev/null @@ -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 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); - }); -}); diff --git a/src/v1/task-scheduler.ts b/src/v1/task-scheduler.ts deleted file mode 100644 index 0d663a9..0000000 --- a/src/v1/task-scheduler.ts +++ /dev/null @@ -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; - getSessions: () => Record; - queue: GroupQueue; - onProcess: (groupJid: string, proc: ChildProcess, containerName: string, groupFolder: string) => void; - sendMessage: (jid: string, text: string) => Promise; -} - -async function runTask(task: ScheduledTask, deps: SchedulerDependencies): Promise { - 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 | 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; -} diff --git a/src/v1/timezone.test.ts b/src/v1/timezone.test.ts deleted file mode 100644 index d9e9454..0000000 --- a/src/v1/timezone.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/src/v1/timezone.ts b/src/v1/timezone.ts deleted file mode 100644 index d8cc6cc..0000000 --- a/src/v1/timezone.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/v1/types.ts b/src/v1/types.ts deleted file mode 100644 index 717aff6..0000000 --- a/src/v1/types.ts +++ /dev/null @@ -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; - sendMessage(jid: string, text: string): Promise; - isConnected(): boolean; - ownsJid(jid: string): boolean; - disconnect(): Promise; - // Optional: typing indicator. Channels that support it implement it. - setTyping?(jid: string, isTyping: boolean): Promise; - // Optional: sync group/chat names from the platform. - syncGroups?(force: boolean): Promise; -} - -// 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; From 53e8135102e7e84845e71fe1bb10523e49c72d67 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 18 Apr 2026 14:23:56 +0300 Subject: [PATCH 2/3] docs: add module contract for refactor Codifies the interface between core and modules: the four registries (delivery actions, inbound gate, response dispatcher, MCP tool self-registration), default modules (typing, mount-security), guarded-inline fallbacks, MODULE-HOOK skill-edit markers, and module migration naming. Authoritative reference for downstream extraction PRs and install skills. See REFACTOR_PLAN.md for broader context. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/module-contract.md | 186 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/module-contract.md diff --git a/docs/module-contract.md b/docs/module-contract.md new file mode 100644 index 0000000..c65a6b3 --- /dev/null +++ b/docs/module-contract.md @@ -0,0 +1,186 @@ +# Module Contract + +This doc is the authoritative reference for how core and modules connect. Everything downstream — extraction PRs, install skills, module authors — keys off these signatures and defaults. See [REFACTOR_PLAN.md](../REFACTOR_PLAN.md) for the broader plan; this doc is the narrow interface spec. + +## Principles + +- Core runs standalone. The `src/modules/index.ts` barrel can be empty and NanoClaw still routes messages in and delivers responses out. +- Modules are independent. No module imports from another module. Cross-module coordination goes through a core dispatcher. +- Registries exist only when multiple modules plug into the same decision point. Single-consumer integrations use skill edits (`MODULE-HOOK` markers) or stay inline with `sqlite_master` guards. +- Removing a module = delete files + remove barrel imports + revert any `MODULE-HOOK` content. Migration files stay (data is preserved). + +## Module taxonomy + +Three categories: + +1. **Default modules** — ship on `main`, live in `src/modules/` for signaling, core imports them directly. No hook, no registry. Removing requires editing core imports (deliberately less frictionless than registry modules — the friction signals "not really core, but you probably want it"). +2. **Registry-based modules** — live on the `modules` branch, installed via `/add-` skills. Plug into core through one of the four registries below. +3. **Channel adapters** — live on the `channels` branch, installed via `/add-` skills. Not covered by this contract; they use the pre-existing `ChannelAdapter` interface and `registerChannelAdapter()`. + +Current default modules: + +- `src/modules/typing/` — typing indicator refresh +- `src/modules/mount-security/` — container mount allowlist validation + +## The four registries + +Each registry has an explicit default for when no module registers. Core must run when all four are empty. + +### 1. Delivery action handlers + +```typescript +// src/delivery.ts +type ActionHandler = ( + content: Record, + session: Session, + inDb: Database.Database, +) => Promise; + +export function registerDeliveryAction(action: string, handler: ActionHandler): void; +``` + +**Purpose:** system-kind outbound messages (`msg.kind === 'system'`) carry an `action` string. Core dispatches to the registered handler. + +**Default when action is unknown:** log `"Unknown system action"` at `warn` and return. Message is still marked delivered (it was consumed by the host, not sent to a channel). + +**Current consumers:** scheduling (5 actions — `schedule_task`, `cancel_task`, `pause_task`, `resume_task`, `update_task`), approvals (3 actions — `install_packages`, `request_rebuild`, `add_mcp_server`), agent-to-agent (`create_agent`, and the agent-routing branch keyed as a pseudo-action `agent_route`). + +### 2. Router inbound gate + +```typescript +// src/router.ts +type InboundGateResult = + | { allowed: true; userId: string | null } + | { allowed: false; userId: string | null; reason: string }; + +type InboundGateFn = ( + event: InboundEvent, + mg: MessagingGroup, + agentGroupId: string, +) => InboundGateResult; + +export function setInboundGate(fn: InboundGateFn): void; +``` + +**Purpose:** single-setter gate that owns both sender resolution (user upsert) and access decision. Takes the raw event because the permissions module needs the sender fields inside `event.message.content`. + +**Default when unset:** `{ allowed: true, userId: null }`. Every message routes through, no users table is needed, downstream must tolerate `userId=null`. + +**Current consumer:** permissions module. + +**Not a registry, a setter.** There is one decision per inbound message and one module that owns it. Calling `setInboundGate` twice overwrites; core does not iterate. + +### 3. Response dispatcher + +```typescript +// src/index.ts (or src/response-dispatch.ts if it grows) +interface ResponsePayload { + questionId: string; + value: string; + userId: string | null; + channelType: string; + platformId: string; + threadId: string | null; +} + +type ResponseHandler = (payload: ResponsePayload) => Promise; + +export function registerResponseHandler(handler: ResponseHandler): void; +``` + +**Purpose:** button-click / question responses arrive via the channel adapter's `onAction` callback. Core iterates registered handlers in registration order. The first one that returns `true` claims the response. + +**Default when empty:** log `"Unclaimed response"` at `warn` and drop. + +**Current consumers:** interactive (matches `pending_questions`), approvals (matches `pending_approvals`). The two tables have disjoint `question_id` / `approval_id` namespaces in practice (`q-*` vs `appr-*`), so first-match-wins is safe. + +### 4. Container MCP tool self-registration + +```typescript +// container/agent-runner/src/mcp-tools/server.ts +export function registerTools(tools: McpToolDefinition[]): void; +``` + +**Purpose:** each tool module calls `registerTools([...])` at import time. The MCP server uses whatever was registered. + +**Default:** only `mcp-tools/core.ts` (`send_message`) registered. + +**Current consumers:** all container-side modules (scheduling, interactive, agents, self-mod). + +## Skill edits to core + +For one-off integrations with a single consumer, install skills edit core directly between `MODULE-HOOK` markers. No registry. + +Marker format: + +```typescript +// MODULE-HOOK:-:start +// MODULE-HOOK:-:end +``` + +The skill inserts between markers on install and clears between them on uninstall. Markers live in core from day one (empty until a skill fills them). + +**Current uses:** + +- `src/host-sweep.ts` → `MODULE-HOOK:scheduling-recurrence` — call to scheduling module's `handleRecurrence`. +- `container/agent-runner/src/poll-loop.ts` → `MODULE-HOOK:scheduling-pre-task` — call to scheduling module's `applyPreTaskScripts`. + +**Promotion rule:** if a third consumer appears for any marker, promote to a registry. + +## Guarded inline (core) + +Some code stays in core but references module-owned tables. These use `sqlite_master` checks to degrade cleanly when the owning module isn't installed. + +| Site | Owning module | Fallback | +|------|---------------|----------| +| `container-runner.ts` admin-ID query (`user_roles`, `agent_group_members`) | permissions | returns `[]` | +| `container-runner.ts` `writeDestinations` (`agent_destinations`) | agent-to-agent | no-op | +| `delivery.ts` channel-permission check (`agent_destinations`) | agent-to-agent | permit (origin-chat always OK) | +| `delivery.ts` `createPendingQuestion` (`pending_questions`) | interactive | no-op (log warning) | + +`container/agent-runner/src/formatter.ts` has a related non-DB fallback: when `NANOCLAW_ADMIN_USER_IDS` is empty, every sender is treated as admin (permissionless mode). This is the one-line change from the current deny-all behavior. + +## Migrations + +All migrations live in `src/db/migrations/` as TypeScript files exporting a `Migration` object: + +```typescript +export interface Migration { + version: number; + name: string; + up: (db: Database.Database) => void; +} +``` + +The barrel `src/db/migrations/index.ts` imports each and lists them in an ordered array. + +**Uniqueness key is `name`, not `version`.** The migrator applies any migration whose `name` isn't in `schema_version`. Version stays as an ordering hint; integer collisions across modules are allowed. + +**Module migration naming:** + +- File: `src/db/migrations/module--.ts` +- `Migration.name`: `'-'` (e.g. `'approvals-pending-approvals'`) + +**Uninstall behavior:** migration files and barrel entries stay. Tables persist across reinstalls. No down migrations. + +## What a registry-based module provides + +Each `src/modules//` module must supply: + +- `index.ts` — imported by `src/modules/index.ts` for side-effect registration (calls `registerDeliveryAction` / `setInboundGate` / `registerResponseHandler` at module load time). +- `project.md` — appended to project `CLAUDE.md` by the install skill. Describes module architecture for anyone reading the codebase. +- `agent.md` — appended to `groups/global/CLAUDE.md` by the install skill. Describes the module's tools for the agent. +- Migration file in `src/db/migrations/` if the module owns any tables. +- Barrel entry in `src/db/migrations/index.ts` for that migration. + +Optionally: + +- Container-side additions to `container/agent-runner/src/mcp-tools/.ts` that call `registerTools([...])`, with a barrel entry in `container/agent-runner/src/mcp-tools/index.ts`. +- `MODULE-HOOK` edits to specific core files, applied by the install skill. + +## What a module must not do + +- Import from another module. +- Write to core-owned tables (`sessions`, `agent_groups`, `messaging_groups`, `schema_version`, etc.) outside of migrations. +- Depend on a specific channel adapter being installed. +- Break core behavior when unloaded. If a module's absence leaves a core feature non-functional, that feature belongs in core, not the module. From a1b227269e59fe172a669f6247ae47b2df620045 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 18 Apr 2026 14:24:04 +0300 Subject: [PATCH 3/3] chore(deps): bump @onecli-sh/sdk to 0.3.1 Lockfile was pinned to 0.2.0 while package.json already declared ^0.3.1. The code depends on types added in 0.3.x (ApprovalRequest, ManualApprovalHandle, configureManualApproval), so the host build was failing on v2. Refreshing the lockfile resolves it. 0.3.1 was published 2026-04-10, well clear of minimumReleaseAge. Co-Authored-By: Claude Opus 4.7 (1M context) --- pnpm-lock.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f10d6a0..c1aa197 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -282,8 +282,8 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@napi-rs/wasm-runtime@1.1.3': - resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 @@ -1156,8 +1156,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.9: - resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} prebuild-install@7.1.3: @@ -1615,7 +1615,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 @@ -1666,7 +1666,7 @@ snapshots: dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': @@ -2617,7 +2617,7 @@ snapshots: picomatch@4.0.4: {} - postcss@8.5.9: + postcss@8.5.10: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -2867,7 +2867,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.9 + postcss: 8.5.10 rolldown: 1.0.0-rc.15 tinyglobby: 0.2.16 optionalDependencies: