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 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-01 20:49:57 +02:00
parent ade9f2d323
commit 6745a1c54b
6 changed files with 468 additions and 13 deletions

View File

@@ -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({

View File

@@ -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,