From 6e18739f648a9b810c5c718ac38df02789ec7fc4 Mon Sep 17 00:00:00 2001 From: Chen Jin Date: Sun, 2 Aug 2026 22:25:45 +0800 Subject: [PATCH 1/3] fix(cron): reject unknown fields in update_job instead of silently persisting (#67625) update_job merged update payloads unconditionally (only 'id' was guarded), so a typo like 'promt' was persisted to jobs.json and reported as a successful update while the real prompt stayed unchanged. Add an _UPDATEABLE_JOB_FIELDS whitelist (create_job parameters + schedule_display + the lifecycle fields pause/resume/trigger persist) and raise ValueError listing the unknown keys. Dashboard/API paths already translate ValueError to HTTP 400, so typo'd payloads now fail loudly. --- cron/jobs.py | 45 +++++++++++++++++++++++++++++++++++++++++ tests/cron/test_jobs.py | 26 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/cron/jobs.py b/cron/jobs.py index e1b775bb4b505..c3d68c0b490f6 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -370,6 +370,43 @@ def _jobs_lock(): # into output writes/deletes. _IMMUTABLE_JOB_FIELDS = frozenset({"id"}) +# Fields a caller may legitimately change via update_job. Everything else — +# including typos like ``promt`` — is rejected loudly instead of being +# silently persisted to jobs.json and reported as a successful update +# (#67625). Mirrors the create_job() parameter set plus schedule_display +# (which update_job derives itself) and the scheduler-owned lifecycle fields +# that pause_job/resume_job/trigger_job persist through update_job. Other +# runtime fields (last_run_at, last_status, run_claim, ...) are owned by +# the scheduler and never updatable. +_UPDATEABLE_JOB_FIELDS = frozenset( + { + "prompt", + "schedule", + "name", + "repeat", + "deliver", + "origin", + "skill", + "skills", + "model", + "provider", + "base_url", + "script", + "context_from", + "enabled_toolsets", + "workdir", + "no_agent", + "attach_to_session", + "schedule_display", + # lifecycle fields written by pause/resume/trigger + "enabled", + "state", + "paused_at", + "paused_reason", + "next_run_at", + } +) + def _job_output_dir(job_id: str) -> Path: """Resolve a job's output directory, rejecting any path-escape attempt. @@ -1515,6 +1552,14 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] f"Cron job field(s) cannot be updated: {', '.join(sorted(bad_fields))}" ) + unknown_fields = (updates or {}).keys() - _UPDATEABLE_JOB_FIELDS + if unknown_fields: + raise ValueError( + "Cron job update contains unknown field(s): " + f"{', '.join(sorted(unknown_fields))}. Known updateable fields: " + f"{', '.join(sorted(_UPDATEABLE_JOB_FIELDS))}" + ) + with _jobs_lock(): jobs = load_jobs() for i, job in enumerate(jobs): diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index 6bfb7edffdf23..d06fdc5a9f57a 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -251,6 +251,32 @@ def test_update_name(self, tmp_cron_dir): fetched = get_job(job["id"]) assert fetched["name"] == "New Name" + def test_update_rejects_unknown_fields(self, tmp_cron_dir): + """#67625: a typo'd field like ``promt`` must be rejected loudly + instead of silently persisted as a successful update.""" + job = create_job(prompt="Check server status", schedule="every 1h", name="Old Name") + with pytest.raises(ValueError, match="unknown field.*promt"): + update_job(job["id"], {"promt": "some value"}) + # Nothing was persisted + fetched = get_job(job["id"]) + assert "promt" not in fetched + assert fetched["prompt"] == "Check server status" + + def test_update_accepts_all_known_fields(self, tmp_cron_dir): + """Every create_job parameter is a legal update key.""" + job = create_job(prompt="P", schedule="30m") + updates = { + "prompt": "P2", "schedule": "1h", "name": "n", "repeat": 2, + "deliver": "local", "origin": {"platform": "test"}, + "skill": "s1", "skills": ["s1", "s2"], "model": "m", "provider": "p", + "base_url": "http://x", "script": "", "context_from": None, + "enabled_toolsets": ["web"], "workdir": None, "no_agent": False, + "attach_to_session": None, "schedule_display": "1h", + } + updated = update_job(job["id"], updates) + assert updated is not None + assert updated["name"] == "n" + class TestPauseResumeJob: def test_pause_sets_state(self, tmp_cron_dir): From 49787f2227075acaec6d601e5db1276b291e058a Mon Sep 17 00:00:00 2001 From: Chen Jin Date: Sun, 2 Aug 2026 23:45:56 +0800 Subject: [PATCH 2/3] ci: retrigger flaky slice 7/8 test From 49ecfb063191eac1db2f226233db1ae0dc1dc1d6 Mon Sep 17 00:00:00 2001 From: Chen Jin Date: Tue, 4 Aug 2026 02:55:02 +0800 Subject: [PATCH 3/3] fix(cron): add 'description' to _UPDATEABLE_JOB_FIELDS whitelist (#76831) description round-trips through update_job and is the compatibility basis for the open #66786 description feature. Without this entry, update_job silently drops the field rather than accepting or loudly rejecting it. --- cron/jobs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cron/jobs.py b/cron/jobs.py index c3d68c0b490f6..c4d06d30e203b 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -393,6 +393,7 @@ def _jobs_lock(): "base_url", "script", "context_from", + "description", "enabled_toolsets", "workdir", "no_agent",