diff --git a/CHANGELOG.md b/CHANGELOG.md index 5058efea727..372b83dc661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Control UI/cron: keep the runtime-only `last` delivery sentinel from being materialized into persisted cron delivery and failure-alert channel configs when jobs are created or edited. (#68829) Thanks @tianhaocui. - OpenAI/Responses: strip orphaned reasoning blocks before outbound Responses API calls so compacted or restored histories no longer fail on standalone reasoning items. (#55787) Thanks @suboss87. +- Cron/CLI: parse PowerShell-style `--tools` allow-lists the same way as comma-separated input, so `cron add` and `cron edit` no longer persist `exec read write` as one combined tool entry on Windows. (#68858) Thanks @chen-zhang-cs-code. ## 2026.4.19-beta.2 diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 96c7ec5fbea..876e9aaec70 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -60,6 +60,7 @@ type CronUpdatePatch = { model?: string; thinking?: string; lightContext?: boolean; + toolsAllow?: string[]; }; delivery?: { mode?: string; @@ -73,7 +74,12 @@ type CronUpdatePatch = { type CronAddParams = { schedule?: { kind?: string; staggerMs?: number }; - payload?: { model?: string; thinking?: string; lightContext?: boolean }; + payload?: { + model?: string; + thinking?: string; + lightContext?: boolean; + toolsAllow?: string[]; + }; delivery?: { mode?: string; accountId?: string }; deleteAfterRun?: boolean; agentId?: string; @@ -418,6 +424,23 @@ describe("cron cli", () => { expect(params?.payload?.lightContext).toBe(true); }); + it("splits PowerShell-style space-separated --tools on cron add", async () => { + const params = await runCronAddAndGetParams([ + "--name", + "Tools", + "--cron", + "* * * * *", + "--session", + "isolated", + "--message", + "hello", + "--tools", + "exec read write", + ]); + + expect(params?.payload?.toolsAllow).toEqual(["exec", "read", "write"]); + }); + it.each([ { label: "omits empty model and thinking", @@ -437,6 +460,17 @@ describe("cron cli", () => { expect(patch?.patch?.payload?.thinking).toBe(expectedThinking); }); + it("splits PowerShell-style space-separated --tools on cron edit", async () => { + const patch = await runCronEditAndGetPatch([ + "--message", + "hello", + "--tools", + "exec read write", + ]); + + expect(patch?.patch?.payload?.toolsAllow).toEqual(["exec", "read", "write"]); + }); + it("sets and clears agent id on cron edit", async () => { await runCronCommand(["cron", "edit", "job-1", "--agent", " Ops ", "--message", "hello"]); diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index b41aebd8c5e..3c0ec6b11e4 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -13,6 +13,7 @@ import { resolveCronCreateSchedule } from "./schedule-options.js"; import { getCronChannelOptions, handleCronCliError, + parseCronToolsAllow, printCronJson, printCronList, warnIfCronSchedulerDisabled, @@ -93,7 +94,7 @@ export function registerCronAddCommand(cron: Command) { .option("--model ", "Model override for agent jobs (provider/model or alias)") .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--light-context", "Use lightweight bootstrap context for agent jobs", false) - .option("--tools ", "Comma-separated tool allow-list (e.g. exec,read,write)") + .option("--tools ", "Tool allow-list (e.g. exec,read,write or exec read write)") .option("--announce", "Announce summary to a chat (subagent-style)", false) .option("--deliver", "Deprecated (use --announce). Announces a summary to a chat.") .option("--no-deliver", "Disable announce delivery and skip main-session summary") @@ -150,13 +151,7 @@ export function registerCronAddCommand(cron: Command) { timeoutSeconds: timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined, lightContext: opts.lightContext === true ? true : undefined, - toolsAllow: - typeof opts.tools === "string" && opts.tools.trim() - ? opts.tools - .split(",") - .map((t: string) => normalizeOptionalString(t)) - .filter((t): t is string => Boolean(t)) - : undefined, + toolsAllow: parseCronToolsAllow(opts.tools), }; })(); diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index b689b470360..9fac949b152 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -12,7 +12,12 @@ import { applyExistingCronSchedulePatch, resolveCronEditScheduleRequest, } from "./schedule-options.js"; -import { getCronChannelOptions, parseDurationMs, warnIfCronSchedulerDisabled } from "./shared.js"; +import { + getCronChannelOptions, + parseCronToolsAllow, + parseDurationMs, + warnIfCronSchedulerDisabled, +} from "./shared.js"; const assignIf = ( target: Record, @@ -59,7 +64,7 @@ export function registerCronEditCommand(cron: Command) { .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--light-context", "Enable lightweight bootstrap context for agent jobs") .option("--no-light-context", "Disable lightweight bootstrap context for agent jobs") - .option("--tools ", "Comma-separated tool allow-list (e.g. exec,read,write)") + .option("--tools ", "Tool allow-list (e.g. exec,read,write or exec read write)") .option("--clear-tools", "Remove tool allow-list (use all tools)", false) .option("--announce", "Announce summary to a chat (subagent-style)") .option("--deliver", "Deprecated (use --announce). Announces a summary to a chat.") @@ -175,6 +180,7 @@ export function registerCronEditCommand(cron: Command) { const hasSystemEventPatch = typeof opts.systemEvent === "string"; const model = normalizeOptionalString(opts.model); const thinking = normalizeOptionalString(opts.thinking); + const toolsAllow = parseCronToolsAllow(opts.tools); const timeoutSeconds = opts.timeoutSeconds ? Number.parseInt(String(opts.timeoutSeconds), 10) : undefined; @@ -190,6 +196,7 @@ export function registerCronEditCommand(cron: Command) { hasTimeoutSeconds || typeof opts.lightContext === "boolean" || typeof opts.tools === "string" || + Array.isArray(opts.tools) || opts.clearTools || hasDeliveryModeFlag || hasDeliveryTarget || @@ -217,11 +224,8 @@ export function registerCronEditCommand(cron: Command) { ); if (opts.clearTools) { payload.toolsAllow = null; - } else if (typeof opts.tools === "string" && opts.tools.trim()) { - payload.toolsAllow = opts.tools - .split(",") - .map((t: string) => t.trim()) - .filter(Boolean); + } else if (toolsAllow) { + payload.toolsAllow = toolsAllow; } patch.payload = payload; } diff --git a/src/cli/cron-cli/shared.test.ts b/src/cli/cron-cli/shared.test.ts index ca593cd5bfd..6394fc1767e 100644 --- a/src/cli/cron-cli/shared.test.ts +++ b/src/cli/cron-cli/shared.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CronJob } from "../../cron/types.js"; import type { RuntimeEnv } from "../../runtime.js"; -import { getCronChannelOptions, printCronList } from "./shared.js"; +import { getCronChannelOptions, parseCronToolsAllow, printCronList } from "./shared.js"; const hoisted = vi.hoisted(() => ({ listChannelPluginsMock: vi.fn(), @@ -192,3 +192,19 @@ describe("getCronChannelOptions", () => { expect(getCronChannelOptions()).toBe("last|telegram|signal"); }); }); + +describe("parseCronToolsAllow", () => { + it.each([ + { input: "exec,read,write", expected: ["exec", "read", "write"] }, + { input: "exec, read, write", expected: ["exec", "read", "write"] }, + { input: "exec read write", expected: ["exec", "read", "write"] }, + { input: " exec read,write ", expected: ["exec", "read", "write"] }, + { input: ["exec", "read", "write"], expected: ["exec", "read", "write"] }, + ])("parses $input", ({ input, expected }) => { + expect(parseCronToolsAllow(input)).toEqual(expected); + }); + + it("returns undefined for empty input", () => { + expect(parseCronToolsAllow(" , ")).toBeUndefined(); + }); +}); diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index d1160fde747..16872061f54 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -9,7 +9,10 @@ import { parseOffsetlessIsoDateTimeInTimeZone, } from "../../infra/format-time/parse-offsetless-zoned-datetime.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; -import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../shared/string-coerce.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { callGatewayFromCli } from "../gateway-rpc.js"; @@ -99,6 +102,19 @@ export function parseCronStaggerMs(params: { return parsed; } +export function parseCronToolsAllow(input: unknown): string[] | undefined { + const raw = Array.isArray(input) + ? input.map((value) => String(value)).join(" ") + : typeof input === "string" + ? input + : ""; + const tools = raw + .split(/[,\s]+/u) + .map((tool) => normalizeOptionalString(tool)) + .filter((tool): tool is string => Boolean(tool)); + return tools.length > 0 ? tools : undefined; +} + /** * Parse a one-shot `--at` value into an ISO string (UTC). *