From 6745a1c54b71ca9332a8660c75dc4c9b3a7ca12c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sun, 1 Feb 2026 20:49:57 +0200 Subject: [PATCH] Apply fixes from closed PRs: sentinel markers, JID lookup, schedule validation - PR #10: Add sentinel markers for robust JSON parsing between container and host. Fallback to last-line parsing for backwards compatibility. - PR #5: Look up target JID from registeredGroups instead of trusting IPC payload, fixing cross-group scheduled tasks getting wrong chat_jid. - PR #8: Add lightweight schedule validation in container MCP that returns errors to agents (cron syntax, positive interval, valid ISO timestamp). Also defensive validation on host side. Co-Authored-By: Claude Opus 4.5 --- container/agent-runner/package-lock.json | 389 +++++++++++++++++++++++ container/agent-runner/package.json | 1 + container/agent-runner/src/index.ts | 5 + container/agent-runner/src/ipc-mcp.ts | 29 ++ src/container-runner.ts | 20 +- src/index.ts | 37 ++- 6 files changed, 468 insertions(+), 13 deletions(-) create mode 100644 container/agent-runner/package-lock.json diff --git a/container/agent-runner/package-lock.json b/container/agent-runner/package-lock.json new file mode 100644 index 0000000..c725ace --- /dev/null +++ b/container/agent-runner/package-lock.json @@ -0,0 +1,389 @@ +{ + "name": "nanoclaw-agent-runner", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nanoclaw-agent-runner", + "version": "1.0.0", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "0.2.29", + "cron-parser": "^5.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^22.10.7", + "typescript": "^5.7.3" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.29", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.29.tgz", + "integrity": "sha512-b+655n4ZqqAiMQEL3P44e9UurkI7WWanWTQQQTEcKngL5YCjjXExEPEJRxrmqp8mQXs0kLErZhObx0ZuwibOhA==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.33.5", + "@img/sharp-darwin-x64": "^0.33.5", + "@img/sharp-linux-arm": "^0.33.5", + "@img/sharp-linux-arm64": "^0.33.5", + "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-linuxmusl-arm64": "^0.33.5", + "@img/sharp-linuxmusl-x64": "^0.33.5", + "@img/sharp-win32-x64": "^0.33.5" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/cron-parser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", + "integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==", + "license": "MIT", + "dependencies": { + "luxon": "^3.7.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index 05f8a46..cfb437d 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.29", + "cron-parser": "^5.0.0", "zod": "^4.0.0" }, "devDependencies": { diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 5e6f9c3..b3cb583 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -45,8 +45,13 @@ async function readStdin(): Promise { }); } +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 { diff --git a/container/agent-runner/src/ipc-mcp.ts b/container/agent-runner/src/ipc-mcp.ts index 9d0d712..4b98216 100644 --- a/container/agent-runner/src/ipc-mcp.ts +++ b/container/agent-runner/src/ipc-mcp.ts @@ -7,6 +7,7 @@ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'; 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'); @@ -80,6 +81,34 @@ IMPORTANT - schedule_value format depends on schedule_type: target_group: z.string().optional().describe('Target group folder (main only, defaults to current group)') }, async (args) => { + // Validate schedule_value before writing IPC + if (args.schedule_type === 'cron') { + try { + CronExpressionParser.parse(args.schedule_value); + } catch (err) { + return { + content: [{ type: 'text', 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', text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).` }], + isError: true + }; + } + } else if (args.schedule_type === 'once') { + const date = new Date(args.schedule_value); + if (isNaN(date.getTime())) { + return { + content: [{ type: 'text', text: `Invalid timestamp: "${args.schedule_value}". Use ISO 8601 format like "2026-02-01T15:30:00.000Z".` }], + isError: true + }; + } + } + // Non-main groups can only schedule for themselves const targetGroup = isMain && args.target_group ? args.target_group : groupFolder; diff --git a/src/container-runner.ts b/src/container-runner.ts index eb9463c..e79fd32 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -21,6 +21,10 @@ const logger = pino({ transport: { target: 'pino-pretty', options: { colorize: true } } }); +// Sentinel markers for robust output parsing (must match agent-runner) +const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; +const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; + function getHomeDir(): string { const home = process.env.HOME || os.homedir(); if (!home) { @@ -321,9 +325,19 @@ export async function runContainerAgent( } try { - // Last non-empty line is the JSON output - const lines = stdout.trim().split('\n'); - const jsonLine = lines[lines.length - 1]; + // 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({ diff --git a/src/index.ts b/src/index.ts index 065db5e..b68f5fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -247,18 +247,21 @@ async function processTaskIpc( switch (data.type) { case 'schedule_task': - if (data.prompt && data.schedule_type && data.schedule_value && data.groupFolder && data.chatJid) { + if (data.prompt && data.schedule_type && data.schedule_value && data.groupFolder) { // Authorization: non-main groups can only schedule for themselves const targetGroup = data.groupFolder; if (!isMain && targetGroup !== sourceGroup) { - logger.warn({ sourceGroup, targetGroup, chatJid: data.chatJid }, 'Unauthorized schedule_task attempt blocked'); + logger.warn({ sourceGroup, targetGroup }, 'Unauthorized schedule_task attempt blocked'); break; } - // Authorization: verify the chatJid belongs to the target group - const chatGroup = registeredGroups[data.chatJid]; - if (!isMain && (!chatGroup || chatGroup.folder !== targetGroup)) { - logger.warn({ sourceGroup, targetGroup, chatJid: data.chatJid }, 'Unauthorized schedule_task chatJid blocked'); + // Resolve the correct JID for the target group (don't trust IPC payload) + const targetJid = Object.entries(registeredGroups).find( + ([, group]) => group.folder === targetGroup + )?.[0]; + + if (!targetJid) { + logger.warn({ targetGroup }, 'Cannot schedule task: target group not registered'); break; } @@ -266,20 +269,34 @@ async function processTaskIpc( let nextRun: string | null = null; if (scheduleType === 'cron') { - const interval = CronExpressionParser.parse(data.schedule_value); - nextRun = interval.next().toISOString(); + try { + const interval = CronExpressionParser.parse(data.schedule_value); + 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') { - nextRun = data.schedule_value; // ISO timestamp + const scheduled = new Date(data.schedule_value); + if (isNaN(scheduled.getTime())) { + logger.warn({ scheduleValue: data.schedule_value }, 'Invalid timestamp'); + break; + } + nextRun = scheduled.toISOString(); } const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; createTask({ id: taskId, group_folder: targetGroup, - chat_jid: data.chatJid, + chat_jid: targetJid, prompt: data.prompt, schedule_type: scheduleType, schedule_value: data.schedule_value,