From 04b8652fecb5ab41719e2cbebbaa213710a4ffbe Mon Sep 17 00:00:00 2001 From: Dylan <2894220@gmail.com> Date: Tue, 24 Mar 2026 11:02:06 +0800 Subject: [PATCH] refactor(openclaw): migrate runtime hotfixes to version-specific source-level git patches Replace the code-level string-replacement hotfix system (openclawRuntimeHotfix.ts) with git patch files applied to OpenClaw TypeScript sources before compilation. Patches are now organized under scripts/patches// directories, tied to the openclaw version declared in package.json. - Move 8 patch files into scripts/patches/v2026.3.2/ - Update apply-openclaw-patches.cjs to resolve version from package.json - Update build-openclaw-runtime.sh patch hash to use versioned directory - Remove openclaw:hotfix build step from all 6 platform build chains - Delete openclawRuntimeHotfix.ts, apply-openclaw-runtime-hotfixes.cjs, and tests - Remove applyRuntimeHotfixes() from openclawEngineManager.ts Signed-off-by: Dylan <2894220@gmail.com> --- package.json | 13 +- scripts/apply-openclaw-patches.cjs | 27 +- scripts/apply-openclaw-runtime-hotfixes.cjs | 80 ----- scripts/build-openclaw-runtime.sh | 4 +- .../openclaw-cron-current-time-suffix.patch | 13 + .../openclaw-cron-delivery-inference.patch | 110 +++++++ .../openclaw-cron-isolated-session-key.patch | 13 + .../openclaw-cron-owner-fallback.patch | 13 + .../openclaw-cron-reminder-prompt.patch | 24 ++ .../openclaw-cron-tool-owner-only.patch | 13 + .../openclaw-gateway-entry.patch | 0 .../v2026.3.2/openclaw-wecom-exec-deny.patch | 12 + .../agentEngine/openclawRuntimeAdapter.ts | 5 +- src/main/libs/openclawEngineManager.ts | 28 -- src/main/libs/openclawRuntimeHotfix.ts | 288 ------------------ tests/openclawRuntimeHotfix.test.mjs | 275 ----------------- 16 files changed, 231 insertions(+), 687 deletions(-) delete mode 100644 scripts/apply-openclaw-runtime-hotfixes.cjs create mode 100644 scripts/patches/v2026.3.2/openclaw-cron-current-time-suffix.patch create mode 100644 scripts/patches/v2026.3.2/openclaw-cron-delivery-inference.patch create mode 100644 scripts/patches/v2026.3.2/openclaw-cron-isolated-session-key.patch create mode 100644 scripts/patches/v2026.3.2/openclaw-cron-owner-fallback.patch create mode 100644 scripts/patches/v2026.3.2/openclaw-cron-reminder-prompt.patch create mode 100644 scripts/patches/v2026.3.2/openclaw-cron-tool-owner-only.patch rename scripts/patches/{ => v2026.3.2}/openclaw-gateway-entry.patch (100%) create mode 100644 scripts/patches/v2026.3.2/openclaw-wecom-exec-deny.patch delete mode 100644 src/main/libs/openclawRuntimeHotfix.ts delete mode 100644 tests/openclawRuntimeHotfix.test.mjs diff --git a/package.json b/package.json index 459fd70..587e507 100644 --- a/package.json +++ b/package.json @@ -86,19 +86,18 @@ "setup:python-runtime": "node scripts/setup-python-runtime.js", "openclaw:ensure": "node scripts/ensure-openclaw-version.cjs", "openclaw:patch": "node scripts/apply-openclaw-patches.cjs", - "openclaw:hotfix": "node scripts/apply-openclaw-runtime-hotfixes.cjs", "openclaw:plugins": "node scripts/ensure-openclaw-plugins.cjs", "openclaw:extensions:local": "node scripts/sync-local-openclaw-extensions.cjs", "openclaw:bundle": "node scripts/bundle-openclaw-gateway.cjs", "openclaw:precompile": "node scripts/precompile-openclaw-extensions.cjs", "openclaw:prune": "node scripts/prune-openclaw-runtime.cjs", "openclaw:runtime:host": "node scripts/openclaw-runtime-host.cjs", - "openclaw:runtime:mac-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs mac-arm64 && node scripts/sync-openclaw-runtime-current.cjs mac-arm64 && npm run openclaw:hotfix && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile && npm run openclaw:prune", - "openclaw:runtime:mac-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs mac-x64 && node scripts/sync-openclaw-runtime-current.cjs mac-x64 && npm run openclaw:hotfix && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile && npm run openclaw:prune", - "openclaw:runtime:win-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs win-x64 && node scripts/sync-openclaw-runtime-current.cjs win-x64 && npm run openclaw:hotfix && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile && npm run openclaw:prune", - "openclaw:runtime:win-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs win-arm64 && node scripts/sync-openclaw-runtime-current.cjs win-arm64 && npm run openclaw:hotfix && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile && npm run openclaw:prune", - "openclaw:runtime:linux-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs linux-x64 && node scripts/sync-openclaw-runtime-current.cjs linux-x64 && npm run openclaw:hotfix && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile && npm run openclaw:prune", - "openclaw:runtime:linux-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs linux-arm64 && node scripts/sync-openclaw-runtime-current.cjs linux-arm64 && npm run openclaw:hotfix && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile && npm run openclaw:prune", + "openclaw:runtime:mac-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs mac-arm64 && node scripts/sync-openclaw-runtime-current.cjs mac-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile && npm run openclaw:prune", + "openclaw:runtime:mac-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs mac-x64 && node scripts/sync-openclaw-runtime-current.cjs mac-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile && npm run openclaw:prune", + "openclaw:runtime:win-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs win-x64 && node scripts/sync-openclaw-runtime-current.cjs win-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile && npm run openclaw:prune", + "openclaw:runtime:win-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs win-arm64 && node scripts/sync-openclaw-runtime-current.cjs win-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile && npm run openclaw:prune", + "openclaw:runtime:linux-x64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs linux-x64 && node scripts/sync-openclaw-runtime-current.cjs linux-x64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile && npm run openclaw:prune", + "openclaw:runtime:linux-arm64": "npm run openclaw:ensure && npm run openclaw:patch && node scripts/run-build-openclaw-runtime.cjs linux-arm64 && node scripts/sync-openclaw-runtime-current.cjs linux-arm64 && npm run openclaw:bundle && npm run openclaw:plugins && npm run openclaw:extensions:local && npm run openclaw:precompile && npm run openclaw:prune", "regenerate:icon": "bash scripts/regenerate-mac-icon.sh", "fix:mac-icon": "bash scripts/fix-mac-icon-display.sh" }, diff --git a/scripts/apply-openclaw-patches.cjs b/scripts/apply-openclaw-patches.cjs index 496363a..6e55553 100644 --- a/scripts/apply-openclaw-patches.cjs +++ b/scripts/apply-openclaw-patches.cjs @@ -1,11 +1,11 @@ 'use strict'; /** - * Apply LobsterAI patches to the openclaw source tree. + * Apply version-specific LobsterAI patches to the openclaw source tree. * - * These patches add a dedicated gateway entry point that skips the full CLI - * infrastructure, dramatically reducing startup time inside Electron's - * utilityProcess (~15s instead of ~120s). + * Patches are organised in scripts/patches// directories, where + * matches the "openclaw.version" field in package.json (e.g. + * "v2026.3.2"). Only patches for the currently pinned version are applied. * * Usage: * node scripts/apply-openclaw-patches.cjs [openclaw-src-dir] @@ -25,7 +25,15 @@ const openclawSrc = process.argv[2] ? path.resolve(process.argv[2]) : path.resolve(rootDir, '..', 'openclaw'); -const patchesDir = path.join(rootDir, 'scripts', 'patches'); +// Read pinned openclaw version from package.json. +const pkg = require(path.join(rootDir, 'package.json')); +const openclawVersion = pkg.openclaw && pkg.openclaw.version; +if (!openclawVersion) { + console.error('[apply-openclaw-patches] Missing "openclaw.version" in package.json.'); + process.exit(1); +} + +const patchesDir = path.join(rootDir, 'scripts', 'patches', openclawVersion); if (!fs.existsSync(openclawSrc)) { console.error(`[apply-openclaw-patches] openclaw source not found: ${openclawSrc}`); @@ -37,15 +45,22 @@ if (!fs.existsSync(path.join(openclawSrc, 'package.json'))) { process.exit(1); } +if (!fs.existsSync(patchesDir)) { + console.log(`[apply-openclaw-patches] No patches directory for ${openclawVersion}, nothing to do.`); + process.exit(0); +} + const patchFiles = fs.readdirSync(patchesDir) .filter(f => f.endsWith('.patch')) .sort(); if (patchFiles.length === 0) { - console.log('[apply-openclaw-patches] No patches found, nothing to do.'); + console.log(`[apply-openclaw-patches] No patches found for ${openclawVersion}, nothing to do.`); process.exit(0); } +console.log(`[apply-openclaw-patches] Applying patches for openclaw ${openclawVersion} (${patchFiles.length} file(s))`); + let applied = 0; let skipped = 0; diff --git a/scripts/apply-openclaw-runtime-hotfixes.cjs b/scripts/apply-openclaw-runtime-hotfixes.cjs deleted file mode 100644 index f98be90..0000000 --- a/scripts/apply-openclaw-runtime-hotfixes.cjs +++ /dev/null @@ -1,80 +0,0 @@ -'use strict'; - -/** - * Apply runtime hotfixes to the openclaw dist/ files BEFORE esbuild bundling. - * - * This ensures the gateway-bundle.mjs contains the patched code, eliminating - * the need for the slow runtime hotfix pass (applyRuntimeHotfixes) which scans - * ~1100 JS files via Electron's transparent asar read and takes 250+ seconds - * on Windows. - * - * Usage: - * node scripts/apply-openclaw-runtime-hotfixes.cjs [runtime-dir] - * - * If runtime-dir is not specified, defaults to vendor/openclaw-runtime/current. - */ - -const path = require('path'); - -const rootDir = path.resolve(__dirname, '..'); -const runtimeDir = process.argv[2] - ? path.resolve(process.argv[2]) - : path.join(rootDir, 'vendor', 'openclaw-runtime', 'current'); - -// Import the compiled hotfix module from dist-electron/. -// The hotfix code lives in src/main/libs/openclawRuntimeHotfix.ts and is -// compiled by `npm run compile:electron` to dist-electron/openclawRuntimeHotfix.js. -// However, compile:electron runs AFTER the runtime build in the dist:win chain, -// so we compile just this file on the fly using tsx/ts-node if the compiled -// output is not available. - -let applyBundledOpenClawRuntimeHotfixes; - -const compiledPath = path.join(rootDir, 'dist-electron', 'openclawRuntimeHotfix.js'); -try { - const mod = require(compiledPath); - applyBundledOpenClawRuntimeHotfixes = mod.applyBundledOpenClawRuntimeHotfixes; -} catch { - // Fallback: compile the TypeScript source directly using esbuild's register - // (esbuild is already a devDependency for the bundle step). - try { - const esbuild = require('esbuild'); - const srcPath = path.join(rootDir, 'src', 'main', 'libs', 'openclawRuntimeHotfix.ts'); - const result = esbuild.buildSync({ - entryPoints: [srcPath], - bundle: false, - platform: 'node', - format: 'cjs', - write: false, - }); - const code = result.outputFiles[0].text; - const mod = new module.constructor(); - mod._compile(code, srcPath); - applyBundledOpenClawRuntimeHotfixes = mod.exports.applyBundledOpenClawRuntimeHotfixes; - } catch (err) { - console.error('[apply-openclaw-runtime-hotfixes] Failed to load hotfix module:', err.message || err); - console.error('[apply-openclaw-runtime-hotfixes] Run `npm run compile:electron` first, or ensure esbuild is installed.'); - process.exit(1); - } -} - -if (typeof applyBundledOpenClawRuntimeHotfixes !== 'function') { - console.error('[apply-openclaw-runtime-hotfixes] applyBundledOpenClawRuntimeHotfixes is not a function'); - process.exit(1); -} - -console.log(`[apply-openclaw-runtime-hotfixes] Applying hotfixes to: ${runtimeDir}`); -const result = applyBundledOpenClawRuntimeHotfixes(runtimeDir); - -if (result.changed) { - console.log( - `[apply-openclaw-runtime-hotfixes] Patched ${result.patchedFiles.length} file(s): ` - + result.patchedFiles.map(f => path.relative(runtimeDir, f)).join(', ') - ); -} else { - console.log('[apply-openclaw-runtime-hotfixes] No files needed patching (already up to date).'); -} - -if (result.errors.length > 0) { - console.warn('[apply-openclaw-runtime-hotfixes] Warnings:', result.errors.join(' | ')); -} diff --git a/scripts/build-openclaw-runtime.sh b/scripts/build-openclaw-runtime.sh index 8037cd1..69a79bd 100755 --- a/scripts/build-openclaw-runtime.sh +++ b/scripts/build-openclaw-runtime.sh @@ -91,8 +91,8 @@ try { READVER ) -# Compute a fingerprint of all patch files so the build is invalidated when patches change. -PATCHES_DIR="$ELECTRON_ROOT/scripts/patches" +# Compute a fingerprint of version-specific patch files so the build is invalidated when patches change. +PATCHES_DIR="$ELECTRON_ROOT/scripts/patches/$DESIRED_VERSION" PATCH_HASH="" if [[ -d "$PATCHES_DIR" ]]; then PATCH_HASH=$(cat "$PATCHES_DIR"/*.patch 2>/dev/null | sha256sum | cut -d' ' -f1) diff --git a/scripts/patches/v2026.3.2/openclaw-cron-current-time-suffix.patch b/scripts/patches/v2026.3.2/openclaw-cron-current-time-suffix.patch new file mode 100644 index 0000000..e9cb286 --- /dev/null +++ b/scripts/patches/v2026.3.2/openclaw-cron-current-time-suffix.patch @@ -0,0 +1,13 @@ +diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts +index 2d0bee48f0..426036bd07 100644 +--- a/src/infra/heartbeat-runner.ts ++++ b/src/infra/heartbeat-runner.ts +@@ -675,7 +675,7 @@ export async function runHeartbeatOnce(opts: { + canRelayToUser, + }); + const ctx = { +- Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt), ++ Body: hasCronEvents ? prompt : appendCronStyleCurrentTimeLine(prompt, cfg, startedAt), + From: sender, + To: sender, + OriginatingChannel: delivery.channel !== "none" ? delivery.channel : undefined, diff --git a/scripts/patches/v2026.3.2/openclaw-cron-delivery-inference.patch b/scripts/patches/v2026.3.2/openclaw-cron-delivery-inference.patch new file mode 100644 index 0000000..7f72613 --- /dev/null +++ b/scripts/patches/v2026.3.2/openclaw-cron-delivery-inference.patch @@ -0,0 +1,110 @@ +diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts +index 14df690102..c36714ece6 100644 +--- a/src/agents/tools/cron-tool.ts ++++ b/src/agents/tools/cron-tool.ts +@@ -1,11 +1,13 @@ + import { Type } from "@sinclair/typebox"; + import { loadConfig } from "../../config/config.js"; ++import { extractDeliveryInfo } from "../../config/sessions/delivery-info.js"; + import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; + import type { CronDelivery, CronMessageChannel } from "../../cron/types.js"; + import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js"; + import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; + import { extractTextFromChatContent } from "../../shared/chat-content.js"; + import { isRecord, truncateUtf16Safe } from "../../utils.js"; ++import { normalizeDeliveryContext } from "../../utils/delivery-context.js"; + import { resolveSessionAgentId } from "../agent-scope.js"; + import { optionalStringEnum, stringEnum } from "../schema/typebox.js"; + import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; +@@ -159,6 +161,46 @@ function inferDeliveryFromSessionKey(agentSessionKey?: string): CronDelivery | n + if (!rawSessionKey) { + return null; + } ++ ++ // Try structured delivery info from session store (most reliable). ++ const { deliveryContext, threadId } = extractDeliveryInfo(rawSessionKey); ++ const persistedDelivery = normalizeDeliveryContext(deliveryContext); ++ if (persistedDelivery?.channel && persistedDelivery?.to) { ++ const delivery: CronDelivery = { ++ mode: "announce", ++ channel: persistedDelivery.channel as CronMessageChannel, ++ to: persistedDelivery.to, ++ }; ++ if (persistedDelivery.accountId) delivery.accountId = persistedDelivery.accountId; ++ if (persistedDelivery.threadId != null) delivery.threadId = persistedDelivery.threadId; ++ else if (threadId != null) delivery.threadId = threadId; ++ return delivery; ++ } ++ ++ // Try JSON-encoded delivery context embedded in session key. ++ const jsonIdx = rawSessionKey.indexOf(":{"); ++ if (jsonIdx > 0) { ++ try { ++ const ctx = JSON.parse(rawSessionKey.slice(jsonIdx + 1)); ++ if (ctx && typeof ctx.channel === "string" && ctx.channel.trim()) { ++ const channel = ctx.channel.trim().toLowerCase() as CronMessageChannel; ++ const peerId = (ctx.peerid || ctx.peerId || ctx.conversationId || "").trim(); ++ const chatType = (ctx.chattype || ctx.chatType || "direct").trim().toLowerCase(); ++ const accountId = (ctx.accountid || ctx.accountId || "").trim(); ++ if (peerId) { ++ const to = chatType === "group" ? `group:${peerId}` : `user:${peerId}`; ++ const delivery: CronDelivery = { mode: "announce", channel, to }; ++ if (accountId && accountId !== "__default__") delivery.accountId = accountId; ++ if (threadId != null) delivery.threadId = threadId; ++ return delivery; ++ } ++ } ++ } catch { ++ // fall through to legacy parsing ++ } ++ } ++ ++ // Legacy session-key parsing (channel:marker:peerId patterns). + const parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey)); + if (!parsed || !parsed.rest) { + return null; +@@ -172,6 +214,21 @@ function inferDeliveryFromSessionKey(agentSessionKey?: string): CronDelivery | n + return null; + } + ++ if (head === "dingtalk-connector") { ++ const accountId = parts[1]?.trim(); ++ const senderId = parts[2]?.trim(); ++ if (accountId && senderId) { ++ const delivery: CronDelivery = { ++ mode: "announce", ++ channel: "dingtalk-connector" as CronMessageChannel, ++ to: `user:${senderId}`, ++ accountId, ++ }; ++ if (threadId != null) delivery.threadId = threadId; ++ return delivery; ++ } ++ } ++ + // buildAgentPeerSessionKey encodes peers as: + // - direct: + // - :direct: +@@ -204,6 +261,9 @@ function inferDeliveryFromSessionKey(agentSessionKey?: string): CronDelivery | n + if (channel) { + delivery.channel = channel; + } ++ if (threadId != null) { ++ delivery.threadId = threadId; ++ } + return delivery; + } + +diff --git a/src/cron/types.ts b/src/cron/types.ts +index ef5de924b0..8dde2d0754 100644 +--- a/src/cron/types.ts ++++ b/src/cron/types.ts +@@ -25,6 +25,8 @@ export type CronDelivery = { + to?: string; + /** Explicit channel account id for multi-account setups (e.g. multiple Telegram bots). */ + accountId?: string; ++ /** Thread or topic id for threaded channels (e.g. Telegram forum topics). */ ++ threadId?: string | number; + bestEffort?: boolean; + /** Separate destination for failure notifications. */ + failureDestination?: CronFailureDestination; diff --git a/scripts/patches/v2026.3.2/openclaw-cron-isolated-session-key.patch b/scripts/patches/v2026.3.2/openclaw-cron-isolated-session-key.patch new file mode 100644 index 0000000..3043060 --- /dev/null +++ b/scripts/patches/v2026.3.2/openclaw-cron-isolated-session-key.patch @@ -0,0 +1,13 @@ +diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts +index 1f1cd1f535..67d4a644c0 100644 +--- a/src/gateway/server-cron.ts ++++ b/src/gateway/server-cron.ts +@@ -291,7 +291,7 @@ export function buildGatewayCronService(params: { + message, + abortSignal, + agentId, +- sessionKey: `cron:${job.id}`, ++ sessionKey: job.sessionKey || `cron:${job.id}`, + lane: "cron", + }); + }, diff --git a/scripts/patches/v2026.3.2/openclaw-cron-owner-fallback.patch b/scripts/patches/v2026.3.2/openclaw-cron-owner-fallback.patch new file mode 100644 index 0000000..cab10ef --- /dev/null +++ b/scripts/patches/v2026.3.2/openclaw-cron-owner-fallback.patch @@ -0,0 +1,13 @@ +diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts +index 188a9c3361..7d35d4950d 100644 +--- a/src/agents/tool-policy.ts ++++ b/src/agents/tool-policy.ts +@@ -28,7 +28,7 @@ function wrapOwnerOnlyToolExecution(tool: AnyAgentTool, senderIsOwner: boolean): + }; + } + +-const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set(["whatsapp_login", "cron", "gateway"]); ++const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set(["whatsapp_login", "gateway"]); + + export function isOwnerOnlyToolName(name: string) { + return OWNER_ONLY_TOOL_NAME_FALLBACKS.has(normalizeToolName(name)); diff --git a/scripts/patches/v2026.3.2/openclaw-cron-reminder-prompt.patch b/scripts/patches/v2026.3.2/openclaw-cron-reminder-prompt.patch new file mode 100644 index 0000000..1156fc4 --- /dev/null +++ b/scripts/patches/v2026.3.2/openclaw-cron-reminder-prompt.patch @@ -0,0 +1,24 @@ +diff --git a/src/infra/heartbeat-events-filter.ts b/src/infra/heartbeat-events-filter.ts +index 1682c3b308..cf1678a3a0 100644 +--- a/src/infra/heartbeat-events-filter.ts ++++ b/src/infra/heartbeat-events-filter.ts +@@ -23,18 +23,7 @@ export function buildCronEventPrompt( + "Reply HEARTBEAT_OK." + ); + } +- if (!deliverToUser) { +- return ( +- "A scheduled reminder has been triggered. The reminder content is:\n\n" + +- eventText + +- "\n\nHandle this reminder internally. Do not relay it to the user unless explicitly requested." +- ); +- } +- return ( +- "A scheduled reminder has been triggered. The reminder content is:\n\n" + +- eventText + +- "\n\nPlease relay this reminder to the user in a helpful and friendly way." +- ); ++ return eventText; + } + + export function buildExecEventPrompt(opts?: { deliverToUser?: boolean }): string { diff --git a/scripts/patches/v2026.3.2/openclaw-cron-tool-owner-only.patch b/scripts/patches/v2026.3.2/openclaw-cron-tool-owner-only.patch new file mode 100644 index 0000000..69df87f --- /dev/null +++ b/scripts/patches/v2026.3.2/openclaw-cron-tool-owner-only.patch @@ -0,0 +1,13 @@ +diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts +index 14df690102..66bc27d59f 100644 +--- a/src/agents/tools/cron-tool.ts ++++ b/src/agents/tools/cron-tool.ts +@@ -212,7 +212,7 @@ export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): Any + return { + label: "Cron", + name: "cron", +- ownerOnly: true, ++ ownerOnly: false, + description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. + + ACTIONS: diff --git a/scripts/patches/openclaw-gateway-entry.patch b/scripts/patches/v2026.3.2/openclaw-gateway-entry.patch similarity index 100% rename from scripts/patches/openclaw-gateway-entry.patch rename to scripts/patches/v2026.3.2/openclaw-gateway-entry.patch diff --git a/scripts/patches/v2026.3.2/openclaw-wecom-exec-deny.patch b/scripts/patches/v2026.3.2/openclaw-wecom-exec-deny.patch new file mode 100644 index 0000000..428c244 --- /dev/null +++ b/scripts/patches/v2026.3.2/openclaw-wecom-exec-deny.patch @@ -0,0 +1,12 @@ +diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts +index 7d6fdf1c14..b622c1adf8 100644 +--- a/src/agents/pi-tools.ts ++++ b/src/agents/pi-tools.ts +@@ -64,6 +64,7 @@ function isOpenAIProvider(provider?: string) { + + const TOOL_DENY_BY_MESSAGE_PROVIDER: Readonly> = { + voice: ["tts"], ++ wecom: ["exec", "process"], + }; + + function normalizeMessageProvider(messageProvider?: string): string | undefined { diff --git a/src/main/libs/agentEngine/openclawRuntimeAdapter.ts b/src/main/libs/agentEngine/openclawRuntimeAdapter.ts index ed36787..af73533 100644 --- a/src/main/libs/agentEngine/openclawRuntimeAdapter.ts +++ b/src/main/libs/agentEngine/openclawRuntimeAdapter.ts @@ -1581,7 +1581,6 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim } private handleGatewayEvent(event: GatewayEventFrame): void { - if (event.event === 'tick') { this.lastTickTimestamp = Date.now(); return; @@ -1608,6 +1607,10 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim if (event.event === 'exec.approval.resolved') { this.handleApprovalResolved(event.payload); } + + if (event.event === 'cron') { + console.debug('[OpenClawRuntime] received cron event:', JSON.stringify(event)); + } } private handleAgentEvent(payload: unknown, seq?: number): void { diff --git a/src/main/libs/openclawEngineManager.ts b/src/main/libs/openclawEngineManager.ts index 825ce24..49852fc 100644 --- a/src/main/libs/openclawEngineManager.ts +++ b/src/main/libs/openclawEngineManager.ts @@ -7,7 +7,6 @@ import net from 'net'; import path from 'path'; import { getElectronNodeRuntimePath, ensureElectronNodeShim } from './coworkUtil'; import { syncLocalOpenClawExtensionsIntoRuntime } from './openclawLocalExtensions'; -import { applyBundledOpenClawRuntimeHotfixes } from './openclawRuntimeHotfix'; import { appendPythonRuntimeToEnv } from './pythonRuntime'; import { isSystemProxyEnabled, resolveSystemProxyUrl } from './systemProxy'; @@ -358,19 +357,6 @@ export class OpenClawEngineManager extends EventEmitter { this.ensureBareEntryFiles(runtime.root); console.log(`[OpenClaw] startGateway: ensureBareEntryFiles done (${elapsed()})`); - // Skip hotfixes when gateway-bundle.mjs exists. The hotfixes patch individual - // JS files in dist/, but the bundle is a single esbuild artifact that doesn't - // use those files. Applying hotfixes with bundle present is wasteful (Electron's - // transparent asar read causes walkJsFiles to scan ~1100 files inside gateway.asar) - // and can take 250+ seconds on Windows due to Defender scanning. - const bundlePath = path.join(runtime.root, 'gateway-bundle.mjs'); - if (fs.existsSync(bundlePath)) { - console.log(`[OpenClaw] startGateway: skipping applyRuntimeHotfixes (bundle exists) (${elapsed()})`); - } else { - this.applyRuntimeHotfixes(runtime.root); - console.log(`[OpenClaw] startGateway: applyRuntimeHotfixes done (${elapsed()})`); - } - const openclawEntry = this.resolveOpenClawEntry(runtime.root); console.log(`[OpenClaw] startGateway: resolveOpenClawEntry done (${elapsed()}), entry=${openclawEntry}`); if (!openclawEntry) { @@ -680,20 +666,6 @@ export class OpenClawEngineManager extends EventEmitter { } } - private applyRuntimeHotfixes(runtimeRoot: string): void { - const result = applyBundledOpenClawRuntimeHotfixes(runtimeRoot); - if (result.changed) { - console.log( - `[OpenClaw] Applied runtime hotfixes: ${result.patchedFiles - .map((filePath) => path.relative(runtimeRoot, filePath)) - .join(', ')}`, - ); - } - if (result.errors.length > 0) { - console.warn('[OpenClaw] Runtime hotfix warnings:', result.errors.join(' | ')); - } - } - private ensureBundledCliShims(): string | null { const shimDir = path.join(this.stateDir, 'bin'); const shellWrapper = [ diff --git a/src/main/libs/openclawRuntimeHotfix.ts b/src/main/libs/openclawRuntimeHotfix.ts deleted file mode 100644 index 70008be..0000000 --- a/src/main/libs/openclawRuntimeHotfix.ts +++ /dev/null @@ -1,288 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -const CRON_OWNER_ONLY_PATTERN = - /(function createCronTool\([^)]*\)\s*{[\s\S]*?\bname:\s*"cron",\s*\n\s*ownerOnly:\s*)true,/; -const OWNER_ONLY_FALLBACK_PATTERN = - /(const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set\(\[\s*\n\s*"whatsapp_login",\s*\n\s*)"cron",\s*\n(\s*"gateway"\s*\n\s*\]\);)/; -const WECOM_EXEC_DENY_PATTERN = - /const TOOL_DENY_BY_MESSAGE_PROVIDER = \{ voice: \["tts"\](?:,\s*wecom: \["exec", "process"\])? \};/; -const CRON_DELIVERY_INFERENCE_OLD = `function inferDeliveryFromSessionKey(agentSessionKey) { -\tconst rawSessionKey = agentSessionKey?.trim(); -\tif (!rawSessionKey) return null; -\tconst parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey)); -\tif (!parsed || !parsed.rest) return null; -\tconst parts = parsed.rest.split(":").filter(Boolean); -\tif (parts.length === 0) return null; -\tconst head = parts[0]?.trim().toLowerCase(); -\tif (!head || head === "main" || head === "subagent" || head === "acp") return null; -\tconst markerIndex = parts.findIndex((part) => part === "direct" || part === "dm" || part === "group" || part === "channel"); -\tif (markerIndex === -1) return null; -\tconst peerId = parts.slice(markerIndex + 1).join(":").trim(); -\tif (!peerId) return null; -\tlet channel; -\tif (markerIndex >= 1) channel = parts[0]?.trim().toLowerCase(); -\tconst delivery = { -\t\tmode: "announce", -\t\tto: peerId -\t}; -\tif (channel) delivery.channel = channel; -\treturn delivery; -}`; -const CRON_DELIVERY_INFERENCE_NEW = `function inferDeliveryFromSessionKey(agentSessionKey) { -\tconst rawSessionKey = agentSessionKey?.trim(); -\tif (!rawSessionKey) return null; -\tconst { deliveryContext, threadId } = extractDeliveryInfo(rawSessionKey); -\tconst persistedDelivery = normalizeDeliveryContext(deliveryContext); -\tif (persistedDelivery?.channel && persistedDelivery?.to) { -\t\tconst delivery = { -\t\t\tmode: "announce", -\t\t\tchannel: persistedDelivery.channel, -\t\t\tto: persistedDelivery.to -\t\t}; -\t\tif (persistedDelivery.accountId) delivery.accountId = persistedDelivery.accountId; -\t\tif (persistedDelivery.threadId != null) delivery.threadId = persistedDelivery.threadId; -\t\telse if (threadId != null) delivery.threadId = threadId; -\t\treturn delivery; -\t} -\tconst jsonIdx = rawSessionKey.indexOf(":{"); -\tif (jsonIdx > 0) { -\t\ttry { -\t\t\tconst ctx = JSON.parse(rawSessionKey.slice(jsonIdx + 1)); -\t\t\tif (ctx && typeof ctx.channel === "string" && ctx.channel.trim()) { -\t\t\t\tconst channel = ctx.channel.trim().toLowerCase(); -\t\t\t\tconst peerId = (ctx.peerid || ctx.peerId || ctx.conversationId || "").trim(); -\t\t\t\tconst chatType = (ctx.chattype || ctx.chatType || "direct").trim().toLowerCase(); -\t\t\t\tconst accountId = (ctx.accountid || ctx.accountId || "").trim(); -\t\t\t\tif (peerId) { -\t\t\t\t\tconst to = chatType === "group" ? \`group:\${peerId}\` : \`user:\${peerId}\`; -\t\t\t\t\tconst delivery = { mode: "announce", channel, to }; -\t\t\t\t\tif (accountId && accountId !== "__default__") delivery.accountId = accountId; -\t\t\t\t\tif (threadId != null) delivery.threadId = threadId; -\t\t\t\t\treturn delivery; -\t\t\t\t} -\t\t\t} -\t\t} catch {} -\t} -\tconst parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey)); -\tif (!parsed || !parsed.rest) return null; -\tconst parts = parsed.rest.split(":").filter(Boolean); -\tif (parts.length === 0) return null; -\tconst head = parts[0]?.trim().toLowerCase(); -\tif (!head || head === "main" || head === "subagent" || head === "acp") return null; -\tif (head === "dingtalk-connector") { -\t\tconst accountId = parts[1]?.trim(); -\t\tconst senderId = parts[2]?.trim(); -\t\tif (accountId && senderId) { -\t\t\tconst delivery = { -\t\t\t\tmode: "announce", -\t\t\t\tchannel: "dingtalk-connector", -\t\t\t\tto: \`user:\${senderId}\`, -\t\t\t\taccountId -\t\t\t}; -\t\t\tif (threadId != null) delivery.threadId = threadId; -\t\t\treturn delivery; -\t\t} -\t} -\tconst markerIndex = parts.findIndex((part) => part === "direct" || part === "dm" || part === "group" || part === "channel"); -\tif (markerIndex === -1) return null; -\tconst peerId = parts.slice(markerIndex + 1).join(":").trim(); -\tif (!peerId) return null; -\tlet channel; -\tif (markerIndex >= 1) channel = parts[0]?.trim().toLowerCase(); -\tconst delivery = { -\t\tmode: "announce", -\t\tto: peerId -\t}; -\tif (channel) delivery.channel = channel; -\tif (threadId != null) delivery.threadId = threadId; -\treturn delivery; -}`; -const CRON_REMINDER_PROMPT_OLD = [ - 'function buildCronEventPrompt(pendingEvents, opts) {', - '\tconst deliverToUser = opts?.deliverToUser ?? true;', - '\tconst eventText = pendingEvents.join("\\n").trim();', - '\tif (!eventText) {', - '\t\tif (!deliverToUser) return "A scheduled cron event was triggered, but no event content was found. Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up.";', - '\t\treturn "A scheduled cron event was triggered, but no event content was found. Reply HEARTBEAT_OK.";', - '\t}', - '\tif (!deliverToUser) return "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nHandle this reminder internally. Do not relay it to the user unless explicitly requested.";', - '\treturn "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nPlease relay this reminder to the user in a helpful and friendly way.";', - '}', -].join('\n'); -const CRON_REMINDER_PROMPT_NEW = [ - 'function buildCronEventPrompt(pendingEvents, opts) {', - '\tconst deliverToUser = opts?.deliverToUser ?? true;', - '\tconst eventText = pendingEvents.join("\\n").trim();', - '\tif (!eventText) {', - '\t\tif (!deliverToUser) return "A scheduled cron event was triggered, but no event content was found. Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up.";', - '\t\treturn "A scheduled cron event was triggered, but no event content was found. Reply HEARTBEAT_OK.";', - '\t}', - '\treturn eventText;', - '}', -].join('\n'); -const CRON_REMINDER_CURRENT_TIME_BODY_OLD = 'Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),'; -const CRON_REMINDER_CURRENT_TIME_BODY_NEW = 'Body: hasCronEvents ? prompt : appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),'; - -const walkJsFiles = (dirPath: string, files: string[]): void => { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dirPath, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - if (entry.isDirectory()) { - walkJsFiles(fullPath, files); - continue; - } - if (entry.isFile() && fullPath.endsWith('.js')) { - files.push(fullPath); - } - } -}; - -export const patchCronToolOwnerOnly = (source: string): { changed: boolean; content: string } => { - if (!source.includes('function createCronTool(') || !source.includes('name: "cron"')) { - return { changed: false, content: source }; - } - const next = source.replace(CRON_OWNER_ONLY_PATTERN, '$1false,'); - return { - changed: next !== source, - content: next, - }; -}; - -export const patchCronOwnerFallback = (source: string): { changed: boolean; content: string } => { - if (!source.includes('OWNER_ONLY_TOOL_NAME_FALLBACKS') || !source.includes('"cron"')) { - return { changed: false, content: source }; - } - const next = source.replace(OWNER_ONLY_FALLBACK_PATTERN, '$1$2'); - return { - changed: next !== source, - content: next, - }; -}; - -export const patchWecomMessageProviderExecDeny = ( - source: string, -): { changed: boolean; content: string } => { - if (!source.includes('TOOL_DENY_BY_MESSAGE_PROVIDER') || !source.includes('voice: ["tts"]')) { - return { changed: false, content: source }; - } - if (source.includes('wecom: ["exec", "process"]')) { - return { changed: false, content: source }; - } - const next = source.replace( - WECOM_EXEC_DENY_PATTERN, - 'const TOOL_DENY_BY_MESSAGE_PROVIDER = { voice: ["tts"], wecom: ["exec", "process"] };', - ); - return { - changed: next !== source, - content: next, - }; -}; - -export const patchCronSessionDeliveryInference = ( - source: string, -): { changed: boolean; content: string } => { - if ( - !source.includes('function inferDeliveryFromSessionKey(agentSessionKey) {') || - !source.includes('const parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));') - ) { - return { changed: false, content: source }; - } - const next = source.replace(CRON_DELIVERY_INFERENCE_OLD, CRON_DELIVERY_INFERENCE_NEW); - return { - changed: next !== source, - content: next, - }; -}; - -export const patchCronReminderPromptEnvelope = ( - source: string, -): { changed: boolean; content: string } => { - if ( - !source.includes('function buildCronEventPrompt(') || - !source.includes('A scheduled reminder has been triggered. The reminder content is:') - ) { - return { changed: false, content: source }; - } - const next = source.replace(CRON_REMINDER_PROMPT_OLD, CRON_REMINDER_PROMPT_NEW); - return { - changed: next !== source, - content: next, - }; -}; - -export const patchCronReminderCurrentTimeSuffix = ( - source: string, -): { changed: boolean; content: string } => { - if ( - !source.includes(CRON_REMINDER_CURRENT_TIME_BODY_OLD) || - !source.includes('hasCronEvents') - ) { - return { changed: false, content: source }; - } - const next = source.replace( - CRON_REMINDER_CURRENT_TIME_BODY_OLD, - CRON_REMINDER_CURRENT_TIME_BODY_NEW, - ); - return { - changed: next !== source, - content: next, - }; -}; - -export const applyBundledOpenClawRuntimeHotfixes = ( - runtimeRoot: string, -): { changed: boolean; patchedFiles: string[]; errors: string[] } => { - const distRoot = path.join(runtimeRoot, 'dist'); - const jsFiles: string[] = []; - walkJsFiles(distRoot, jsFiles); - - const patchedFiles: string[] = []; - const errors: string[] = []; - - for (const filePath of jsFiles) { - let source = ''; - try { - source = fs.readFileSync(filePath, 'utf8'); - } catch (error) { - errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`); - continue; - } - - const cronOwnerPatch = patchCronToolOwnerOnly(source); - const fallbackPatch = patchCronOwnerFallback(cronOwnerPatch.content); - const wecomExecPatch = patchWecomMessageProviderExecDeny(fallbackPatch.content); - const cronDeliveryPatch = patchCronSessionDeliveryInference(wecomExecPatch.content); - const cronReminderPromptPatch = patchCronReminderPromptEnvelope(cronDeliveryPatch.content); - const cronReminderTimePatch = patchCronReminderCurrentTimeSuffix(cronReminderPromptPatch.content); - if ( - !cronOwnerPatch.changed && - !fallbackPatch.changed && - !wecomExecPatch.changed && - !cronDeliveryPatch.changed && - !cronReminderPromptPatch.changed && - !cronReminderTimePatch.changed - ) { - continue; - } - - try { - fs.writeFileSync(filePath, cronReminderTimePatch.content, 'utf8'); - patchedFiles.push(filePath); - } catch (error) { - errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`); - } - } - - return { - changed: patchedFiles.length > 0, - patchedFiles, - errors, - }; -}; diff --git a/tests/openclawRuntimeHotfix.test.mjs b/tests/openclawRuntimeHotfix.test.mjs deleted file mode 100644 index d847d2a..0000000 --- a/tests/openclawRuntimeHotfix.test.mjs +++ /dev/null @@ -1,275 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import { createRequire } from 'node:module'; -import os from 'node:os'; -import path from 'node:path'; - -const require = createRequire(import.meta.url); -const { - applyBundledOpenClawRuntimeHotfixes, - patchCronReminderCurrentTimeSuffix, - patchCronReminderPromptEnvelope, - patchCronSessionDeliveryInference, - patchCronToolOwnerOnly, - patchCronOwnerFallback, - patchWecomMessageProviderExecDeny, -} = require('../dist-electron/main/libs/openclawRuntimeHotfix.js'); - -const walkJsFiles = (dirPath, files = []) => { - for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { - const fullPath = path.join(dirPath, entry.name); - if (entry.isDirectory()) { - walkJsFiles(fullPath, files); - continue; - } - if (entry.isFile() && fullPath.endsWith('.js')) { - files.push(fullPath); - } - } - return files; -}; - -test('patchCronToolOwnerOnly only flips the cron tool guard', () => { - const source = [ - 'function createCronTool(opts, deps) {', - ' return {', - ' label: "Cron",', - ' name: "cron",', - ' ownerOnly: true,', - ' };', - '}', - 'function createGatewayTool() {', - ' return {', - ' label: "Gateway",', - ' name: "gateway",', - ' ownerOnly: true,', - ' };', - '}', - ].join('\n'); - - const result = patchCronToolOwnerOnly(source); - - assert.equal(result.changed, true); - assert.match(result.content, /name: "cron",\n\s+ownerOnly: false,/); - assert.match(result.content, /name: "gateway",\n\s+ownerOnly: true,/); -}); - -test('patchCronOwnerFallback removes cron from owner-only fallback names only', () => { - const source = [ - 'const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set([', - ' "whatsapp_login",', - ' "cron",', - ' "gateway"', - ']);', - ].join('\n'); - - const result = patchCronOwnerFallback(source); - - assert.equal(result.changed, true); - assert.doesNotMatch(result.content, /"cron"/); - assert.match(result.content, /"whatsapp_login"/); - assert.match(result.content, /"gateway"/); -}); - -test('patchWecomMessageProviderExecDeny denies exec/process for WeCom native sessions only', () => { - const source = 'const TOOL_DENY_BY_MESSAGE_PROVIDER = { voice: ["tts"] };'; - - const result = patchWecomMessageProviderExecDeny(source); - - assert.equal(result.changed, true); - assert.match( - result.content, - /const TOOL_DENY_BY_MESSAGE_PROVIDER = \{ voice: \["tts"\], wecom: \["exec", "process"\] \};/, - ); -}); - -test('patchCronSessionDeliveryInference restores IM routing for persisted sessions and DingTalk keys', () => { - const source = [ - 'function inferDeliveryFromSessionKey(agentSessionKey) {', - ' const rawSessionKey = agentSessionKey?.trim();', - ' if (!rawSessionKey) return null;', - ' const parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));', - ' if (!parsed || !parsed.rest) return null;', - ' const parts = parsed.rest.split(":").filter(Boolean);', - ' if (parts.length === 0) return null;', - ' const head = parts[0]?.trim().toLowerCase();', - ' if (!head || head === "main" || head === "subagent" || head === "acp") return null;', - ' const markerIndex = parts.findIndex((part) => part === "direct" || part === "dm" || part === "group" || part === "channel");', - ' if (markerIndex === -1) return null;', - ' const peerId = parts.slice(markerIndex + 1).join(":").trim();', - ' if (!peerId) return null;', - ' let channel;', - ' if (markerIndex >= 1) channel = parts[0]?.trim().toLowerCase();', - ' const delivery = {', - ' mode: "announce",', - ' to: peerId', - ' };', - ' if (channel) delivery.channel = channel;', - ' return delivery;', - '}', - ].join('\n').replaceAll(' ', '\t'); - - const result = patchCronSessionDeliveryInference(source); - - assert.equal(result.changed, true); - assert.match(result.content, /extractDeliveryInfo\(rawSessionKey\)/); - assert.match(result.content, /persistedDelivery\?\.channel && persistedDelivery\?\.to/); - assert.match(result.content, /head === "dingtalk-connector"/); - assert.match(result.content, /to: `user:\$\{senderId\}`/); - assert.match(result.content, /delivery\.accountId = persistedDelivery\.accountId/); -}); - -test('patchCronReminderPromptEnvelope strips scheduled reminder wrapper text', () => { - const source = [ - 'function buildCronEventPrompt(pendingEvents, opts) {', - '\tconst deliverToUser = opts?.deliverToUser ?? true;', - '\tconst eventText = pendingEvents.join("\\n").trim();', - '\tif (!eventText) {', - '\t\tif (!deliverToUser) return "A scheduled cron event was triggered, but no event content was found. Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up.";', - '\t\treturn "A scheduled cron event was triggered, but no event content was found. Reply HEARTBEAT_OK.";', - '\t}', - '\tif (!deliverToUser) return "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nHandle this reminder internally. Do not relay it to the user unless explicitly requested.";', - '\treturn "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nPlease relay this reminder to the user in a helpful and friendly way.";', - '}', - ].join('\n'); - - const result = patchCronReminderPromptEnvelope(source); - - assert.equal(result.changed, true); - assert.match(result.content, /return eventText;/); - assert.doesNotMatch(result.content, /A scheduled reminder has been triggered/); -}); - -test('patchCronReminderCurrentTimeSuffix skips appending Current time for cron reminder prompts', () => { - const source = [ - 'const hasCronEvents = true;', - 'const ctx = {', - '\tBody: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),', - '};', - ].join('\n'); - - const result = patchCronReminderCurrentTimeSuffix(source); - - assert.equal(result.changed, true); - assert.match( - result.content, - /Body: hasCronEvents \? prompt : appendCronStyleCurrentTimeLine\(prompt, cfg, startedAt\),/, - ); -}); - -test('applyBundledOpenClawRuntimeHotfixes patches matching runtime dist files', () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-runtime-hotfix-')); - const distDir = path.join(tmpRoot, 'dist', 'plugin-sdk'); - fs.mkdirSync(distDir, { recursive: true }); - - const targetFile = path.join(distDir, 'reply.js'); - fs.writeFileSync( - targetFile, - [ - 'function createCronTool(opts, deps) {', - ' return {', - ' label: "Cron",', - ' name: "cron",', - ' ownerOnly: true,', - ' };', - '}', - 'const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set([', - ' "whatsapp_login",', - ' "cron",', - ' "gateway"', - ']);', - 'const TOOL_DENY_BY_MESSAGE_PROVIDER = { voice: ["tts"] };', - 'function inferDeliveryFromSessionKey(agentSessionKey) {', - '\tconst rawSessionKey = agentSessionKey?.trim();', - '\tif (!rawSessionKey) return null;', - '\tconst parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));', - '\tif (!parsed || !parsed.rest) return null;', - '\tconst parts = parsed.rest.split(":").filter(Boolean);', - '\tif (parts.length === 0) return null;', - '\tconst head = parts[0]?.trim().toLowerCase();', - '\tif (!head || head === "main" || head === "subagent" || head === "acp") return null;', - '\tconst markerIndex = parts.findIndex((part) => part === "direct" || part === "dm" || part === "group" || part === "channel");', - '\tif (markerIndex === -1) return null;', - '\tconst peerId = parts.slice(markerIndex + 1).join(":").trim();', - '\tif (!peerId) return null;', - '\tlet channel;', - '\tif (markerIndex >= 1) channel = parts[0]?.trim().toLowerCase();', - '\tconst delivery = {', - '\t\tmode: "announce",', - '\t\tto: peerId', - '\t};', - '\tif (channel) delivery.channel = channel;', - '\treturn delivery;', - '}', - 'function buildCronEventPrompt(pendingEvents, opts) {', - '\tconst deliverToUser = opts?.deliverToUser ?? true;', - '\tconst eventText = pendingEvents.join("\\n").trim();', - '\tif (!eventText) {', - '\t\tif (!deliverToUser) return "A scheduled cron event was triggered, but no event content was found. Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up.";', - '\t\treturn "A scheduled cron event was triggered, but no event content was found. Reply HEARTBEAT_OK.";', - '\t}', - '\tif (!deliverToUser) return "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nHandle this reminder internally. Do not relay it to the user unless explicitly requested.";', - '\treturn "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nPlease relay this reminder to the user in a helpful and friendly way.";', - '}', - 'const hasCronEvents = true;', - 'const ctx = {', - '\tBody: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),', - '};', - ].join('\n'), - 'utf8', - ); - - const controlFile = path.join(tmpRoot, 'dist', 'other.js'); - fs.writeFileSync( - controlFile, - [ - 'function noop() {', - ' return { ownerOnly: true };', - '}', - ].join('\n'), - 'utf8', - ); - - const result = applyBundledOpenClawRuntimeHotfixes(tmpRoot); - - assert.equal(result.changed, true); - assert.deepEqual(result.errors, []); - assert.deepEqual(result.patchedFiles, [targetFile]); - assert.match(fs.readFileSync(targetFile, 'utf8'), /ownerOnly: false,/); - assert.doesNotMatch( - fs.readFileSync(targetFile, 'utf8'), - /OWNER_ONLY_TOOL_NAME_FALLBACKS[\s\S]*"cron"/, - ); - assert.match( - fs.readFileSync(targetFile, 'utf8'), - /TOOL_DENY_BY_MESSAGE_PROVIDER = \{ voice: \["tts"\], wecom: \["exec", "process"\] \};/, - ); - assert.match(fs.readFileSync(targetFile, 'utf8'), /extractDeliveryInfo\(rawSessionKey\)/); - assert.match(fs.readFileSync(targetFile, 'utf8'), /head === "dingtalk-connector"/); - assert.match(fs.readFileSync(targetFile, 'utf8'), /return eventText;/); - assert.match( - fs.readFileSync(targetFile, 'utf8'), - /Body: hasCronEvents \? prompt : appendCronStyleCurrentTimeLine\(prompt, cfg, startedAt\),/, - ); - assert.match(fs.readFileSync(controlFile, 'utf8'), /ownerOnly: true/); -}); - -test('bundled OpenClaw runtime exposes cron to non-owner native sessions', () => { - const runtimeDist = path.resolve('vendor/openclaw-runtime/mac-arm64/dist'); - const cronOwnerOffendingFiles = []; - const cronFallbackOffendingFiles = []; - - for (const filePath of walkJsFiles(runtimeDist)) { - const source = fs.readFileSync(filePath, 'utf8'); - if (patchCronToolOwnerOnly(source).changed) { - cronOwnerOffendingFiles.push(path.relative(process.cwd(), filePath)); - } - if (patchCronOwnerFallback(source).changed) { - cronFallbackOffendingFiles.push(path.relative(process.cwd(), filePath)); - } - } - - assert.deepEqual(cronOwnerOffendingFiles, []); - assert.deepEqual(cronFallbackOffendingFiles, []); -});