fix(cron): reject blank --command-cwd before kind forge (#121535)

* fix(cron): reject blank --command-cwd/--command-input before kind forge

Presence-only typeof checks counted blank cwd/input as command-specific
edits, forging {kind:"command"} patches that could convert agentTurn or
script jobs into empty command payloads. Require non-blank values first.

Co-authored-by: Peter Steinberger <steipete@gmail.com>

* fix(cron): allow empty --command-input while rejecting blank cwd

ClawSweeper: Gateway command stdin is an unrestricted string, so empty
or whitespace --command-input must still patch through. Keep the blank
--command-cwd reject that prevents forging a command payload with no cwd.

Co-authored-by: Peter Steinberger <steipete@gmail.com>

* test(cron): focus command blank option coverage

Amp-Thread-ID: https://ampcode.com/threads/T-01a0220d-eaa0-76b4-adb9-68841f015b75

* fix(cron): reject blank cwd before edit reads

Amp-Thread-ID: https://ampcode.com/threads/T-01a0220d-eaa0-76b4-adb9-68841f015b75

---------

Co-authored-by: zyw02 <zyw02@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
sinner
2026-08-21 13:44:52 +08:00
committed by GitHub
parent 0f642d2ac1
commit dbf791bb1b
3 changed files with 39 additions and 10 deletions
@@ -25,6 +25,7 @@ export async function resolveCronEditPayloadDeliveryPatch(
opts: Record<string, unknown>,
loadExistingJob: () => Promise<CronJob>,
webhookUrl: string | undefined,
commandCwd: string | undefined,
): Promise<Record<string, unknown>> {
const patch: Record<string, unknown> = {};
const hasSystemEventPatch = typeof opts.systemEvent === "string";
@@ -120,11 +121,13 @@ export async function resolveCronEditPayloadDeliveryPatch(
throw new Error("Use --account or --clear-account, not both");
}
// Unlike cwd, command stdin intentionally accepts empty and whitespace strings.
const hasCommandInput = typeof opts.commandInput === "string";
const hasCommandSpecificPayloadField =
Boolean(commandShell) ||
Boolean(commandArgv) ||
typeof opts.commandCwd === "string" ||
typeof opts.commandInput === "string" ||
Boolean(commandCwd) ||
hasCommandInput ||
opts.commandEnv !== undefined ||
noOutputTimeoutSeconds !== undefined ||
outputMaxBytes !== undefined;
@@ -228,14 +231,9 @@ export async function resolveCronEditPayloadDeliveryPatch(
const payload: Record<string, unknown> = { kind: "command" };
assignIf(payload, "argv", commandArgv, Boolean(commandArgv));
assignIf(payload, "argv", ["sh", "-lc", commandShell], Boolean(commandShell));
assignIf(
payload,
"cwd",
normalizeOptionalString(opts.commandCwd),
typeof opts.commandCwd === "string",
);
assignIf(payload, "cwd", commandCwd, Boolean(commandCwd));
assignIf(payload, "env", parseCronCommandEnv(opts.commandEnv), opts.commandEnv !== undefined);
assignIf(payload, "input", opts.commandInput, typeof opts.commandInput === "string");
assignIf(payload, "input", opts.commandInput, hasCommandInput);
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
assignIf(
payload,
@@ -1033,6 +1033,28 @@ describe("cron edit command", () => {
exitSpy.mockRestore();
});
it.each(["", " "])("rejects blank --command-cwd %j", async (value) => {
await expectCronEditRejection(["--command-cwd", value], "--command-cwd must not be blank");
});
it("rejects blank --command-cwd before loading an existing job", async () => {
await expectCronEditRejection(
["--pacing-min", "30m", "--command-cwd", " "],
"--command-cwd must not be blank",
);
});
it.each(["", " "])("preserves --command-input %j as command stdin", async (value) => {
await createCronProgram().parseAsync(["edit", "job-1", "--command-input", value], {
from: "user",
});
expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), {
id: "job-1",
patch: { payload: { kind: "command", input: value } },
});
});
it("rejects --webhook combined with a delivery clear flag", async () => {
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(defaultRuntime, "exit").mockImplementation((() => undefined) as never);
+10 -1
View File
@@ -171,6 +171,10 @@ export function registerCronEditCommand(cron: Command) {
if (opts.clearTools && opts.tools !== undefined) {
throw new Error("Use --tools or --clear-tools, not both");
}
const commandCwd = normalizeOptionalString(opts.commandCwd);
if (typeof opts.commandCwd === "string" && !commandCwd) {
throw new Error("--command-cwd must not be blank");
}
let existingJobPromise: Promise<CronJobForEdit> | undefined;
let expectedConfigRevision: string | undefined;
const readExistingCronJob = async (): Promise<CronJobForEdit> => {
@@ -421,7 +425,12 @@ export function registerCronEditCommand(cron: Command) {
Object.assign(
patch,
await resolveCronEditPayloadDeliveryPatch(opts, readExistingCronJob, webhookUrl),
await resolveCronEditPayloadDeliveryPatch(
opts,
readExistingCronJob,
webhookUrl,
commandCwd,
),
);
const hasFailureAlertAfter = typeof opts.failureAlertAfter === "string";