From 5c8af2f5b9a9ea03fd05215e2769000e0bb610d2 Mon Sep 17 00:00:00 2001 From: R-M-Naveen Date: Fri, 6 Mar 2026 13:28:15 -0600 Subject: [PATCH 1/6] feat(atxp): auto-discover channels and write HEARTBEAT.md for multi-channel notification relay Instead of injecting instructions via /hooks/wake, directly write relay instructions to HEARTBEAT.md and configure heartbeat delivery target from discovered session channels. Adds sms.received to help text. Co-Authored-By: Claude Opus 4.6 --- packages/atxp/src/commands/notifications.ts | 179 +++++++++++++++----- skills/atxp/SKILL.md | 2 +- 2 files changed, 134 insertions(+), 47 deletions(-) diff --git a/packages/atxp/src/commands/notifications.ts b/packages/atxp/src/commands/notifications.ts index cda92c4..c5571e9 100644 --- a/packages/atxp/src/commands/notifications.ts +++ b/packages/atxp/src/commands/notifications.ts @@ -11,10 +11,76 @@ interface EnableResponse { error?: string; } +interface NotificationChannel { + channel: string; // "telegram", "discord", "slack", etc. + to: string; // peer ID (chat ID, channel ID, etc.) +} + +/** + * Discover connected messaging channels by reading the local session store. + * Parses session keys like "agent:main:telegram:direct:8204320066" to extract + * the channel type and peer ID for each active DM session. + */ +async function discoverConnectedChannels(): Promise { + const sessionsPath = '/data/.openclaw/agents/main/sessions/sessions.json'; + try { + const raw = await fs.readFile(sessionsPath, 'utf-8'); + const sessions = JSON.parse(raw); + const channels: NotificationChannel[] = []; + const seen = new Set(); + + for (const key of Object.keys(sessions)) { + // Match DM session keys: agent:main::direct: + const match = key.match(/^agent:main:([^:]+):direct:(.+)$/); + if (!match) continue; + const [, channel, to] = match; + // Skip ephemeral channels (webchat has no persistent address) + if (channel === 'webchat') continue; + const dedupKey = `${channel}:${to}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + channels.push({ channel, to }); + } + return channels; + } catch { + return []; // sessions file may not exist yet + } +} + /** - * Configure hooks in openclaw.json on the running instance. + * Build the HEARTBEAT.md content with notification relay instructions. + * The default heartbeat prompt says "Read HEARTBEAT.md if it exists. Follow it strictly." + * so writing these instructions directly to the file ensures the LLM relays + * system events to all discovered channels. + */ +function buildHeartbeatMd(channels: NotificationChannel[]): string { + const lines = [ + '# ATXP Notification Relay', + '', + 'When you see System: event lines about new emails or SMS messages,', + 'you MUST immediately relay each one to the user.', + 'Do NOT reply HEARTBEAT_OK when System events are present.', + '', + ]; + + if (channels.length > 0) { + lines.push('Relay to ALL of these channels:'); + for (const c of channels) { + lines.push(`- Use the message tool with channel="${c.channel}" and target="${c.to}"`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Configure hooks, heartbeat delivery target, and HEARTBEAT.md on the instance. * Only runs when inside a Fly instance (FLY_MACHINE_ID is set). - * Updates openclaw.json with the hooks token and restarts the gateway. + * + * Discovers all connected messaging channels from the session store, writes + * HEARTBEAT.md with relay instructions for each channel, and sets the primary + * delivery target to the first discovered channel. */ async function configureHooksOnInstance(hooksToken: string): Promise { if (!process.env.FLY_MACHINE_ID) return; @@ -24,59 +90,81 @@ async function configureHooksOnInstance(hooksToken: string): Promise { const raw = await fs.readFile(configPath, 'utf-8'); const config = JSON.parse(raw); + // Discover connected channels from session store + const channels = await discoverConnectedChannels(); + + let changed = false; + + // Configure hooks if (!config.hooks) config.hooks = {}; - // Already configured with this token — skip - if (config.hooks.token === hooksToken && config.hooks.enabled === true) return; + if (config.hooks.token !== hooksToken || config.hooks.enabled !== true) { + config.hooks.enabled = true; + config.hooks.token = hooksToken; + changed = true; + } - config.hooks.enabled = true; - config.hooks.token = hooksToken; - await fs.writeFile(configPath, JSON.stringify(config, null, 2)); - console.log(chalk.gray('Hooks configured in openclaw.json')); + // Set primary delivery target to first discovered channel + if (!config.agents) config.agents = {}; + if (!config.agents.defaults) config.agents.defaults = {}; + if (!config.agents.defaults.heartbeat) config.agents.defaults.heartbeat = {}; + const hb = config.agents.defaults.heartbeat; - // Restart gateway to pick up new config (watchdog auto-restarts it) - try { - execSync('pkill -f openclaw-gateway', { stdio: 'ignore' }); - console.log(chalk.gray('Gateway restarting to apply hooks config...')); - } catch { - // Gateway may not be running yet — config will be picked up on next start + if (channels.length > 0) { + const primary = channels[0]; + if (hb.target !== primary.channel || hb.to !== primary.to) { + hb.target = primary.channel; + hb.to = primary.to; + changed = true; + } + } else if (!hb.target) { + hb.target = 'last'; + changed = true; } - } catch { - // Non-fatal — hooks will be configured on next reboot via entrypoint config sync - } -} -/** - * Send a one-time instruction to the agent via /hooks/wake so it saves - * notification handling instructions to its HEARTBEAT.md file. - * - * Trust boundary: This intentionally injects a system-level prompt into the - * agent's memory. The webhook endpoint MUST validate the Authorization header - * before acting on the message content. The hardcoded text is controlled by us - * (not user input) and tells the agent how to broadcast notifications. - */ -async function sendHeartbeatInstruction(webhookUrl: string, hooksToken: string): Promise { - try { - const res = await fetch(webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${hooksToken}`, - }, - body: JSON.stringify({ - text: 'SYSTEM: Push notifications are now enabled. When you receive a system event about a new email, proactively use the message tool to broadcast the notification to every connected channel. Check your available tools, find all messaging channels, and send the notification to each one individually. The goal is that the owner sees the notification regardless of which channel they are watching.', - mode: 'now', - }), - }); - if (!res.ok) { - console.log(chalk.gray(`Note: Setup instruction returned HTTP ${res.status} — agent may not have received it.`)); + if (changed) { + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + console.log(chalk.gray('Hooks and heartbeat configured in openclaw.json')); + } + + // Append notification relay instructions to HEARTBEAT.md. + // The default heartbeat prompt reads this file and follows it strictly. + const workspaceDir = '/data/.openclaw/workspace'; + await fs.mkdir(workspaceDir, { recursive: true }); + const heartbeatPath = `${workspaceDir}/HEARTBEAT.md`; + const section = buildHeartbeatMd(channels); + let existing = ''; + try { existing = await fs.readFile(heartbeatPath, 'utf-8'); } catch { /* file may not exist */ } + // Replace existing notification section or append if not present + const sectionStart = '# ATXP Notification Relay'; + if (existing.includes(sectionStart)) { + // Replace from section header to next top-level heading or end of file + const re = new RegExp(`${sectionStart}[\\s\\S]*?(?=\\n# |$)`); + await fs.writeFile(heartbeatPath, existing.replace(re, section.trimEnd())); } else { - console.log(chalk.gray('Notification instructions sent to your agent.')); + const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n\n' : existing.length > 0 ? '\n' : ''; + await fs.writeFile(heartbeatPath, existing + separator + section); + } + console.log(chalk.gray('HEARTBEAT.md updated with notification relay instructions')); + + if (channels.length > 0) { + console.log(chalk.gray(`Notification channels: ${channels.map(c => `${c.channel}:${c.to}`).join(', ')}`)); + } + + // Restart gateway to pick up new config (watchdog auto-restarts it) + if (changed) { + try { + execSync('pkill -f openclaw-gateway', { stdio: 'ignore' }); + console.log(chalk.gray('Gateway restarting to apply config...')); + } catch { + // Gateway may not be running yet — config will be picked up on next start + } } } catch { - console.log(chalk.gray('Note: Could not send setup instruction to instance.')); + // Non-fatal — hooks will be configured on next reboot via entrypoint config sync } } + function getMachineId(): string | undefined { const flyId = process.env.FLY_MACHINE_ID; if (flyId) return flyId; @@ -145,8 +233,6 @@ async function enableNotifications(): Promise { console.log(chalk.gray('Use it to verify webhook signatures (HMAC-SHA256).')); } - // Send one-time HEARTBEAT.md instruction to the agent - await sendHeartbeatInstruction(instance.webhookUrl, instance.hooksToken); } function showNotificationsHelp(): void { @@ -156,6 +242,7 @@ function showNotificationsHelp(): void { console.log(); console.log(chalk.bold('Available Events:')); console.log(' ' + chalk.green('email.received') + ' ' + 'Triggered when an inbound email arrives'); + console.log(' ' + chalk.green('sms.received') + ' ' + 'Triggered when an inbound SMS arrives'); console.log(); console.log(chalk.bold('Examples:')); console.log(' npx atxp notifications enable'); diff --git a/skills/atxp/SKILL.md b/skills/atxp/SKILL.md index 937b7f8..e966330 100644 --- a/skills/atxp/SKILL.md +++ b/skills/atxp/SKILL.md @@ -337,7 +337,7 @@ Local contacts database for resolving names to phone numbers and emails. Stored ### Notifications -Enable push notifications so your agent receives a POST to its `/hooks/wake` endpoint when events happen (e.g., inbound email), instead of polling. +Enable push notifications so your agent receives a POST to its `/hooks/wake` endpoint when events happen (e.g., inbound email or SMS), instead of polling. | Command | Cost | Description | |---------|------|-------------| From c593b616aa97ca499d3d1dcd59953f67645bbb46 Mon Sep 17 00:00:00 2001 From: R-M-Naveen Date: Fri, 6 Mar 2026 13:35:03 -0600 Subject: [PATCH 2/6] =?UTF-8?q?fix(atxp):=20harden=20notifications=20?= =?UTF-8?q?=E2=80=94=20extract=20constants,=20fix=20section=20replace,=20r?= =?UTF-8?q?eset=20stale=20target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract hardcoded paths as named constants - Replace regex section replacement with split-on-header approach - Reset stale heartbeat target when channels disappear - Add error handling for fetch, getAccountId, and configureHooksOnInstance Co-Authored-By: Claude Opus 4.6 --- packages/atxp/src/commands/notifications.ts | 68 ++++++++++++++------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/packages/atxp/src/commands/notifications.ts b/packages/atxp/src/commands/notifications.ts index c5571e9..23b555e 100644 --- a/packages/atxp/src/commands/notifications.ts +++ b/packages/atxp/src/commands/notifications.ts @@ -4,6 +4,10 @@ import os from 'os'; import { execSync } from 'child_process'; const NOTIFICATIONS_BASE_URL = 'https://clowdbot-notifications.corp.circuitandchisel.com'; +const OPENCLAW_CONFIG_PATH = '/data/.openclaw/openclaw.json'; +const SESSIONS_PATH = '/data/.openclaw/agents/main/sessions/sessions.json'; +const WORKSPACE_DIR = '/data/.openclaw/workspace'; +const HEARTBEAT_SECTION_HEADER = '# ATXP Notification Relay'; interface EnableResponse { instance?: { webhookUrl?: string; hooksToken?: string }; @@ -22,7 +26,7 @@ interface NotificationChannel { * the channel type and peer ID for each active DM session. */ async function discoverConnectedChannels(): Promise { - const sessionsPath = '/data/.openclaw/agents/main/sessions/sessions.json'; + const sessionsPath = SESSIONS_PATH; try { const raw = await fs.readFile(sessionsPath, 'utf-8'); const sessions = JSON.parse(raw); @@ -55,7 +59,7 @@ async function discoverConnectedChannels(): Promise { */ function buildHeartbeatMd(channels: NotificationChannel[]): string { const lines = [ - '# ATXP Notification Relay', + HEARTBEAT_SECTION_HEADER, '', 'When you see System: event lines about new emails or SMS messages,', 'you MUST immediately relay each one to the user.', @@ -85,7 +89,7 @@ function buildHeartbeatMd(channels: NotificationChannel[]): string { async function configureHooksOnInstance(hooksToken: string): Promise { if (!process.env.FLY_MACHINE_ID) return; - const configPath = '/data/.openclaw/openclaw.json'; + const configPath = OPENCLAW_CONFIG_PATH; try { const raw = await fs.readFile(configPath, 'utf-8'); const config = JSON.parse(raw); @@ -116,8 +120,10 @@ async function configureHooksOnInstance(hooksToken: string): Promise { hb.to = primary.to; changed = true; } - } else if (!hb.target) { + } else if (hb.target !== 'last') { + // No channels discovered — fall back to 'last' and clear stale target hb.target = 'last'; + delete hb.to; changed = true; } @@ -128,18 +134,21 @@ async function configureHooksOnInstance(hooksToken: string): Promise { // Append notification relay instructions to HEARTBEAT.md. // The default heartbeat prompt reads this file and follows it strictly. - const workspaceDir = '/data/.openclaw/workspace'; - await fs.mkdir(workspaceDir, { recursive: true }); - const heartbeatPath = `${workspaceDir}/HEARTBEAT.md`; + await fs.mkdir(WORKSPACE_DIR, { recursive: true }); + const heartbeatPath = `${WORKSPACE_DIR}/HEARTBEAT.md`; const section = buildHeartbeatMd(channels); let existing = ''; try { existing = await fs.readFile(heartbeatPath, 'utf-8'); } catch { /* file may not exist */ } - // Replace existing notification section or append if not present - const sectionStart = '# ATXP Notification Relay'; - if (existing.includes(sectionStart)) { - // Replace from section header to next top-level heading or end of file - const re = new RegExp(`${sectionStart}[\\s\\S]*?(?=\\n# |$)`); - await fs.writeFile(heartbeatPath, existing.replace(re, section.trimEnd())); + // Replace existing notification section or append if not present. + // Uses split-on-header to avoid regex edge cases with anchors/newlines. + const idx = existing.indexOf(HEARTBEAT_SECTION_HEADER); + if (idx !== -1) { + const before = existing.slice(0, idx); + const afterHeader = existing.slice(idx + HEARTBEAT_SECTION_HEADER.length); + // Find start of next top-level heading after our section + const nextHeading = afterHeader.search(/\n# /); + const after = nextHeading !== -1 ? afterHeader.slice(nextHeading) : ''; + await fs.writeFile(heartbeatPath, before + section.trimEnd() + after); } else { const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n\n' : existing.length > 0 ? '\n' : ''; await fs.writeFile(heartbeatPath, existing + separator + section); @@ -159,8 +168,10 @@ async function configureHooksOnInstance(hooksToken: string): Promise { // Gateway may not be running yet — config will be picked up on next start } } - } catch { - // Non-fatal — hooks will be configured on next reboot via entrypoint config sync + } catch (err) { + console.log(chalk.yellow('Warning: Could not configure instance locally.')); + console.log(chalk.gray(`${err instanceof Error ? err.message : err}`)); + console.log(chalk.gray('Hooks will be configured on next instance reboot.')); } } @@ -179,9 +190,13 @@ function getMachineId(): string | undefined { } async function getAccountId(): Promise { - const { getAccountInfo } = await import('./whoami.js'); - const account = await getAccountInfo(); - return account?.accountId; + try { + const { getAccountInfo } = await import('./whoami.js'); + const account = await getAccountInfo(); + return account?.accountId; + } catch { + return undefined; + } } async function enableNotifications(): Promise { @@ -200,11 +215,18 @@ async function enableNotifications(): Promise { const body: Record = { machine_id: machineId }; if (accountId) body.account_id = accountId; - const res = await fetch(`${NOTIFICATIONS_BASE_URL}/notifications/enable`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + let res: Response; + try { + res = await fetch(`${NOTIFICATIONS_BASE_URL}/notifications/enable`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch (err) { + console.error(chalk.red(`Error: Could not reach notifications service.`)); + console.error(chalk.gray(`${err instanceof Error ? err.message : err}`)); + process.exit(1); + } const data = await res.json().catch(() => ({})) as EnableResponse; if (!res.ok) { From 57caf38731f15f07de9451d0d3a8683388bbe912 Mon Sep 17 00:00:00 2001 From: R-M-Naveen Date: Fri, 6 Mar 2026 14:17:15 -0600 Subject: [PATCH 3/6] fix(atxp): sanitize session values, fix section-replace newline, remove extra blank line - Strip newlines/control chars and limit length on channel/to values parsed from session keys to prevent prompt injection via HEARTBEAT.md - Ensure newline separator between preceding content and replaced section - Remove extra blank line after configureHooksOnInstance Co-Authored-By: Claude Opus 4.6 --- packages/atxp/src/commands/notifications.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/atxp/src/commands/notifications.ts b/packages/atxp/src/commands/notifications.ts index 23b555e..8ab0785 100644 --- a/packages/atxp/src/commands/notifications.ts +++ b/packages/atxp/src/commands/notifications.ts @@ -37,7 +37,10 @@ async function discoverConnectedChannels(): Promise { // Match DM session keys: agent:main::direct: const match = key.match(/^agent:main:([^:]+):direct:(.+)$/); if (!match) continue; - const [, channel, to] = match; + // Sanitize: strip newlines/control chars to prevent prompt injection via session keys + const channel = match[1].replace(/[\n\r\x00-\x1f]/g, '').slice(0, 64); + const to = match[2].replace(/[\n\r\x00-\x1f]/g, '').slice(0, 128); + if (!channel || !to) continue; // Skip ephemeral channels (webchat has no persistent address) if (channel === 'webchat') continue; const dedupKey = `${channel}:${to}`; @@ -143,7 +146,9 @@ async function configureHooksOnInstance(hooksToken: string): Promise { // Uses split-on-header to avoid regex edge cases with anchors/newlines. const idx = existing.indexOf(HEARTBEAT_SECTION_HEADER); if (idx !== -1) { - const before = existing.slice(0, idx); + let before = existing.slice(0, idx); + // Ensure a newline separates preceding content from our section + if (before.length > 0 && !before.endsWith('\n')) before += '\n'; const afterHeader = existing.slice(idx + HEARTBEAT_SECTION_HEADER.length); // Find start of next top-level heading after our section const nextHeading = afterHeader.search(/\n# /); @@ -175,7 +180,6 @@ async function configureHooksOnInstance(hooksToken: string): Promise { } } - function getMachineId(): string | undefined { const flyId = process.env.FLY_MACHINE_ID; if (flyId) return flyId; From 5a2ad993af4cea6b3d776b5402e3e01825089983 Mon Sep 17 00:00:00 2001 From: R-M-Naveen Date: Fri, 6 Mar 2026 14:26:39 -0600 Subject: [PATCH 4/6] fix(atxp): suppress no-control-regex lint error in sanitization regex Co-Authored-By: Claude Opus 4.6 --- packages/atxp/src/commands/notifications.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/atxp/src/commands/notifications.ts b/packages/atxp/src/commands/notifications.ts index 8ab0785..8b1d907 100644 --- a/packages/atxp/src/commands/notifications.ts +++ b/packages/atxp/src/commands/notifications.ts @@ -38,8 +38,10 @@ async function discoverConnectedChannels(): Promise { const match = key.match(/^agent:main:([^:]+):direct:(.+)$/); if (!match) continue; // Sanitize: strip newlines/control chars to prevent prompt injection via session keys - const channel = match[1].replace(/[\n\r\x00-\x1f]/g, '').slice(0, 64); - const to = match[2].replace(/[\n\r\x00-\x1f]/g, '').slice(0, 128); + // eslint-disable-next-line no-control-regex + const sanitize = (s: string) => s.replace(/[\x00-\x1f]/g, ''); + const channel = sanitize(match[1]).slice(0, 64); + const to = sanitize(match[2]).slice(0, 128); if (!channel || !to) continue; // Skip ephemeral channels (webchat has no persistent address) if (channel === 'webchat') continue; From 87bdb0f7a9ca005ce9966bd58c620c431cdbd502 Mon Sep 17 00:00:00 2001 From: R-M-Naveen Date: Fri, 6 Mar 2026 14:42:32 -0600 Subject: [PATCH 5/6] fix(atxp): hoist sanitize, drop redundant alias, backtick-wrap prompt values - Hoist sanitizeSessionValue to module scope (out of loop) - Also strip quote/backtick chars to prevent LLM prompt injection - Remove redundant sessionsPath alias, use SESSIONS_PATH directly - Backtick-wrap channel/target values in HEARTBEAT.md output Co-Authored-By: Claude Opus 4.6 --- packages/atxp/src/commands/notifications.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/atxp/src/commands/notifications.ts b/packages/atxp/src/commands/notifications.ts index 8b1d907..ada23a0 100644 --- a/packages/atxp/src/commands/notifications.ts +++ b/packages/atxp/src/commands/notifications.ts @@ -9,6 +9,9 @@ const SESSIONS_PATH = '/data/.openclaw/agents/main/sessions/sessions.json'; const WORKSPACE_DIR = '/data/.openclaw/workspace'; const HEARTBEAT_SECTION_HEADER = '# ATXP Notification Relay'; +// eslint-disable-next-line no-control-regex +const sanitizeSessionValue = (s: string) => s.replace(/[\x00-\x1f"`]/g, ''); + interface EnableResponse { instance?: { webhookUrl?: string; hooksToken?: string }; webhook?: { id?: string; url?: string; eventTypes?: string[]; secret?: string; enabled?: boolean }; @@ -26,9 +29,8 @@ interface NotificationChannel { * the channel type and peer ID for each active DM session. */ async function discoverConnectedChannels(): Promise { - const sessionsPath = SESSIONS_PATH; try { - const raw = await fs.readFile(sessionsPath, 'utf-8'); + const raw = await fs.readFile(SESSIONS_PATH, 'utf-8'); const sessions = JSON.parse(raw); const channels: NotificationChannel[] = []; const seen = new Set(); @@ -37,11 +39,8 @@ async function discoverConnectedChannels(): Promise { // Match DM session keys: agent:main::direct: const match = key.match(/^agent:main:([^:]+):direct:(.+)$/); if (!match) continue; - // Sanitize: strip newlines/control chars to prevent prompt injection via session keys - // eslint-disable-next-line no-control-regex - const sanitize = (s: string) => s.replace(/[\x00-\x1f]/g, ''); - const channel = sanitize(match[1]).slice(0, 64); - const to = sanitize(match[2]).slice(0, 128); + const channel = sanitizeSessionValue(match[1]).slice(0, 64); + const to = sanitizeSessionValue(match[2]).slice(0, 128); if (!channel || !to) continue; // Skip ephemeral channels (webchat has no persistent address) if (channel === 'webchat') continue; @@ -75,7 +74,7 @@ function buildHeartbeatMd(channels: NotificationChannel[]): string { if (channels.length > 0) { lines.push('Relay to ALL of these channels:'); for (const c of channels) { - lines.push(`- Use the message tool with channel="${c.channel}" and target="${c.to}"`); + lines.push(`- Use the message tool with channel=\`${c.channel}\` and target=\`${c.to}\``); } lines.push(''); } From 0e1120f2277e245d1bd3df4d9c965626eb52da32 Mon Sep 17 00:00:00 2001 From: R-M-Naveen Date: Fri, 6 Mar 2026 14:53:42 -0600 Subject: [PATCH 6/6] fix(atxp): strip markdown brackets in sanitizer, remove trailing blank line, add heading comment Co-Authored-By: Claude Opus 4.6 --- packages/atxp/src/commands/notifications.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/atxp/src/commands/notifications.ts b/packages/atxp/src/commands/notifications.ts index ada23a0..77ccf51 100644 --- a/packages/atxp/src/commands/notifications.ts +++ b/packages/atxp/src/commands/notifications.ts @@ -10,7 +10,7 @@ const WORKSPACE_DIR = '/data/.openclaw/workspace'; const HEARTBEAT_SECTION_HEADER = '# ATXP Notification Relay'; // eslint-disable-next-line no-control-regex -const sanitizeSessionValue = (s: string) => s.replace(/[\x00-\x1f"`]/g, ''); +const sanitizeSessionValue = (s: string) => s.replace(/[\x00-\x1f"`[\]]/g, ''); interface EnableResponse { instance?: { webhookUrl?: string; hooksToken?: string }; @@ -151,7 +151,7 @@ async function configureHooksOnInstance(hooksToken: string): Promise { // Ensure a newline separates preceding content from our section if (before.length > 0 && !before.endsWith('\n')) before += '\n'; const afterHeader = existing.slice(idx + HEARTBEAT_SECTION_HEADER.length); - // Find start of next top-level heading after our section + // Find next top-level heading. Assumes a preceding newline (standard markdown). const nextHeading = afterHeader.search(/\n# /); const after = nextHeading !== -1 ? afterHeader.slice(nextHeading) : ''; await fs.writeFile(heartbeatPath, before + section.trimEnd() + after); @@ -259,7 +259,6 @@ async function enableNotifications(): Promise { console.log(chalk.gray('Save the secret — it will not be shown again.')); console.log(chalk.gray('Use it to verify webhook signatures (HMAC-SHA256).')); } - } function showNotificationsHelp(): void {