Merge pull request #733 from nmgwddj/feature/migrate-runtime-hotfixes

refactor(openclaw): migrate runtime hotfixes to version-specific sour…
This commit is contained in:
liuzhq
2026-03-25 14:17:40 +08:00
committed by GitHub
16 changed files with 231 additions and 687 deletions
+6 -7
View File
@@ -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"
},
+21 -6
View File
@@ -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/<version>/ directories, where
* <version> 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;
@@ -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(' | '));
}
+2 -2
View File
@@ -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)
@@ -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,
@@ -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:<peerId>
// - <channel>:direct:<peerId>
@@ -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;
@@ -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",
});
},
@@ -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<string>(["whatsapp_login", "cron", "gateway"]);
+const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set<string>(["whatsapp_login", "gateway"]);
export function isOwnerOnlyToolName(name: string) {
return OWNER_ONLY_TOOL_NAME_FALLBACKS.has(normalizeToolName(name));
@@ -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 {
@@ -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:
@@ -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<Record<string, readonly string[]>> = {
voice: ["tts"],
+ wecom: ["exec", "process"],
};
function normalizeMessageProvider(messageProvider?: string): string | undefined {
@@ -1624,7 +1624,6 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
}
private handleGatewayEvent(event: GatewayEventFrame): void {
if (event.event === 'tick') {
this.lastTickTimestamp = Date.now();
return;
@@ -1651,6 +1650,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 {
-28
View File
@@ -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 = [
-288
View File
@@ -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,
};
};
-275
View File
@@ -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, []);
});