feat(agents): rename scheduler agent tool cron -> automations (#114841)

* refactor(agents): route scheduler tool-name consumers through canonical identity

Introduce AUTOMATIONS_TOOL_NAME + isAutomationsToolName() in
src/agents/tools/automations-tool-name.ts as the single source of truth for
the scheduler agent tool's name, and convert every exact-name consumer:
factory descriptors, deferred-followup availability detection, add-counting,
policy deny lists, mutation classification, trusted media set, tool catalog
id, system-prompt tool order and tool-line map, sandbox deny defaults,
delegation capability map, MCP loopback probes, and local-model lean deny.

Behavior-neutral: the constant still resolves to "cron". Prepares the
rename in RFC openclaw/rfcs#50 so the flip is a one-line change with no
scattered literals.

* feat(agents): rename scheduler agent tool cron -> automations

Flip AUTOMATIONS_TOOL_NAME to "automations" and register the legacy name:

- TOOL_NAME_ALIASES gains cron -> automations, so persisted toolsAllow/
  toolsDeny lists, tool groups, and creator allowlists written before the
  rename keep matching through the same shipped mechanism as bash -> exec.
  No doctor rewrite needed.
- isAutomationsToolName() accepts legacy names so saved transcripts keep
  their mutation/replay-safety classification; MUTATING_TOOL_NAMES retains
  the legacy entry for the same reason.
- Tool label, catalog label, and tool-search keywords follow the rename
  ("cron" kept as a search synonym).
- Regression tests cover old-name policy matching (allow and deny), legacy
  transcript replay classification, and legacy creator allowlists
  normalizing to the canonical id.

Model-facing description strings still say cron; those move in the
follow-up strings PR. Part of RFC openclaw/rfcs#50 Phase 1.

* test(agents): update creator-cap expectations for canonical automations id

The creator tool surface derives from normalized live tool names, so derived
toolsAllow outputs now emit "automations". Passthrough paths without a
creator cap keep storing user input verbatim; those expectations stay on the
legacy name as stored-data coverage.

* fix(gateway): canonicalize legacy cron tool calls and restore scheduler deny protection

Review follow-ups from ClawSweeper and Codex on the rename (RFC 0026):

- MCP loopback tools/call resolves legacy "cron" names to the published
  automations tool without re-advertising the old name in tools/list.
- Gateway /tools/invoke canonicalizes legacy names before core-id checks and
  exact-name dispatch, so pre-rename integrations keep working.
- Security fix: dangerous-tools deny lists (owner-only HTTP deny and
  control-plane set) were keyed on the literal "cron", so the renamed tool
  silently lost default-deny and owner-only protection on the HTTP invoke
  surface. Lists now use the canonical constant, and the gateway.tools.allow
  un-deny filter normalizes both sides so legacy allow entries still lift it.
- Voice high-impact confirmation list and MCP serve creator allowlist follow
  the canonical name.

Existing cron-regression suite now proves the legacy path end to end:
default deny 404 for both names, legacy allow entry lifts the deny, and
non-owner protection holds.

* fix(agents): cover stdio MCP legacy calls, probe prompts, and prompt snapshots for the rename

- stdio MCP servers (openclaw-tools-serve / plugin tools handlers) resolve
  legacy "cron" callTool names to the published canonical tool, matching
  the HTTP loopback behavior; listTools stays canonical-only.
- Live probe prompts instruct harnesses to load/call the automations MCP
  tool (mcp__openclaw__automations) instead of the retired name.
- Prompt snapshot fixture filter follows the canonical name (the renamed
  tool had silently dropped out of the Codex dynamic-tools snapshots);
  snapshots regenerated as a clean rename.
- Type-cast the new mcp-http handler test payloads for check-test-types.

* test(agents): update tool-surface expectations for the automations rename

CI-surfaced fallout in shards not covered by the focused local runs: tool
availability, agent-config filtering, coding-tools construction,
model-provider lean policy, and skill dispatch all assert the scheduler
tool's surface name. Mock fixtures and expectations follow the canonical
id; legacy-name coverage stays in the dedicated policy/creator-cap/invoke
regression suites.

* test(gateway): update tool-resolution exclude expectations for automations rename

* test(security): update trust-model audit expectations for automations rename

* docs(agents): declare cron a permanent scheduler-tool alias per owner decision

Maintainer decision (Omar): cron is not being retired anywhere — config keys,
RPC methods, schedule syntax, and the CLI token all keep it, and the tool
alias follows the same permanent contract as bash -> exec. No doctor rewrite
and no removal window; comments updated to state the contract instead of a
deprecation plan.

* chore(agents): regen prompt snapshots after rebase onto main

* fix(agents): teach canonical automations tool in fallback guidance and reuse the identity source

Review follow-ups: the structured-list fallback still taught models the cron
tool; the cron-scope test echoed its own stub; MCP serve allowlist and voice
confirmation hardcoded the name instead of the canonical constant.

* fix(mcp): place automations identity import outside the header comment

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
This commit is contained in:
Omar Shahine
2026-07-30 07:16:13 -07:00
committed by GitHub
parent 319fd692d1
commit 364d8be010
56 changed files with 323 additions and 155 deletions
+3 -3
View File
@@ -200,7 +200,7 @@ describe("Agent-specific tool filtering", () => {
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("browser");
expect(toolNames).not.toContain("cron");
expect(toolNames).not.toContain("automations");
expect(toolNames).not.toContain("message");
});
@@ -502,7 +502,7 @@ describe("Agent-specific tool filtering", () => {
expect(ownerTools).toContain("exec");
expect(ownerTools).toContain("process");
expect(ownerTools).toContain("cron");
expect(ownerTools).toContain("automations");
expect(ownerTools).toContain("gateway");
expect(ownerTools).toContain("nodes");
expect(ownerTools).toContain("openclaw");
@@ -511,7 +511,7 @@ describe("Agent-specific tool filtering", () => {
expect(ownerTools).toContain("conversations_turn");
expect(nonOwnerTools).not.toContain("exec");
expect(nonOwnerTools).not.toContain("process");
expect(nonOwnerTools).not.toContain("cron");
expect(nonOwnerTools).not.toContain("automations");
expect(nonOwnerTools).not.toContain("gateway");
expect(nonOwnerTools).not.toContain("nodes");
expect(nonOwnerTools).not.toContain("openclaw");
+1 -1
View File
@@ -28,7 +28,7 @@ describe("tool availability", () => {
const tools = createOpenClawCodingTools();
const toolNames = tools.map((tool) => tool.name);
expect(toolNames).toContain("plugin_login");
expect(toolNames).toContain("cron");
expect(toolNames).toContain("automations");
expect(toolNames).toContain("gateway");
expect(toolNames).toContain("nodes");
expect(toolNames).toContain("openclaw");
@@ -422,7 +422,7 @@ describe("createOpenClawCodingTools", () => {
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("cron")).toBe(true);
expect(names.has("automations")).toBe(true);
expect(names.has("gateway")).toBe(true);
expect(names.has("nodes")).toBe(true);
});
@@ -432,13 +432,13 @@ describe("createOpenClawCodingTools", () => {
createOpenClawCodingTools({
config: testConfig,
}),
["cron"],
["automations"],
);
expect(allowed.map((tool) => tool.name)).toEqual(["cron"]);
expect(allowed.map((tool) => tool.name)).toEqual(["automations"]);
expect(
buildEmptyExplicitToolAllowlistError({
sources: [{ label: "runtime toolsAllow", entries: ["cron"] }],
sources: [{ label: "runtime toolsAllow", entries: ["automations"] }],
callableToolNames: allowed.map((tool) => tool.name),
toolsEnabled: true,
}),
@@ -1253,12 +1253,12 @@ describe("createOpenClawCodingTools", () => {
createOpenClawCodingTools({
sessionKey: "agent:main:whatsapp:group:restricted-room",
config: {
tools: { allow: ["read", "exec", "process", "cron"] },
tools: { allow: ["read", "exec", "process", "automations"] },
channels: {
whatsapp: {
groups: {
"restricted-room": {
tools: { allow: ["read", "cron"] },
tools: { allow: ["read", "automations"] },
},
},
},
@@ -1269,7 +1269,7 @@ describe("createOpenClawCodingTools", () => {
expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1);
const cronAllow = latestCreateOpenClawToolsOptions().cronCreatorToolAllowlist;
const cronAllowNames = cronCreatorToolNames(cronAllow);
expectListIncludes(cronAllowNames, ["read", "cron"]);
expectListIncludes(cronAllowNames, ["read", "automations"]);
expect(cronAllowNames?.includes("exec")).toBe(false);
expect(cronAllowNames?.includes("process")).toBe(false);
});
@@ -1284,7 +1284,7 @@ describe("createOpenClawCodingTools", () => {
const cronAllowNames = cronCreatorToolNames(
latestCreateOpenClawToolsOptions().cronCreatorToolAllowlist,
);
expectListIncludes(cronAllowNames, ["read", "cron", "exec"]);
expectListIncludes(cronAllowNames, ["read", "automations", "exec"]);
});
it("lets embedded attempts refresh a caller-owned cron creator tool surface", () => {
@@ -1295,22 +1295,22 @@ describe("createOpenClawCodingTools", () => {
> = [];
createOpenClawCodingTools({
config: { tools: { allow: ["read", "cron"] } },
config: { tools: { allow: ["read", "automations"] } },
cronCreatorToolAllowlistRef,
});
expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1);
const cronAllow = latestCreateOpenClawToolsOptions().cronCreatorToolAllowlist;
expect(cronAllow).toBe(cronCreatorToolAllowlistRef);
expect(cronCreatorToolNames(cronAllow)).toEqual(["read", "cron"]);
expect(cronCreatorToolNames(cronAllow)).toEqual(["read", "automations"]);
replaceWithEffectiveCronCreatorToolAllowlist(cronCreatorToolAllowlistRef, [
stubTool("read"),
stubTool("cron"),
stubTool("automations"),
stubTool("bundle_mcp_search"),
]);
expect(cronCreatorToolNames(cronAllow)).toEqual(["read", "cron", "bundle_mcp_search"]);
expect(cronCreatorToolNames(cronAllow)).toEqual(["read", "automations", "bundle_mcp_search"]);
});
it("passes deny-restricted tool surface to cron-created agent turns", () => {
@@ -1320,7 +1320,7 @@ describe("createOpenClawCodingTools", () => {
createOpenClawCodingTools({
sessionKey: "agent:main:whatsapp:group:restricted-room",
config: {
tools: { allow: ["read", "exec", "process", "cron"] },
tools: { allow: ["read", "exec", "process", "automations"] },
channels: {
whatsapp: {
groups: {
@@ -1336,7 +1336,7 @@ describe("createOpenClawCodingTools", () => {
expect(createOpenClawToolsMock).toHaveBeenCalledTimes(1);
const cronAllow = latestCreateOpenClawToolsOptions().cronCreatorToolAllowlist;
const cronAllowNames = cronCreatorToolNames(cronAllow);
expectListIncludes(cronAllowNames, ["read", "cron"]);
expectListIncludes(cronAllowNames, ["read", "automations"]);
expect(cronAllowNames?.includes("exec")).toBe(false);
expect(cronAllowNames?.includes("process")).toBe(false);
});
@@ -1374,7 +1374,7 @@ describe("createOpenClawCodingTools", () => {
it("preserves action enums in normalized schemas", () => {
const defaultTools = createOpenClawCodingTools({ config: testConfig });
const toolNames = ["canvas", "nodes", "cron", "gateway", "message"];
const toolNames = ["canvas", "nodes", "automations", "gateway", "message"];
const missingNames = toolNames.filter(
(name) => !defaultTools.some((candidate) => candidate.name === name),
);
@@ -1691,7 +1691,7 @@ describe("createOpenClawCodingTools", () => {
expect(names.has("browser")).toBe(true);
expect(names.has("canvas")).toBe(true);
expect(names.has("gateway")).toBe(true);
expect(names.has("cron")).toBe(true);
expect(names.has("automations")).toBe(true);
expect(names.has("nodes")).toBe(true);
});
+3 -2
View File
@@ -28,7 +28,7 @@ vi.mock("./openclaw-tools.js", async (importOriginal) => {
return {
createOpenClawTools: (options: unknown) => {
mocks.createOpenClawToolsOptions(options);
return [mocks.stubTool("cron")];
return [mocks.stubTool(AUTOMATIONS_TOOL_NAME)];
},
filterToolsByClientCaps: actual.filterToolsByClientCaps,
};
@@ -37,6 +37,7 @@ vi.mock("./openclaw-tools.js", async (importOriginal) => {
import "./test-helpers/fast-bash-tools.js";
import "./test-helpers/fast-coding-tools.js";
import { createOpenClawCodingTools } from "./agent-tools.js";
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
function firstOpenClawToolsOptions(): { cronSelfRemoveOnlyJobId?: string } | undefined {
return mocks.createOpenClawToolsOptions.mock.calls[0]?.[0] as
@@ -55,7 +56,7 @@ describe("createOpenClawCodingTools cron scope", () => {
jobId: "job-current",
});
expect(tools.map((tool) => tool.name)).toContain("cron");
expect(tools.map((tool) => tool.name)).toContain(AUTOMATIONS_TOOL_NAME);
expect(firstOpenClawToolsOptions()?.cronSelfRemoveOnlyJobId).toBe("job-current");
});
+2 -1
View File
@@ -9,6 +9,7 @@ import { describeExecTool, describeProcessTool } from "./bash-tools.descriptions
import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js";
import { copyChannelAgentToolMeta } from "./channel-tools.js";
import { copyToolTerminalPresentation } from "./tool-terminal-presentation.js";
import { isAutomationsToolName } from "./tools/automations-tool-name.js";
function replaceDescription(tool: AnyAgentTool, description: string): AnyAgentTool {
const updated = { ...tool, description };
@@ -24,7 +25,7 @@ export function applyDeferredFollowupToolDescriptions(
tools: AnyAgentTool[],
params?: { agentId?: string },
): AnyAgentTool[] {
const hasCronTool = tools.some((tool) => tool.name === "cron");
const hasCronTool = tools.some((tool) => isAutomationsToolName(tool.name));
return tools.map((tool) => {
if (tool.name === "exec") {
return replaceDescription(tool, describeExecTool({ agentId: params?.agentId, hasCronTool }));
@@ -173,7 +173,7 @@ describe("applyModelProviderToolPolicy", () => {
[
{ name: "read" },
{ name: "browser" },
{ name: "cron" },
{ name: "automations" },
{ name: "message" },
{ name: "exec" },
] as unknown as AnyAgentTool[],
@@ -201,7 +201,7 @@ describe("applyModelProviderToolPolicy", () => {
[
{ name: "read" },
{ name: "browser" },
{ name: "cron" },
{ name: "automations" },
{ name: "message" },
{ name: "exec" },
] as unknown as AnyAgentTool[],
@@ -233,7 +233,7 @@ describe("applyModelProviderToolPolicy", () => {
[
{ name: "read" },
{ name: "browser" },
{ name: "cron" },
{ name: "automations" },
{ name: "message" },
{ name: "exec" },
] as unknown as AnyAgentTool[],
@@ -265,7 +265,7 @@ describe("applyModelProviderToolPolicy", () => {
[
{ name: "read" },
{ name: "browser" },
{ name: "cron" },
{ name: "automations" },
{ name: "message" },
{ name: "exec" },
] as unknown as AnyAgentTool[],
@@ -303,7 +303,7 @@ describe("applyModelProviderToolPolicy", () => {
[
{ name: "read" },
{ name: "browser" },
{ name: "cron" },
{ name: "automations" },
{ name: "message" },
{ name: "exec" },
] as unknown as AnyAgentTool[],
@@ -332,7 +332,7 @@ describe("applyModelProviderToolPolicy", () => {
},
);
expect(toolNames(filtered)).toEqual(["read", "browser", "cron", "message", "exec"]);
expect(toolNames(filtered)).toEqual(["read", "browser", "automations", "message", "exec"]);
});
it("keeps heavyweight tools when the experimental lean local-model flag is not enabled", () => {
@@ -340,7 +340,7 @@ describe("applyModelProviderToolPolicy", () => {
[
{ name: "read" },
{ name: "browser" },
{ name: "cron" },
{ name: "automations" },
{ name: "message" },
{ name: "exec" },
] as unknown as AnyAgentTool[],
@@ -360,6 +360,6 @@ describe("applyModelProviderToolPolicy", () => {
},
);
expect(toolNames(filtered)).toEqual(["read", "browser", "cron", "message", "exec"]);
expect(toolNames(filtered)).toEqual(["read", "browser", "automations", "message", "exec"]);
});
});
+2 -1
View File
@@ -41,6 +41,7 @@ import {
normalizeToolName,
resolveToolProfilePolicy,
} from "./tool-policy.js";
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
export { resolveProviderToolPolicy };
@@ -54,7 +55,7 @@ const SUBAGENT_TOOL_DENY_ALWAYS = [
"agents_list",
// Status/scheduling - main agent coordinates
"session_status",
"cron",
AUTOMATIONS_TOOL_NAME,
// Direct session sends - subagents communicate through announce chain
"sessions_send",
"conversations_list",
+3 -1
View File
@@ -2,6 +2,8 @@
* Static identity for names that select core agent factory families before assembly.
*/
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
export type CoreToolFactoryFamily = "base-coding" | "shell" | "openclaw";
type CoreToolFactoryDescriptor = {
@@ -25,7 +27,7 @@ const CORE_TOOL_FACTORY_DESCRIPTORS = [
{ name: "conversations_list", family: "openclaw" },
{ name: "conversations_send", family: "openclaw" },
{ name: "conversations_turn", family: "openclaw" },
{ name: "cron", family: "openclaw" },
{ name: AUTOMATIONS_TOOL_NAME, family: "openclaw" },
{ name: "dashboard", family: "openclaw" },
{ name: "gateway", family: "openclaw" },
{ name: "get_goal", family: "openclaw" },
+2 -1
View File
@@ -1,5 +1,6 @@
import { isCompletionReportInputProvenance } from "../sessions/input-provenance.js";
import { normalizeToolName } from "./tool-policy.js";
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
import type { AnyAgentTool } from "./tools/common.js";
import { ToolAuthorizationError } from "./tools/common.js";
@@ -14,7 +15,7 @@ const NEW_DELEGATION_TOOL_NAMES = new Set([
]);
const REPORT_ONLY_TOOL_ACTIONS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
["cron", new Set(["get", "list", "remove", "runs", "status"])],
[AUTOMATIONS_TOOL_NAME, new Set(["get", "list", "remove", "runs", "status"])],
["image_generate", new Set(["list", "status"])],
["music_generate", new Set(["list", "status"])],
["video_generate", new Set(["list", "status"])],
@@ -106,6 +106,7 @@ import {
settleAskUserPromptDelivery,
waitForAskUserPromptReady,
} from "./tools/ask-user-tool.js";
import { isAutomationsToolName } from "./tools/automations-tool-name.js";
type ExecApprovalReplyModule = typeof import("../infra/exec-approval-reply.js");
type HookRunnerGlobalModule = typeof import("../plugins/hook-runner-global.js");
@@ -1580,7 +1581,7 @@ export async function handleToolExecutionEnd(
// Track committed reminders only when cron.add completed successfully.
if (
!isToolError &&
((toolName === "cron" && isCronAddAction(startArgs)) ||
((isAutomationsToolName(toolName) && isCronAddAction(startArgs)) ||
(isExecToolName(toolName) && didShellCronAddSucceed(startArgs, result)))
) {
ctx.state.successfulCronAdds += 1;
+2 -1
View File
@@ -34,6 +34,7 @@ import {
readToolResultDetails,
readToolResultStatus,
} from "./tool-result-error.js";
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
export { isToolResultError };
@@ -602,7 +603,7 @@ const TRUSTED_TOOL_RESULT_MEDIA = new Set([
"apply_patch",
"browser",
"canvas",
"cron",
AUTOMATIONS_TOOL_NAME,
"edit",
"exec",
"gateway",
+2 -1
View File
@@ -10,10 +10,11 @@ import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope-config.
import type { AnyAgentTool } from "./agent-tools.types.js";
import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js";
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
const LOCAL_MODEL_LEAN_DENY_TOOL_NAMES = new Set([
"browser",
"cron",
AUTOMATIONS_TOOL_NAME,
"image_generate",
"message",
"music_generate",
+20
View File
@@ -0,0 +1,20 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { buildOpenClawToolFallbackText } from "./prompt-surface.js";
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
describe("buildOpenClawToolFallbackText", () => {
it("teaches the canonical scheduler tool, never the legacy alias", () => {
const text = buildOpenClawToolFallbackText({
surface: "openclaw_main",
execToolName: "exec",
processToolName: "process",
});
expect(text).toContain(`- ${AUTOMATIONS_TOOL_NAME}:`);
// The fallback is model-facing guidance; it must not teach "cron" as a
// tool name even though the inbound alias stays accepted (RFC 0026).
expect(text).not.toMatch(/-\s*cron\b/);
expect(text.toLowerCase()).not.toContain("cron job");
});
});
+2 -1
View File
@@ -6,6 +6,7 @@
import { isOpenClawMainPromptSurface } from "../plugins/agent-prompt-surface-kind.js";
import type { AgentPromptSurfaceKind } from "../plugins/types.js";
import { isAcpSessionKey, isSubagentSessionKey } from "../routing/session-key.js";
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
/** Builds fallback tool guidance when a runtime cannot render the structured tool list. */
export function buildOpenClawToolFallbackText(params: {
@@ -25,7 +26,7 @@ export function buildOpenClawToolFallbackText(params: {
"- browser: control OpenClaw's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
`- ${AUTOMATIONS_TOOL_NAME}: manage automations (scheduled jobs) and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)`,
"- conversations_list: list exact external conversation addresses",
"- conversations_send: send directly to an external conversation",
"- conversations_turn: send and wait for a correlated external reply",
+2 -1
View File
@@ -6,6 +6,7 @@
import path from "node:path";
import { CHANNEL_IDS } from "../../channels/ids.js";
import { STATE_DIR } from "../../config/paths.js";
import { AUTOMATIONS_TOOL_NAME } from "../tools/automations-tool-name.js";
export { DEFAULT_SANDBOX_BROWSER_NETWORK } from "./browser-network.js";
@@ -46,7 +47,7 @@ export const DEFAULT_TOOL_DENY = [
"computer",
"mobile_ui",
"nodes",
"cron",
AUTOMATIONS_TOOL_NAME,
"gateway",
...CHANNEL_IDS,
] as const;
+4 -2
View File
@@ -62,6 +62,7 @@ import type {
ProviderSystemPromptSectionId,
} from "./system-prompt-contribution.js";
import type { PromptMode, SilentReplyPromptMode } from "./system-prompt.types.js";
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
import {
buildWatchedSessionsPromptLines,
type PreparedWatchedSessionsPrompt,
@@ -863,7 +864,8 @@ export function buildAgentSystemPrompt(params: {
"Own visible shell. Use for long/interactive jobs user should watch. exec for quiet work",
canvas: "Present/eval/snapshot Canvas",
nodes: "Paired node status/control/media",
cron: "Schedule/wake. Reminder text must read as reminder when fired; mention reminder for delayed gaps; include useful recent context.",
[AUTOMATIONS_TOOL_NAME]:
"Schedule/wake. Reminder text must read as reminder when fired; mention reminder for delayed gaps; include useful recent context.",
message: "Message/channel actions",
conversations_list: "List exact external conversation addresses",
conversations_send: "Send directly to an external conversation",
@@ -905,7 +907,7 @@ export function buildAgentSystemPrompt(params: {
"terminal",
"canvas",
"nodes",
"cron",
AUTOMATIONS_TOOL_NAME,
"message",
"conversations_list",
"conversations_send",
@@ -16,7 +16,7 @@ vi.mock("../tools/computer-tool.js", () => ({
}));
vi.mock("../tools/cron-tool.js", () => ({
createCronTool: () => stubTool("cron"),
createCronTool: () => stubTool("automations"),
}));
vi.mock("../tools/gateway-tool.js", () => ({
@@ -25,7 +25,7 @@ function stubActionTool(name: string, actions: string[]) {
const coreTools = [
stubActionTool("canvas", ["create", "read"]),
stubActionTool("nodes", ["list", "invoke"]),
stubActionTool("cron", ["schedule", "cancel"]),
stubActionTool("automations", ["schedule", "cancel"]),
stubActionTool("message", ["send", "reply"]),
stubTool("heartbeat_respond"),
stubActionTool("gateway", ["config.get", "config.schema.lookup"]),
+1 -1
View File
@@ -63,7 +63,7 @@ describe("tool-catalog", () => {
"screen",
"dashboard",
"terminal",
"cron",
"automations",
"get_goal",
"create_goal",
"update_goal",
+3 -2
View File
@@ -23,6 +23,7 @@ import {
DISMISS_TASK_TOOL_DISPLAY_SUMMARY,
UPDATE_PLAN_TOOL_DISPLAY_SUMMARY,
} from "./tool-description-presets.js";
import { AUTOMATIONS_TOOL_NAME } from "./tools/automations-tool-name.js";
/** Built-in tool profile ids exposed in config and UI. */
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
@@ -341,8 +342,8 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
includeInOpenClawGroup: true,
},
{
id: "cron",
label: "cron",
id: AUTOMATIONS_TOOL_NAME,
label: AUTOMATIONS_TOOL_NAME,
description: CRON_TOOL_DISPLAY_SUMMARY,
sectionId: "automation",
profiles: ["coding"],
+7 -1
View File
@@ -1,4 +1,8 @@
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import {
AUTOMATIONS_TOOL_NAME,
LEGACY_AUTOMATIONS_TOOL_NAMES,
} from "./tools/automations-tool-name.js";
const MUTATING_TOOL_NAMES = new Set([
"write",
@@ -11,7 +15,9 @@ const MUTATING_TOOL_NAMES = new Set([
"sessions",
"sessions_spawn",
"sessions_send",
"cron",
AUTOMATIONS_TOOL_NAME,
// Saved transcripts predate the rename; legacy names must stay classified.
...LEGACY_AUTOMATIONS_TOOL_NAMES,
"gateway",
"canvas",
"computer",
+3
View File
@@ -267,7 +267,10 @@ describe("tool mutation helpers", () => {
plan: [{ step: "Inspect", status: "in_progress" }],
}),
).toBe(true);
expect(isReplaySafeToolCall("automations", { action: "status" })).toBe(true);
// Legacy transcript entries predate the rename and must stay classified.
expect(isReplaySafeToolCall("cron", { action: "status" })).toBe(true);
expect(isReplaySafeToolCall("cron", { action: "add" })).toBe(false);
expect(isReplaySafeToolCall("gateway", { action: "config.get" })).toBe(true);
expect(isReplaySafeToolCall("gateway", { action: "config.schema.lookup" })).toBe(true);
expect(isReplaySafeToolCall("gateway", { action: "config.patch" })).toBe(false);
+3 -2
View File
@@ -9,6 +9,7 @@ import {
normalizeOptionalLowercaseString,
} from "@openclaw/normalization-core/string-coerce";
import { isLikelyMutatingToolName } from "./tool-mutation-names.js";
import { isAutomationsToolName } from "./tools/automations-tool-name.js";
export { isLikelyMutatingToolName };
@@ -373,7 +374,7 @@ export function isMutatingToolCall(toolName: string, args: unknown): boolean {
case "nodes":
return action == null || !NODES_REPLAY_SAFE_ACTIONS.has(action);
default: {
if (normalized === "cron" || normalized === "canvas") {
if (isAutomationsToolName(normalized) || normalized === "canvas") {
return action == null || !READ_ONLY_ACTIONS.has(action);
}
if (normalized.endsWith("_actions")) {
@@ -424,7 +425,7 @@ export function isReplaySafeToolCall(toolName: string, args: unknown): boolean {
case "nodes":
return action != null && NODES_REPLAY_SAFE_ACTIONS.has(action);
default: {
if (normalized === "cron" || normalized === "canvas") {
if (isAutomationsToolName(normalized) || normalized === "canvas") {
return action != null && READ_ONLY_ACTIONS.has(action);
}
return false;
+2
View File
@@ -19,6 +19,8 @@ type ToolProfilePolicy = {
const TOOL_NAME_ALIASES: Record<string, string> = {
bash: "exec",
"apply-patch": "apply_patch",
// Permanent scheduler-tool alias (owner decision, RFC 0026), like bash -> exec.
cron: "automations",
};
const TOOL_ALLOWLIST_INTERSECTION = Symbol.for("openclaw.toolAllowlistIntersection");
+14 -1
View File
@@ -34,7 +34,7 @@ describe("tool-policy", () => {
it("resolves known profiles and ignores unknown ones", () => {
const coding = resolveToolProfilePolicy("coding");
expect(coding?.allow).toContain("read");
expect(coding?.allow).toContain("cron");
expect(coding?.allow).toContain("automations");
expect(coding?.allow).not.toContain("gateway");
expect(resolveToolProfilePolicy("nope")).toBeUndefined();
});
@@ -52,6 +52,9 @@ describe("tool-policy", () => {
expect(normalizeToolName(" BASH ")).toBe("exec");
expect(normalizeToolName("apply-patch")).toBe("apply_patch");
expect(normalizeToolName("READ")).toBe("read");
// Pre-rename scheduler tool name from persisted config (RFC 0026).
expect(normalizeToolName("cron")).toBe("automations");
expect(normalizeToolName("automations")).toBe("automations");
});
it("collects explicit allowlist entries", () => {
@@ -180,6 +183,16 @@ describe("resolveSandboxToolPolicyForAgent", () => {
});
});
describe("isToolAllowedByPolicyName — legacy scheduler tool name (RFC 0026)", () => {
it("allows the renamed tool through persisted legacy allow lists", () => {
expect(isToolAllowedByPolicyName("automations", { allow: ["cron"] })).toBe(true);
});
it("denies the renamed tool through persisted legacy deny lists", () => {
expect(isToolAllowedByPolicyName("automations", { deny: ["cron"] })).toBe(false);
});
});
describe("isToolAllowedByPolicyName — apply_patch / write deny decoupling (#76749)", () => {
it("does not deny apply_patch when write is denied", () => {
expect(isToolAllowedByPolicyName("apply_patch", { deny: ["write"] })).toBe(true);
+1 -1
View File
@@ -102,7 +102,7 @@ const QUERY_EXPANSIONS: ReadonlyArray<{ terms: readonly string[]; add: readonly
},
{
terms: ["remind", "reminder", "later", "tomorrow", "daily", "weekly", "recurring"],
add: ["schedule", "cron", "reminder"],
add: ["schedule", "automations", "cron", "reminder"],
},
{ terms: ["say", "tell", "reply", "respond", "answer"], add: ["message", "send"] },
{ terms: ["picture", "photo", "meme", "screenshot"], add: ["image"] },
+22
View File
@@ -0,0 +1,22 @@
/**
* Canonical identity of the scheduler agent tool. Single source of truth for
* every name-keyed consumer (policy lists, factory descriptors, runtime
* observers, prompts); never spell the tool name as a string literal.
*/
export const AUTOMATIONS_TOOL_NAME = "automations";
/**
* "cron" is a permanently accepted alias for the scheduler tool in persisted
* allow/deny config, old transcripts, and inbound calls (owner decision,
* RFC 0026; same contract as bash -> exec). Not migration debt: no doctor
* rewrite, no removal window.
*/
export const LEGACY_AUTOMATIONS_TOOL_NAMES = ["cron"] as const;
/** True when a tool name refers to the scheduler tool, including legacy names. */
export function isAutomationsToolName(name: string): boolean {
return (
name === AUTOMATIONS_TOOL_NAME ||
(LEGACY_AUTOMATIONS_TOOL_NAMES as readonly string[]).includes(name)
);
}
@@ -24,10 +24,11 @@ describe("cron tool creator cap", () => {
capCronJobToolsAllowOnCreate(triggerJob, ["read", "cron"]);
capCronJobToolsAllowOnCreate(plainJob, ["read", "cron"]);
// Legacy "cron" creator allowlists normalize to the canonical tool id.
expect(triggerJob.payload).toEqual({
kind: "systemEvent",
text: "wake",
toolsAllow: ["read", "cron"],
toolsAllow: ["read", "automations"],
toolsAllowIsDefault: true,
});
expect(plainJob.payload).toEqual({ kind: "systemEvent", text: "wake" });
@@ -103,7 +104,7 @@ describe("cron tool creator cap", () => {
payload: {
kind: "agentTurn",
message: "updated",
toolsAllow: ["read", "cron"],
toolsAllow: ["read", "automations"],
toolsAllowIsDefault: true,
},
});
+6 -6
View File
@@ -1414,7 +1414,7 @@ describe("cron tool", () => {
const params = expectSingleGatewayCallMethod("cron.add") as
| { payload?: { toolsAllow?: string[] } }
| undefined;
expect(params?.payload?.toolsAllow).toEqual(["read", "cron"]);
expect(params?.payload?.toolsAllow).toEqual(["read", "automations"]);
});
it("caps trigger-script systemEvent adds to the creator tool surface", async () => {
@@ -1437,7 +1437,7 @@ describe("cron tool", () => {
const params = expectSingleGatewayCallMethod("cron.add") as
| { payload?: { toolsAllow?: string[] } }
| undefined;
expect(params?.payload?.toolsAllow).toEqual(["read", "cron"]);
expect(params?.payload?.toolsAllow).toEqual(["read", "automations"]);
});
it("infers systemEvent for implicit text payloads with toolsAllow", async () => {
@@ -1492,7 +1492,7 @@ describe("cron tool", () => {
trigger: { script: "return { fire: false }" },
payload: {
kind: "systemEvent",
toolsAllow: ["read", "cron"],
toolsAllow: ["read", "automations"],
toolsAllowIsDefault: true,
},
},
@@ -1576,7 +1576,7 @@ describe("cron tool", () => {
expect(params?.payload?.toolsAllow).toEqual([
"active_memory_search",
"active_memory_store",
"cron",
"automations",
]);
});
@@ -2973,7 +2973,7 @@ describe("cron tool", () => {
| undefined;
expect(params?.patch?.payload).toEqual({
kind: "agentTurn",
toolsAllow: ["read", "cron"],
toolsAllow: ["read", "automations"],
toolsAllowIsDefault: true,
});
});
@@ -3188,7 +3188,7 @@ describe("cron tool", () => {
payload: {
kind: "agentTurn",
message: "run later",
toolsAllow: ["read", "cron"],
toolsAllow: ["read", "automations"],
toolsAllowIsDefault: true,
},
},
+3 -2
View File
@@ -20,6 +20,7 @@ import { resolveSessionAgentId } from "../agent-scope.js";
import { CRON_TOOL_DISPLAY_SUMMARY } from "../tool-description-presets.js";
import { normalizeToolName } from "../tool-policy.js";
import { setToolTerminalPresentation } from "../tool-terminal-presentation.js";
import { AUTOMATIONS_TOOL_NAME } from "./automations-tool-name.js";
import {
type AnyAgentTool,
jsonResult,
@@ -284,8 +285,8 @@ function isOlderGatewayWithoutCompactCronList(error: unknown): boolean {
export function createCronTool(opts?: CronToolOptions, deps?: CronToolDeps): AnyAgentTool {
const callGateway = deps?.callGatewayTool ?? callGatewayTool;
const tool: AnyAgentTool = {
label: "Cron",
name: "cron",
label: "Automations",
name: AUTOMATIONS_TOOL_NAME,
displaySummary: CRON_TOOL_DISPLAY_SUMMARY,
description: `Gateway scheduler: reminders, delayed self-wakeups, loops, recurring work, event watchers. Never exec sleep/poll as timer.
@@ -99,7 +99,7 @@ describe("gateway CLI backend live probe helpers", () => {
activateLoopbackRuntime(port);
try {
await expect(verifyCliCronMcpLoopbackPreflight(preflightParams())).rejects.toThrow(
"mcp loopback tools/list did not expose cron",
"mcp loopback tools/list did not expose automations",
);
expect(methods).toEqual(["initialize", "notifications/initialized", "tools/list"]);
} finally {
@@ -3,6 +3,7 @@
import { randomUUID } from "node:crypto";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { renderCatFacePngBase64 } from "../../test/helpers/live-image-probe.js";
import { AUTOMATIONS_TOOL_NAME } from "../agents/tools/automations-tool-name.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { readResponseWithLimit } from "../infra/http-body.js";
import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
@@ -298,10 +299,10 @@ export async function verifyCliCronMcpLoopbackPreflight(params: {
.filter(Boolean);
logCliCronProbe("loopback-preflight:tools", {
toolCount: toolNames.length,
cronVisible: toolNames.includes("cron"),
cronVisible: toolNames.includes(AUTOMATIONS_TOOL_NAME),
});
if (!toolNames.includes("cron")) {
throw new Error("mcp loopback tools/list did not expose cron");
if (!toolNames.includes(AUTOMATIONS_TOOL_NAME)) {
throw new Error(`mcp loopback tools/list did not expose ${AUTOMATIONS_TOOL_NAME}`);
}
const toolCall = await callLoopbackJsonRpc({
@@ -314,7 +315,7 @@ export async function verifyCliCronMcpLoopbackPreflight(params: {
id: "cron-add",
method: "tools/call",
params: {
name: "cron",
name: AUTOMATIONS_TOOL_NAME,
arguments: JSON.parse(cronProbe.argsJson) as Record<string, unknown>,
},
},
+5 -3
View File
@@ -67,8 +67,10 @@ describe("live-agent-probes", () => {
expect(claudeRetryPrompt).toContain(
"Preserve job.sessionTarget and job.sessionKey exactly as provided.",
);
expect(claudeRetryPrompt).toContain("search/load MCP tools for `openclaw cron` or `cron`");
expect(claudeRetryPrompt).toContain("mcp__openclaw__cron");
expect(claudeRetryPrompt).toContain(
"search/load MCP tools for `openclaw automations` or `automations`",
);
expect(claudeRetryPrompt).toContain("mcp__openclaw__automations");
expect(claudeRetryPrompt).toContain("Do not use Claude native `CronCreate`");
expect(claudeRetryPrompt).not.toContain("openclaw-tools");
expect(
@@ -86,7 +88,7 @@ describe("live-agent-probes", () => {
attempt: 1,
exactReply: spec.name,
}),
).toContain("previous OpenClaw cron MCP tool call was cancelled");
).toContain("previous OpenClaw automations MCP tool call was cancelled");
const args = JSON.parse(spec.argsJson) as {
job?: {
sessionTarget?: string;
+9 -9
View File
@@ -113,9 +113,9 @@ export function buildLiveCronProbeMessage(params: {
const claudeLike = isClaudeLikeLiveAgent(params.agent);
if (params.attempt === 0) {
return (
"Use the OpenClaw MCP cron tool from server `openclaw`. " +
"If it is not already visible, search/load MCP tools for `openclaw cron` or `cron`, " +
"then call the matching OpenClaw MCP tool; Claude-style names may appear as `mcp__openclaw__cron`. " +
"Use the OpenClaw MCP automations tool from server `openclaw`. " +
"If it is not already visible, search/load MCP tools for `openclaw automations` or `automations`, " +
"then call the matching OpenClaw MCP tool; Claude-style names may appear as `mcp__openclaw__automations`. " +
"Do not use Claude native `CronCreate`, `CronList`, or `CronDelete`; those are not OpenClaw proof. " +
`Call it with JSON arguments ${params.argsJson}. ` +
"Preserve the JSON exactly, including job.sessionTarget and job.sessionKey; do not omit, rename, or flatten those fields. " +
@@ -125,9 +125,9 @@ export function buildLiveCronProbeMessage(params: {
}
if (claudeLike) {
return (
"Retry the OpenClaw MCP cron tool from server `openclaw` now. " +
"If it is not already visible, search/load MCP tools for `openclaw cron` or `cron`, " +
"then call the matching OpenClaw MCP tool; Claude-style names may appear as `mcp__openclaw__cron`. " +
"Retry the OpenClaw MCP automations tool from server `openclaw` now. " +
"If it is not already visible, search/load MCP tools for `openclaw automations` or `automations`, " +
"then call the matching OpenClaw MCP tool; Claude-style names may appear as `mcp__openclaw__automations`. " +
"Do not use Claude native `CronCreate`, `CronList`, or `CronDelete`; those are not OpenClaw proof. " +
`Use these exact JSON arguments: ${params.argsJson}. ` +
"Preserve job.sessionTarget and job.sessionKey exactly as provided. " +
@@ -138,9 +138,9 @@ export function buildLiveCronProbeMessage(params: {
);
}
return (
"Your previous OpenClaw cron MCP tool call was cancelled before the job was created. " +
"Retry the OpenClaw MCP cron tool from server `openclaw` now. " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw__cron`. " +
"Your previous OpenClaw automations MCP tool call was cancelled before the job was created. " +
"Retry the OpenClaw MCP automations tool from server `openclaw` now. " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw__automations`. " +
`Use these exact JSON arguments: ${params.argsJson}. ` +
"Preserve job.sessionTarget and job.sessionKey exactly as provided. " +
`If the cron job is created, reply exactly: ${params.exactReply}. ` +
+12 -1
View File
@@ -9,6 +9,7 @@ import {
resolveToolExecutionErrorKind,
resolveToolResultFailureKind,
} from "../agents/tool-result-error.js";
import { isAutomationsToolName } from "../agents/tools/automations-tool-name.js";
import type { McpLoopbackToolCallOutcome } from "./mcp-http.loopback-runtime.js";
import {
MCP_LOOPBACK_SERVER_NAME,
@@ -97,7 +98,17 @@ export async function handleMcpJsonRpc(params: {
case "tools/list":
return jsonRpcResult(id, { tools: params.toolSchema });
case "tools/call": {
const toolName = typeof methodParams?.name === "string" ? methodParams.name.trim() : "";
const requestedToolName =
typeof methodParams?.name === "string" ? methodParams.name.trim() : "";
// "cron" is a permanently accepted inbound alias for the scheduler tool
// (owner decision, RFC 0026; same contract as bash -> exec). Resolve it to
// the published canonical tool without re-advertising it in tools/list.
const toolName =
!params.toolSchema.some((tool) => tool.name === requestedToolName) &&
isAutomationsToolName(requestedToolName)
? (params.toolSchema.find((tool) => isAutomationsToolName(tool.name))?.name ??
requestedToolName)
: requestedToolName;
const rawToolArgs = methodParams?.arguments;
if (rawToolArgs !== undefined && !isRecord(rawToolArgs)) {
return jsonRpcError(id, -32602, "Invalid params: tools/call arguments must be an object");
+38
View File
@@ -2834,6 +2834,44 @@ describe("mcp loopback server", () => {
expect(signal).toBeInstanceOf(AbortSignal);
});
it("resolves legacy cron tools/call names to the renamed automations tool", async () => {
const execute = vi.fn<MockGatewayTool["execute"]>(async () => ({
content: [{ type: "text", text: "SCHEDULED" }],
}));
const tool = makeMockTool({ name: "automations", description: "manage schedules", execute });
const payload = await handleMcpJsonRpc({
message: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "cron", arguments: {} },
},
tools: [tool as unknown as AnyAgentTool],
toolSchema: buildMockMcpToolSchema([tool]),
});
expectMcpResultText(payload as McpToolResultPayload, "SCHEDULED", false);
expect(execute).toHaveBeenCalledTimes(1);
});
it("still rejects legacy cron calls when the automations tool is not exposed", async () => {
const tool = makeMessageTool();
const payload = await handleMcpJsonRpc({
message: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "cron", arguments: {} },
},
tools: [tool as unknown as AnyAgentTool],
toolSchema: buildMockMcpToolSchema([tool]),
});
expectMcpResultText(payload as McpToolResultPayload, "Tool not available: cron", true);
});
it("preserves request-disconnect evidence without classifying a tool failure", async () => {
const controller = new AbortController();
controller.abort();
+2 -1
View File
@@ -8,6 +8,7 @@ import {
} from "node:http";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { resolveToolLoopDetectionConfig } from "../agents/tool-loop-detection-config.js";
import { isAutomationsToolName } from "../agents/tools/automations-tool-name.js";
import { getRuntimeConfig } from "../config/io.js";
import { resolveSessionEntryAccessTarget } from "../config/sessions/session-accessor.js";
import { isTruthyEnvValue } from "../infra/env.js";
@@ -340,7 +341,7 @@ async function startMcpLoopbackServer(port = 0): Promise<{
inboundEventKind: requestContext.inboundEventKind,
senderIsOwner: requestContext.senderIsOwner,
toolCount: scopedTools.toolSchema.length,
cronVisible: scopedTools.toolSchema.some((tool) => tool.name === "cron"),
cronVisible: scopedTools.toolSchema.some((tool) => isAutomationsToolName(tool.name)),
});
const responses: object[] = [];
for (const [messageIndex, message] of messages.entries()) {
+18 -18
View File
@@ -78,7 +78,7 @@ const hoisted = vi.hoisted(() => {
createOpenClawToolsMock: vi.fn((_args: CreateOpenClawToolsArg) => [
makeTool("read"),
makeTool("sessions_spawn"),
makeTool("cron"),
makeTool("automations"),
makeTool("gateway"),
makeTool("nodes"),
]),
@@ -144,7 +144,7 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
expect(result.tools.map((tool) => tool.name)).toEqual([
"sessions_spawn",
"cron",
"automations",
"gateway",
"nodes",
]);
@@ -255,7 +255,7 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
it("does not fall back when policy removes a mediated coding tool", () => {
hoisted.createOpenClawToolsMock.mockReturnValueOnce([
hoisted.makeTool("write"),
hoisted.makeTool("cron"),
hoisted.makeTool("automations"),
]);
const result = resolveGatewayScopedTools({
@@ -266,7 +266,7 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
excludeToolNames: ["read", "edit", "apply_patch", "exec", "process"],
});
expect(result.tools.map((tool) => tool.name)).toEqual(["cron"]);
expect(result.tools.map((tool) => tool.name)).toEqual(["automations"]);
});
it("keeps owner-only core tools visible only for owner loopback callers", () => {
@@ -290,14 +290,14 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
expect(ownerResult.tools.map((tool) => tool.name)).toEqual([
"read",
"sessions_spawn",
"cron",
"automations",
"gateway",
"nodes",
]);
expect(nonOwnerResult.tools.map((tool) => tool.name)).toEqual(["read", "sessions_spawn"]);
const args = readCreateToolsArgs(1);
expect(args.pluginToolDenylist).toEqual([
"cron",
"automations",
"gateway",
"sessions",
"screen",
@@ -311,7 +311,7 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
"openclaw",
]);
expect(args.inheritedToolDenylist).toEqual([
"cron",
"automations",
"gateway",
"sessions",
"screen",
@@ -804,7 +804,7 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
it("does not inherit node-only exec as a generic child or cron capability", () => {
const result = resolveGatewayScopedTools({
cfg: { tools: { allow: ["exec", "sessions_spawn", "cron"] } } as OpenClawConfig,
cfg: { tools: { allow: ["exec", "sessions_spawn", "automations"] } } as OpenClawConfig,
sessionKey: "agent:main:direct:test",
surface: "loopback",
senderIsOwner: true,
@@ -820,7 +820,7 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
const result = resolveGatewayScopedTools({
cfg: {
agents: { defaults: { sandbox: { mode: "all" } } },
tools: { sandbox: { tools: { deny: ["cron"] } } },
tools: { sandbox: { tools: { deny: ["automations"] } } },
} as OpenClawConfig,
sessionKey: "agent:main:direct:test",
surface: "loopback",
@@ -829,36 +829,36 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
expect(result.tools.map((tool) => tool.name)).toEqual(["read", "sessions_spawn"]);
const args = readCreateToolsArgs();
expect(args.sandboxed).toBe(true);
expect(args.pluginToolDenylist).toEqual(["cron"]);
expect(args.inheritedToolDenylist).toEqual(["cron"]);
expect(args.pluginToolDenylist).toEqual(["automations"]);
expect(args.inheritedToolDenylist).toEqual(["automations"]);
});
it("passes final filtered tool surface to gateway cron jobs", () => {
hoisted.createOpenClawToolsMock.mockReturnValueOnce([
hoisted.makeTool("read"),
hoisted.makeTool("cron"),
hoisted.makeTool("automations"),
hoisted.makeTool("exec"),
]);
const result = resolveGatewayScopedTools({
cfg: {
tools: { allow: ["read", "cron"] },
tools: { allow: ["read", "automations"] },
} as OpenClawConfig,
sessionKey: "agent:main:direct:test",
surface: "loopback",
});
expect(result.tools.map((tool) => tool.name)).toEqual(["read", "cron"]);
expect(result.tools.map((tool) => tool.name)).toEqual(["read", "automations"]);
expect(readCreateToolsArgs().cronCreatorToolAllowlist).toEqual([
{ name: "read" },
{ name: "cron" },
{ name: "automations" },
]);
});
it("passes unrestricted gateway tool surfaces to cron jobs", () => {
hoisted.createOpenClawToolsMock.mockReturnValueOnce([
hoisted.makeTool("read"),
hoisted.makeTool("cron"),
hoisted.makeTool("automations"),
hoisted.makeTool("exec"),
]);
@@ -869,10 +869,10 @@ describe("resolveGatewayScopedTools excludeToolNames", () => {
senderIsOwner: true,
});
expect(result.tools.map((tool) => tool.name)).toEqual(["read", "cron", "exec"]);
expect(result.tools.map((tool) => tool.name)).toEqual(["read", "automations", "exec"]);
expect(readCreateToolsArgs().cronCreatorToolAllowlist).toEqual([
{ name: "read" },
{ name: "cron" },
{ name: "automations" },
{ name: "exec" },
]);
});
+8 -1
View File
@@ -193,7 +193,14 @@ export function resolveGatewayScopedTools(params: {
const gatewayToolsCfg = params.cfg.gateway?.tools;
const defaultGatewayDeny =
surface === "http"
? DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter((name) => !gatewayToolsCfg?.allow?.includes(name))
? DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter(
// Config allow entries may use legacy tool names (e.g. "cron");
// normalize both sides so they still lift the matching default deny.
(name) =>
!gatewayToolsCfg?.allow?.some(
(allowed) => normalizeToolName(allowed) === normalizeToolName(name),
),
)
: [];
const ownerOnlyGatewayDeny =
params.senderIsOwner === false || (surface === "http" && params.senderIsOwner !== true)
@@ -60,9 +60,9 @@ vi.mock("../plugins/tools.js", () => ({
vi.mock("../agents/openclaw-tools.js", () => {
const tools = [
{
name: "cron",
name: "automations",
parameters: { type: "object", properties: { action: { type: "string" } } },
execute: async () => ({ ok: true, via: "cron" }),
execute: async () => ({ ok: true, via: "automations" }),
},
{
name: "gateway",
+5 -4
View File
@@ -141,9 +141,9 @@ vi.mock("../agents/openclaw-tools.js", () => {
},
},
{
name: "cron",
name: "automations",
parameters: { type: "object", properties: {} },
execute: async () => ({ ok: true, result: "cron" }),
execute: async () => ({ ok: true, result: "automations" }),
},
{
name: "exec",
@@ -797,7 +797,7 @@ describe("POST /tools/invoke", () => {
const body = await expectOkInvokeResponse(res);
expect(body.result?.inheritedToolDenylist).toEqual(
expect.arrayContaining(["cron", "gateway", "nodes"]),
expect.arrayContaining(["automations", "gateway", "nodes"]),
);
});
@@ -1227,7 +1227,8 @@ describe("tools.invoke Gateway RPC", () => {
expect(call?.[0], tool).toBe(true);
expect(call?.[1]?.ok, tool).toBe(false);
expect(call?.[1]?.toolName, tool).toBe(tool);
// Legacy "cron" requests canonicalize before dispatch and report the canonical id.
expect(call?.[1]?.toolName, tool).toBe(tool === "cron" ? "automations" : tool);
const error = call?.[1]?.error as { code?: string; message?: string } | undefined;
expect(error?.code, tool).toBe("not_found");
}
+11 -1
View File
@@ -8,6 +8,10 @@ import { runBeforeToolCallHook } from "../agents/agent-tools.before-tool-call.js
import { resolveToolLoopDetectionConfig } from "../agents/agent-tools.js";
import { getChannelAgentToolMeta } from "../agents/channel-tools.js";
import { isKnownCoreToolId } from "../agents/tool-catalog.js";
import {
AUTOMATIONS_TOOL_NAME,
isAutomationsToolName,
} from "../agents/tools/automations-tool-name.js";
import { ToolInputError, type AnyAgentTool } from "../agents/tools/common.js";
import {
normalizeConversationReadInvocationOrigin,
@@ -174,7 +178,13 @@ export async function invokeGatewayTool(params: {
const conversationReadOrigin = normalizeConversationReadInvocationOrigin(
params.conversationReadOrigin,
);
const toolName = normalizeOptionalString(params.input.name ?? params.input.tool) ?? "";
const requestedToolName = normalizeOptionalString(params.input.name ?? params.input.tool) ?? "";
// "cron" is a permanently accepted inbound alias for the scheduler tool
// (owner decision, RFC 0026; same contract as bash -> exec). Canonicalize
// before core-id checks and exact-name dispatch below.
const toolName = isAutomationsToolName(requestedToolName)
? AUTOMATIONS_TOOL_NAME
: requestedToolName;
if (!toolName) {
return {
ok: false,
+1 -1
View File
@@ -28,7 +28,7 @@ describe("OpenClaw tools MCP server", () => {
);
const listed = await handlers.listTools();
expect(listed.tools.map((tool) => tool.name)).toContain("cron");
expect(listed.tools.map((tool) => tool.name)).toContain("automations");
});
it("requires the managed bridge to pass a real agent session key", () => {
+5 -1
View File
@@ -6,6 +6,7 @@
*/
import { pathToFileURL } from "node:url";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { AUTOMATIONS_TOOL_NAME } from "../agents/tools/automations-tool-name.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { createCronTool } from "../agents/tools/cron-tool.js";
import { createSystemAgentTool } from "../agents/tools/system-agent-tool.js";
@@ -57,7 +58,10 @@ export function resolveOpenClawToolsForMcp(
if (!agentSessionKey) {
throw new Error(`${OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV} is required`);
}
return createCronTool({ agentSessionKey, creatorToolAllowlist: [{ name: "cron" }] });
return createCronTool({
agentSessionKey,
creatorToolAllowlist: [{ name: AUTOMATIONS_TOOL_NAME }],
});
});
}
+9 -1
View File
@@ -8,6 +8,7 @@ import {
wrapToolWithBeforeToolCallHook,
} from "../agents/agent-tools.before-tool-call.js";
import { BEFORE_TOOL_CALL_HOOK_CONTEXT } from "../agents/before-tool-call-metadata.js";
import { isAutomationsToolName } from "../agents/tools/automations-tool-name.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { formatErrorMessage } from "../infra/errors.js";
import { coerceChatContentText } from "../shared/chat-content.js";
@@ -82,7 +83,14 @@ export function createPluginToolsMcpHandlers(tools: AnyAgentTool[]) {
})),
}),
callTool: async (params: CallPluginToolParams, signal?: AbortSignal) => {
const entry = toolMap.get(params.name);
// "cron" is a permanently accepted inbound alias for the scheduler tool
// (owner decision, RFC 0026; same contract as bash -> exec). Resolve it to
// the published canonical tool without re-advertising it in listTools.
const entry =
toolMap.get(params.name) ??
(isAutomationsToolName(params.name)
? Array.from(toolMap.entries()).find(([name]) => isAutomationsToolName(name))?.[1]
: undefined);
if (!entry) {
return {
content: [{ type: "text", text: `Unknown tool: ${params.name}` }],
+3 -3
View File
@@ -244,8 +244,8 @@ describe("security audit trust model findings", () => {
);
expect(finding?.severity).toBe("critical");
expect(finding?.detail).toContain("channels.whatsapp.groupPolicy");
expect(finding?.detail).toContain("controlPlane=[cron]");
expect(finding?.detail).not.toContain("controlPlane=[gateway, cron]");
expect(finding?.detail).toContain("controlPlane=[automations]");
expect(finding?.detail).not.toContain("controlPlane=[automations, gateway]");
},
},
{
@@ -278,7 +278,7 @@ describe("security audit trust model findings", () => {
(entry) => entry.checkId === "security.exposure.open_groups_with_control_plane_tools",
);
expect(finding?.detail).toContain(
"agents.defaults (profile=messaging; controlPlane=[cron])",
"agents.defaults (profile=messaging; controlPlane=[automations])",
);
},
},
+4 -3
View File
@@ -1,5 +1,6 @@
// Shared tool-risk constants.
// Keep these centralized so gateway HTTP restrictions and security audits don't drift.
import { AUTOMATIONS_TOOL_NAME } from "../agents/tools/automations-tool-name.js";
/**
* Tools denied via Gateway HTTP `POST /tools/invoke` by default.
@@ -32,7 +33,7 @@ export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [
"conversations_send",
"conversations_turn",
// Persistent automation control plane — can create/update/remove scheduled runs
"cron",
AUTOMATIONS_TOOL_NAME,
// Gateway config can expose secrets and host topology
"gateway",
// Node command relay can reach system.run on paired hosts
@@ -45,10 +46,10 @@ export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [
] as const;
/**
* Sensitive control-plane tools. `cron` can persist automation; `gateway`
* Sensitive control-plane tools. `automations` can persist scheduled runs; `gateway`
* exposes configuration and schema details even though its agent actions are read-only.
*/
export const GATEWAY_CONTROL_PLANE_TOOLS = ["cron", "gateway"] as const;
export const GATEWAY_CONTROL_PLANE_TOOLS = [AUTOMATIONS_TOOL_NAME, "gateway"] as const;
/**
* Core tools that require sender owner identity on Gateway-scoped surfaces.
+2 -2
View File
@@ -59,7 +59,7 @@ describe("resolveSkillDispatchTools", () => {
const args = hoisted.createOpenClawToolsMock.mock.calls[0]?.[0];
expect(tools.map((tool) => tool.name)).toEqual(["read", "cron"]);
expect(args?.cronCreatorToolAllowlist).toEqual([{ name: "read" }, { name: "cron" }]);
expect(args?.cronCreatorToolAllowlist).toEqual([{ name: "read" }, { name: "automations" }]);
expect(args?.nativeChannelId).toBe("native-room-1");
});
@@ -78,7 +78,7 @@ describe("resolveSkillDispatchTools", () => {
expect(tools.map((tool) => tool.name)).toEqual(["read", "cron", "exec"]);
expect(args?.cronCreatorToolAllowlist).toEqual([
{ name: "read" },
{ name: "cron" },
{ name: "automations" },
{ name: "exec" },
]);
});
+2 -1
View File
@@ -1,6 +1,7 @@
/** In-memory spoken confirmation binding for high-impact Talk actions. */
import { createHash, randomUUID } from "node:crypto";
import { buildToolMutationState } from "../agents/tool-mutation.js";
import { AUTOMATIONS_TOOL_NAME } from "../agents/tools/automations-tool-name.js";
const CONFIRMATION_TTL_MS = 2 * 60_000;
@@ -72,7 +73,7 @@ function requiresHighImpactVoiceConfirmation(toolName: string, params: unknown):
"computer",
"mobile_ui",
"canvas",
"cron",
AUTOMATIONS_TOOL_NAME,
"process",
].includes(normalizedTool)
) {
@@ -1100,7 +1100,7 @@
"required": ["action"],
"type": "object"
},
"name": "cron",
"name": "automations",
"type": "function"
},
{
@@ -1096,7 +1096,7 @@
"required": ["action"],
"type": "object"
},
"name": "cron",
"name": "automations",
"type": "function"
},
{
@@ -1096,7 +1096,7 @@
"required": ["action"],
"type": "object"
},
"name": "cron",
"name": "automations",
"type": "function"
},
{
@@ -78,7 +78,7 @@
"agents_list",
"message",
"sessions_spawn",
"cron",
"automations",
"gateway",
"nodes",
"session_status",
@@ -221,20 +221,20 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 61437,
"roughTokens": 15360
"chars": 61444,
"roughTokens": 15361
},
"openClawDeveloperInstructions": {
"chars": 3804,
"roughTokens": 951
"chars": 3811,
"roughTokens": 953
},
"totalTextOnly": {
"chars": 28187,
"roughTokens": 7047
"chars": 28194,
"roughTokens": 7049
},
"totalWithDynamicToolsJson": {
"chars": 89626,
"roughTokens": 22407
"chars": 89640,
"roughTokens": 22410
},
"userInputText": {
"chars": 1300,
@@ -421,7 +421,7 @@ Approval policy is currently never. Do not provide the `sandbox_permissions` for
````text
You are a personal agent running inside OpenClaw. OpenClaw has dynamic tools for OpenClaw-owned messaging, cron, sessions, media, gateway, and nodes.
Deferred searchable OpenClaw dynamic tools available: cron, gateway, nodes, session_status, sessions_history, sessions_list, sessions_search, sessions_send, subagents, tts, web_fetch, web_search. Use `tool_search` to load exact callable specs before use.
Deferred searchable OpenClaw dynamic tools available: automations, gateway, nodes, session_status, sessions_history, sessions_list, sessions_search, sessions_send, subagents, tts, web_fetch, web_search. Use `tool_search` to load exact callable specs before use.
Use Codex native `spawn_agent` for Codex subagents. `spawn_agent` and the other native collaboration tools may be deferred: when `spawn_agent` is not directly listed, load it with `tool_search` before spawning. Use OpenClaw `sessions_spawn` only for OpenClaw or ACP delegation, never as a substitute for `spawn_agent`.
@@ -526,7 +526,7 @@ Full JSON: `codex-dynamic-tools.discord-group.json`
"agents_list",
"message",
"sessions_spawn",
"cron",
"automations",
"gateway",
"nodes",
"session_status",
@@ -78,7 +78,7 @@
"agents_list",
"message",
"sessions_spawn",
"cron",
"automations",
"gateway",
"nodes",
"session_status",
@@ -221,20 +221,20 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 61129,
"roughTokens": 15283
"chars": 61136,
"roughTokens": 15284
},
"openClawDeveloperInstructions": {
"chars": 2695,
"roughTokens": 674
"chars": 2702,
"roughTokens": 676
},
"totalTextOnly": {
"chars": 26707,
"roughTokens": 6677
"chars": 26714,
"roughTokens": 6679
},
"totalWithDynamicToolsJson": {
"chars": 87838,
"roughTokens": 21960
"chars": 87852,
"roughTokens": 21963
},
"userInputText": {
"chars": 929,
@@ -421,7 +421,7 @@ Approval policy is currently never. Do not provide the `sandbox_permissions` for
````text
You are a personal agent running inside OpenClaw. OpenClaw has dynamic tools for OpenClaw-owned messaging, cron, sessions, media, gateway, and nodes.
Deferred searchable OpenClaw dynamic tools available: cron, gateway, nodes, session_status, sessions_history, sessions_list, sessions_search, sessions_send, subagents, tts, web_fetch, web_search. Use `tool_search` to load exact callable specs before use.
Deferred searchable OpenClaw dynamic tools available: automations, gateway, nodes, session_status, sessions_history, sessions_list, sessions_search, sessions_send, subagents, tts, web_fetch, web_search. Use `tool_search` to load exact callable specs before use.
Use Codex native `spawn_agent` for Codex subagents. `spawn_agent` and the other native collaboration tools may be deferred: when `spawn_agent` is not directly listed, load it with `tool_search` before spawning. Use OpenClaw `sessions_spawn` only for OpenClaw or ACP delegation, never as a substitute for `spawn_agent`.
@@ -520,7 +520,7 @@ Full JSON: `codex-dynamic-tools.telegram-direct.json`
"agents_list",
"message",
"sessions_spawn",
"cron",
"automations",
"gateway",
"nodes",
"session_status",
@@ -78,7 +78,7 @@
"agents_list",
"message",
"sessions_spawn",
"cron",
"automations",
"gateway",
"nodes",
"session_status",
@@ -222,20 +222,20 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 62661,
"roughTokens": 15666
"chars": 62668,
"roughTokens": 15667
},
"openClawDeveloperInstructions": {
"chars": 2695,
"roughTokens": 674
"chars": 2702,
"roughTokens": 676
},
"totalTextOnly": {
"chars": 27136,
"roughTokens": 6784
"chars": 27143,
"roughTokens": 6786
},
"totalWithDynamicToolsJson": {
"chars": 89799,
"roughTokens": 22450
"chars": 89813,
"roughTokens": 22454
},
"userInputText": {
"chars": 1284,
@@ -422,7 +422,7 @@ Approval policy is currently never. Do not provide the `sandbox_permissions` for
````text
You are a personal agent running inside OpenClaw. OpenClaw has dynamic tools for OpenClaw-owned messaging, cron, sessions, media, gateway, and nodes.
Deferred searchable OpenClaw dynamic tools available: cron, gateway, nodes, session_status, sessions_history, sessions_list, sessions_search, sessions_send, subagents, tts, web_fetch, web_search. Use `tool_search` to load exact callable specs before use.
Deferred searchable OpenClaw dynamic tools available: automations, gateway, nodes, session_status, sessions_history, sessions_list, sessions_search, sessions_send, subagents, tts, web_fetch, web_search. Use `tool_search` to load exact callable specs before use.
Use Codex native `spawn_agent` for Codex subagents. `spawn_agent` and the other native collaboration tools may be deferred: when `spawn_agent` is not directly listed, load it with `tool_search` before spawning. Use OpenClaw `sessions_spawn` only for OpenClaw or ACP delegation, never as a substitute for `spawn_agent`.
@@ -521,7 +521,7 @@ Full JSON: `codex-dynamic-tools.heartbeat-turn.json`
"agents_list",
"message",
"sessions_spawn",
"cron",
"automations",
"gateway",
"nodes",
"session_status",
@@ -59,7 +59,7 @@ const CODEX_YOLO_PERMISSION_INSTRUCTIONS = [
].join("\n");
const HAPPY_PATH_TOOL_NAMES = new Set([
"nodes",
"cron",
"automations",
"message",
"heartbeat_respond",
"tts",