Add scheduled task creation, and stop a bad cron unscheduling everything
Build App / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 9m35s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Failing after 2m26s
Build App / build-macos (pull_request) Successful in 2m49s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped

Completes the Automation tab: it could list, toggle, run, log and remove
tasks but not create them, so task creation still meant dropping to the
CLI. Adds add_scheduled_task and update_scheduled_task, plus a task
editor with cron presets and a plain-English reading of the expression.

Every field is free user text, so all of it goes to the scheduler as a
bare argv vector through bollard — no shell, no quoting. Validation is
shape-only rather than metacharacter scrubbing: length caps, no control
characters in single-line fields, no leading-dash name, absolute
working_dir. Verified by round-tripping a prompt containing
`; rm -rf /`, `$(id)`, backticks and newlines: it landed byte-for-byte
in the task JSON with nothing executed.

The scheduler CLI has no `edit`, so update is add-then-remove with the
add first — a rejected edit leaves the original intact. The new id is
surfaced in the editor rather than hidden.

Root-cause fix, and the more serious half of this commit:
triple-c-scheduler never validated --schedule, and rebuild_crontab
regenerates the entire crontab and pipes it to `crontab`, which rejects
the whole file if any line is malformed — with the error thrown away by
`2>/dev/null || true`. A single bad schedule therefore silently
unscheduled every other task in the container while reporting success.
Reproduced directly. It matters because the global CLAUDE.md tells
Claude to drive this CLI, so Claude could trigger it unprompted.

`add` now validates the expression and exits non-zero, and
rebuild_crontab reports a rejected crontab instead of swallowing it,
keeping the offending file for inspection. Verified against the real CLI
in this container: a bad schedule is refused without disturbing an
existing task's crontab entry, and `0 9 * * 1-5`, `*/30 * * * *`,
`0,30 8-17 * * *` and `0 0 1 1 *` are all still accepted. The Rust layer
validates independently, agreeing with vixie cron on 23 probed
expressions including `1/2` and `*/0` being invalid.

