mirror of
https://gh-proxy.org/https://github.com/klinsmaya/LobsterAI.git
synced 2026-11-08 01:06:44 +08:00
Merge pull request #733 from nmgwddj/feature/migrate-runtime-hotfixes
refactor(openclaw): migrate runtime hotfixes to version-specific sour…
This commit is contained in:
+6
-7
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(' | '));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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, []);
|
||||
});
|
||||
Reference in New Issue
Block a user