fix: parse PowerShell cron tools allow-list (#68858) (thanks @chen-zhang-cs-code)

* fix(cron): parse PowerShell tools allow list

* fix(cron): clarify tools allow-list help

* fix: parse PowerShell cron tools allow-list (#68858) (thanks @chen-zhang-cs-code)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
ZC
2026-04-19 17:41:14 +08:00
committed by GitHub
parent 53495f5136
commit 25e51bba52
6 changed files with 84 additions and 18 deletions

View File

@@ -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

View File

@@ -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"]);

View File

@@ -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>", "Model override for agent jobs (provider/model or alias)")
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
.option("--light-context", "Use lightweight bootstrap context for agent jobs", false)
.option("--tools <csv>", "Comma-separated tool allow-list (e.g. exec,read,write)")
.option("--tools <list>", "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),
};
})();

View File

@@ -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<string, unknown>,
@@ -59,7 +64,7 @@ export function registerCronEditCommand(cron: Command) {
.option("--timeout-seconds <n>", "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 <csv>", "Comma-separated tool allow-list (e.g. exec,read,write)")
.option("--tools <list>", "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;
}

View File

@@ -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();
});
});

View File

@@ -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).
*