121 frontend tests, 44 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 12:20:52 -07:00
co-authored by Claude Opus 5
parent cf3b021c72
commit 7d00390e1f
11 changed files with 1878 additions and 14 deletions
@@ -0,0 +1,156 @@
import { describe, it, expect } from "vitest";
import {
atTimestampIsPast,
describeCron,
validateAtTimestamp,
validateCronExpression,
validateTaskName,
validateTaskPrompt,
validateWorkingDir,
MAX_TASK_NAME_LEN,
MAX_TASK_PROMPT_LEN,
} from "./taskValidation";
describe("task field validation", () => {
it("requires a name that cannot be read as an option", () => {
expect(validateTaskName("nightly")).toBeNull();
expect(validateTaskName(" nightly ")).toBeNull();
expect(validateTaskName("")).toMatch(/required/i);
expect(validateTaskName(" ")).toMatch(/required/i);
expect(validateTaskName("-id")).toMatch(/cannot start/i);
expect(validateTaskName("--prompt")).toMatch(/cannot start/i);
expect(validateTaskName("two\nlines")).toMatch(/single line/i);
expect(validateTaskName("n".repeat(MAX_TASK_NAME_LEN + 1))).toMatch(/too long/i);
});
it("treats shell syntax in a name or prompt as ordinary text", () => {
// Nothing downstream is a shell, so these must not be rejected —
// over-blocking would be its own bug.
for (const value of ["; rm -rf /", "$(id)", "`id`", "a | b && c", "%pct"]) {
expect(validateTaskName(value)).toBeNull();
expect(validateTaskPrompt(value)).toBeNull();
}
});
it("requires a prompt and allows it to be multi-line", () => {
expect(validateTaskPrompt("Run the tests\nthen report")).toBeNull();
expect(validateTaskPrompt("")).toMatch(/required/i);
expect(validateTaskPrompt(" \n ")).toMatch(/required/i);
expect(validateTaskPrompt("p".repeat(MAX_TASK_PROMPT_LEN + 1))).toMatch(/too long/i);
expect(validateTaskPrompt("bad\u0000nul")).toMatch(/unsupported/i);
});
it("requires an absolute working directory, defaulting when blank", () => {
expect(validateWorkingDir("")).toBeNull();
expect(validateWorkingDir("/workspace/app")).toBeNull();
expect(validateWorkingDir("workspace")).toMatch(/absolute/i);
expect(validateWorkingDir("./rel")).toMatch(/absolute/i);
expect(validateWorkingDir("~/home")).toMatch(/absolute/i);
expect(validateWorkingDir("/workspace/../etc")).toMatch(/\.\./);
});
});
describe("cron validation", () => {
// Every expression below was checked against the container's own
// Debian/vixie `crontab` binary, which is the thing that ultimately accepts
// or rejects the schedule.
it("accepts expressions vixie cron accepts", () => {
for (const good of [
"* * * * *",
"*/30 * * * *",
"0 3 * * *",
"0 9 * * 1-5",
"0,30 9-17 * * 1-5",
"15 0 1 1 *",
"0 9 * * 0",
"0 9 * * 7",
"0 9 * * MON-FRI",
"0 0 1 JAN *",
"0-59/70 * * * *",
"1-5/2 * * * *",
"05 09 * * *",
]) {
expect(validateCronExpression(good), good).toBeNull();
}
});
it("rejects expressions vixie cron rejects", () => {
for (const bad of [
"",
"* * * *",
"* * * * * *",
"@daily",
"not a cron",
"99 * * * *",
"0 24 * * *",
"0 0 0 1 *",
"0 9 * * 8",
"0 9 * 13 *",
"*/0 * * * *",
"1/2 * * * *",
"0 9 * * jan",
"jan 9 * * *",
"0 9 * * mon,",
"0 9 * * 1--5",
"0 9 * * 1-5/x",
"0 9 * * *; rm -rf /",
"$(id) * * * *",
]) {
expect(validateCronExpression(bad), bad).not.toBeNull();
}
});
it("matches the backend's message shape for the field count", () => {
expect(validateCronExpression("* * * *")).toMatch(/exactly 5 fields/);
});
});
describe("describeCron", () => {
const cases: [string, string][] = [
["* * * * *", "Every minute, every day."],
["*/30 * * * *", "Every 30 minutes, every day."],
["0 * * * *", "At :00 past every hour, every day."],
["0,30 * * * *", "At :00 and :30 past every hour, every day."],
["0 9 * * *", "At 09:00, every day."],
["30 9 * * 1-5", "At 09:30, on Monday to Friday."],
["0 8 * * 1", "At 08:00, on Monday."],
["0 9 * * 0", "At 09:00, on Sunday."],
// 7 is Sunday too, and must not read as an eighth day.
["0 9 * * 7", "At 09:00, on Sunday."],
["0 9,17 * * *", "At 09:00 and 17:00, every day."],
["0 9-17 * * *", "At :00 past every hour from 09:00 to 17:00, every day."],
["0 */2 * * *", "At :00 past every 2 hours, every day."],
["0 0 1 * *", "At 00:00, on day 1 of the month."],
["0 0 1 1 *", "At 00:00, on day 1 of the month in January."],
["0 9 * * MON,THU", "At 09:00, on Monday and Thursday."],
];
it.each(cases)("reads %s as %s", (expression, expected) => {
expect(describeCron(expression)).toBe(expected);
});
it("says nothing rather than guessing when the expression is invalid", () => {
expect(describeCron("nope")).toBeNull();
expect(describeCron("99 * * * *")).toBeNull();
});
});
describe("one-shot timestamps", () => {
it("accepts only the scheduler's own format", () => {
expect(validateAtTimestamp("2026-12-25 09:05")).toBeNull();
expect(validateAtTimestamp("")).toMatch(/required/i);
// The scheduler's regex demands two digits everywhere.
expect(validateAtTimestamp("2026-1-5 09:05")).toMatch(/YYYY-MM-DD/);
expect(validateAtTimestamp("2026-12-25T09:05")).toMatch(/YYYY-MM-DD/);
expect(validateAtTimestamp("2026-12-25 09:05:00")).toMatch(/YYYY-MM-DD/);
expect(validateAtTimestamp("2026-02-30 09:05")).toMatch(/not a real/i);
expect(validateAtTimestamp("2026-12-25 25:00")).toMatch(/not a real/i);
});
it("flags a time in the past, because cron would fire it next year", () => {
const now = new Date(2026, 5, 1, 12, 0);
expect(atTimestampIsPast("2026-05-31 09:00", now)).toBe(true);
expect(atTimestampIsPast("2026-06-01 12:01", now)).toBe(false);
expect(atTimestampIsPast("nonsense", now)).toBe(false);
});
});