Skip to content

fix(core): reject malformed cron numeric fields#5352

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/cron-strict-numeric-fields
Jun 18, 2026
Merged

fix(core): reject malformed cron numeric fields#5352
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/cron-strict-numeric-fields

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Tightens cron field parsing so numeric tokens are only accepted when the entire token is a valid integer, instead of relying on bare parseInt() (which silently ignores trailing characters).

  • Single values, range endpoints, and step values are now whole-token validated before conversion. Malformed inputs are rejected with the existing error kinds: a bad single value throws Invalid value, a malformed or over-segmented range (e.g. 1-5x, 1-2-3) throws Invalid range, and a malformed step (e.g. */15garbage, 5/2x) throws Invalid step.
  • humanReadableCron() applies the same whole-token check to step expressions, so a malformed pattern like */15x * * * * falls back to the raw expression string instead of being summarized as Every 15 minutes.
  • Adds regression tests for the malformed parser inputs and the display fallbacks.

Why it's needed

parseCron() previously normalized malformed fields by parsing only the leading digits, so 5x * * * * behaved like 5 * * * *, 1-5x like 1-5, 1-2-3 like 1-2, and */15garbage like */15. humanReadableCron() had the matching problem, displaying */15x * * * * as Every 15 minutes.

This matters because cron_create validates schedules through parseCron() (and nextFireTime()) before creating a job. If a malformed expression is silently normalized rather than rejected, a user or the model can create a scheduled task that runs at an unintended cadence instead of failing fast. The fix makes the parser fail fast on malformed numeric fields. (See issue #5348.)

Reviewer Test Plan

How to verify

Repro of the original bug: before this change, parseCron('5x * * * *') succeeded and behaved like 5 * * * *, and humanReadableCron('*/15x * * * *') returned Every 15 minutes. After this change, the parser throws (Invalid value / Invalid range / Invalid step) for malformed tokens, and humanReadableCron falls back to the raw string. The added unit tests assert exactly these expected-vs-observed outcomes.

Verification commands already run on this branch:

  • npx vitest run packages/core/src/utils/cronParser.test.ts packages/core/src/utils/cronDisplay.test.ts
  • npx prettier --check packages/core/src/utils/cronParser.ts packages/core/src/utils/cronParser.test.ts packages/core/src/utils/cronDisplay.ts packages/core/src/utils/cronDisplay.test.ts
  • npx eslint packages/core/src/utils/cronParser.ts packages/core/src/utils/cronParser.test.ts packages/core/src/utils/cronDisplay.ts packages/core/src/utils/cronDisplay.test.ts
  • npm run typecheck --workspace=packages/core
  • git diff --check

Evidence (Before & After)

N/A — non-visible logic fix; covered by the unit tests above.

Tested on

OS Status
🍏 macOS ✅ CI
🪟 Windows ✅ CI
🐧 Linux ✅ CI

✅ tested · ⚠️ not tested · N/A

Environment (optional)

Unit tests only (npm workspaces).

Risk & Scope

  • Main risk or tradeoff: Low; scoped to cron field parsing and display. The behavior change is limited to rejecting (or, for display, falling back on) malformed numeric tokens that were previously silently normalized. Well-formed expressions are unaffected.
  • Not validated / out of scope: No unrelated refactors, no public API changes, no UI redesigns.
  • Breaking changes / migration notes: None. Inputs that were already valid keep working; only previously-malformed-but-silently-accepted inputs now fail fast.

Linked Issues

Fixes #5348

中文说明

此 PR 的作用

收紧 cron 字段解析逻辑:只有当整个数字 token 都是合法整数时才接受,而不再依赖原始的 parseInt()(它会静默忽略尾部多余字符)。

  • 单值、范围端点和步长值在转换前都会进行整 token 校验。非法输入会按既有的错误类型被拒绝:非法单值抛出 Invalid value;非法或多段范围(如 1-5x1-2-3)抛出 Invalid range;非法步长(如 */15garbage5/2x)抛出 Invalid step
  • humanReadableCron() 对步长表达式应用相同的整 token 校验,因此像 */15x * * * * 这样的非法表达式会回退为原始表达式字符串,而不再被概括为 Every 15 minutes
  • 为这些非法解析输入和显示回退新增了回归测试。

为什么需要

此前 parseCron() 只解析前导数字,从而把非法字段静默归一化:5x * * * * 表现得像 5 * * * *1-5x1-51-2-31-2*/15garbage*/15humanReadableCron() 也有同样的问题,会把 */15x * * * * 显示成 Every 15 minutes

这很重要,因为 cron_create 在创建任务前会通过 parseCron()(以及 nextFireTime())来校验调度表达式。如果非法表达式被静默归一化而不是被拒绝,用户或模型就可能创建出以非预期频率运行的定时任务,而不是快速失败。本次修复让解析器在遇到非法数字字段时快速失败。(见 issue #5348。)

审阅者测试计划

如何验证

原始 bug 复现:在本次改动前,parseCron('5x * * * *') 会成功并表现得像 5 * * * *humanReadableCron('*/15x * * * *') 会返回 Every 15 minutes。改动后,对于非法 token,解析器会抛错(Invalid value / Invalid range / Invalid step),而 humanReadableCron 会回退为原始字符串。新增的单元测试正是断言这些"期望 vs 实际"的结果。

本分支上已运行的验证命令:

  • npx vitest run packages/core/src/utils/cronParser.test.ts packages/core/src/utils/cronDisplay.test.ts
  • npx prettier --check packages/core/src/utils/cronParser.ts packages/core/src/utils/cronParser.test.ts packages/core/src/utils/cronDisplay.ts packages/core/src/utils/cronDisplay.test.ts
  • npx eslint packages/core/src/utils/cronParser.ts packages/core/src/utils/cronParser.test.ts packages/core/src/utils/cronDisplay.ts packages/core/src/utils/cronDisplay.test.ts
  • npm run typecheck --workspace=packages/core
  • git diff --check

证据(前后对比)

N/A —— 非可见的逻辑修复;已由上述单元测试覆盖。

测试平台

操作系统 状态
🍏 macOS ✅ CI
🪟 Windows ✅ CI
🐧 Linux ✅ CI

✅ 已测试 · ⚠️ 未测试 · N/A

环境(可选)

仅单元测试(npm workspaces)。

风险与范围

  • 主要风险或权衡:低;范围限定在 cron 字段解析与显示。行为变化仅限于拒绝(或在显示时回退)此前被静默归一化的非法数字 token。合法表达式不受影响。
  • 未验证 / 范围之外:无无关重构、无公共 API 变更、无 UI 重新设计。
  • 破坏性变更 / 迁移说明:无。原本合法的输入继续可用;只有此前"非法但被静默接受"的输入现在会快速失败。

关联 Issue

Fixes #5348

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

@tt-a1i
tt-a1i marked this pull request as ready for review June 18, 2026 18:21
@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @tt-a1i, thanks for the contribution!

The PR body doesn't follow our pull request template. The current headings ("Summary", "Test Plan", "AI Assistance Disclosure") don't match the required sections. Please update the PR description to include:

  • What this PR does — describe the change in prose
  • Why it's needed — motivation / the problem being solved
  • Reviewer Test Plan — how a reviewer can confirm this works (steps, expected behavior, evidence)
  • Risk & Scope — main risk, what's out of scope, breaking changes
  • Linked Issues — use Fixes #5348 to auto-close the issue
  • 中文说明 — Chinese translation of the above

The template also has a "Tested on" OS table — please fill that in too.

The code changes themselves look focused and relevant to #5348 — this is purely a description-format issue. Once updated, the review can continue. 🙏

中文说明

你好 @tt-a1i,感谢贡献!

PR 描述没有按照我们的 pull request 模板 来写。当前的标题("Summary"、"Test Plan"、"AI Assistance Disclosure")和要求的章节不匹配。请更新 PR 描述,包含以下内容:

  • What this PR does — 用文字描述改动
  • Why it's needed — 动机 / 要解决的问题
  • Reviewer Test Plan — 审查者如何确认改动有效(步骤、预期行为、证据)
  • Risk & Scope — 主要风险、不在范围内的内容、破坏性变更
  • Linked Issues — 使用 Fixes #5348 自动关闭 issue
  • 中文说明 — 以上英文的中文翻译

模板中还有一个 "Tested on" 操作系统表格,也请填写。

代码改动本身看起来聚焦且与 #5348 相关——这纯粹是描述格式的问题。更新后审查可以继续。🙏

Qwen Code · qwen3.7-max

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

On direction: clearly aligned. Cron is a first-class feature (CHANGELOG: "durable cron jobs" #5004, "enable loop/cron tools by default" #4950), and silent normalization of malformed input is a real correctness bug — users can end up with jobs running at unintended cadences. The linked issue #5348 lays it out well.

On approach: scope is tight and appropriate. Four files, +59/-12, all directly related to the fix. The whole-token regex validation before parseInt() is the right minimal fix. No scope creep, no drive-by refactors.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

方向:明确对齐。Cron 是一等公民功能(CHANGELOG:"durable cron jobs" #5004、"enable loop/cron tools by default" #4950),静默归一化非法输入是真正的正确性 bug——用户可能会以非预期的频率运行定时任务。关联 issue #5348 描述得很清楚。

方案:范围紧凑恰当。4 个文件,+59/-12,全部与修复直接相关。在 parseInt() 之前做整 token 正则校验是正确的最小修复。无范围蔓延,无顺手重构。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

The fix is clean and correct. Both cronParser.ts and cronDisplay.ts adopt the same pattern — a ^\d+$ regex check before parseInt() — which is exactly the minimal change needed. The parser correctly distinguishes error types: Invalid value for bad single tokens, Invalid range for malformed/multi-segment ranges, Invalid step for bad step values.

One minor observation: INTEGER_TOKEN_RE and the positive-integer parsing helper are duplicated across cronParser.ts and cronDisplay.ts. Not a blocker — the two modules are independent and the duplication is a few lines — but worth flagging for the contributor to consider.

No correctness bugs, no security issues, no regressions. Scope is tight.

Testing

Before (main, without PR)

Bug confirmed — malformed inputs silently normalized:

$ npx tsx -e "import { parseCron } from './packages/core/src/utils/cronParser'; ..."
=== Before (main, no PR) ===
BUG CONFIRMED: 5x * * * * accepted (silently normalized)
BUG CONFIRMED: 1-2-3 accepted (silently normalized)
BUG CONFIRMED: */15garbage accepted (silently normalized)

After (PR #5352 applied)

All malformed inputs rejected, valid inputs still work:

$ npx tsx -e "import { parseCron } from './packages/core/src/utils/cronParser'; import { humanReadableCron } from './packages/core/src/utils/cronDisplay'; ..."
=== After (PR #5352 applied) ===
FIXED: 5x rejected - Invalid value: "5x"
FIXED: 1-2-3 rejected - Invalid range: "1-2-3"
FIXED: */15garbage rejected - Invalid step: "15garbage"
VALID: */15 accepted ✓
VALID: 1-5 accepted ✓

Display: */15x => */15x * * * *
Display: */15  => Every 15 minutes

Unit Tests (27/27 pass)

$ cd packages/core && npx vitest run src/utils/cronParser.test.ts src/utils/cronDisplay.test.ts

 RUN  v3.2.4 /home/runner/work/qwen-code/qwen-code/packages/core

 ✓ src/utils/cronDisplay.test.ts (2 tests) 5ms
 ✓ src/utils/cronParser.test.ts (25 tests) 18ms

 Test Files  2 passed (2)
      Tests  27 passed (27)
   Duration  4.29s

Results match the PR's claims exactly. ✅

中文说明

代码审查

修复干净正确。cronParser.tscronDisplay.ts 都采用了同一模式——在 parseInt() 之前用 ^\d+$ 正则校验——这正是所需的最小改动。解析器正确区分了错误类型:非法单值用 Invalid value,非法/多段范围用 Invalid range,非法步长用 Invalid step

一个小观察:INTEGER_TOKEN_RE 和正整数解析辅助函数在 cronParser.tscronDisplay.ts 中有重复。不是阻断问题——两个模块独立,重复只有几行——但值得贡献者考虑。

无正确性 bug,无安全问题,无回归。范围紧凑。

测试

修复前(main 分支,无 PR)

Bug 确认——非法输入被静默归一化。

修复后(应用 PR #5352

所有非法输入被拒绝,合法输入正常工作。

单元测试(27/27 通过)

结果与 PR 声明完全一致。✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a textbook bugfix PR: well-scoped, correctly implemented, with regression tests that actually cover the failure modes.

My independent proposal would have been the same — add a ^\d+$ regex guard before every parseInt() call in the cron field parser. The PR goes slightly further by also fixing humanReadableCron(), which had the identical bug on the display side. That's the right call — fix both paths in one PR rather than leaving a known-good-but-actually-broken display function.

The before/after testing is unambiguous: on main, parseCron('5x * * * *') silently succeeds (the bug). With the PR, it throws Invalid value: "5x". Valid expressions like */15 and 1-5 continue to work. All 27 unit tests pass. The results match the PR's claims exactly.

The minor duplication of INTEGER_TOKEN_RE across the two files is not worth extracting — they're in independent modules and the shared logic is three lines. If a future change needs to update the validation, the duplication will be obvious.

Approving. ✅

中文说明

这是一个教科书式的 bugfix PR:范围恰当、实现正确、回归测试覆盖了实际的失败模式。

我的独立方案也会一样——在 cron 字段解析器的每个 parseInt() 调用前加 ^\d+$ 正则校验。PR 还额外修复了 humanReadableCron(),该函数在显示侧有同样的 bug。这是正确的做法——一次性修复两条路径,而不是留下一个已知有问题的显示函数。

修复前后对比明确:main 上 parseCron('5x * * * *') 静默成功(bug);应用 PR 后抛出 Invalid value: "5x"。合法表达式如 */151-5 继续正常工作。27 个单元测试全部通过。结果与 PR 声明完全一致。

两个文件中 INTEGER_TOKEN_RE 的轻微重复不值得提取——它们位于独立模块,共享逻辑只有三行。如果未来需要更新校验逻辑,重复会很明显。

批准 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@wenshao

wenshao commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

✅ Local runtime verification (real tmux, real CronCreateTool) — PR #5352

Verdict: fixes #5348 as intended, no regressions, type-checks & lints clean — safe to merge.

I drove the real cron functions and the real CronCreateTool + CronScheduler against the malformed inputs from #5348, inside an isolated tmux session. A/B baseline is origin/main (1 commit behind; the two changed files are byte-identical to the merge-base → clean apply).

What the PR does

parseCron() used bare parseInt(), which silently drops trailing junk, so malformed fields were normalized instead of rejected. The PR adds a whole-token /^\d+$/ check before conversion for single values (Invalid value), range endpoints / over-segmented ranges (Invalid range), and steps (Invalid step). humanReadableCron() gets the same check via parsePositiveInteger, so malformed step patterns fall back to the raw string instead of being summarized.

Evidence — PR head

Static checks

vitest cronParser.test.ts + cronDisplay.test.ts → 27 passed   VITEST_EXIT=0
eslint (all 4 changed files)                    → ESLINT_EXIT=0
typecheck core (tsc --noEmit)                   → TYPECHECK_CORE_EXIT=0

Real runtime — parseCron rejects every malformed token:

5x * * * *          THROW Invalid value: "5x"
1-5x * * * *        THROW Invalid range: "1-5x"
1-2-3 * * * *       THROW Invalid range: "1-2-3"
*/15garbage * * * * THROW Invalid step:  "15garbage"
1-10/3x * * * *     THROW Invalid step:  "3x"
5/2x * * * *        THROW Invalid step:  "2x"
valid controls (5, */15, 0 9 * * 1-5, 1-5) → all OK (no regression)

humanReadableCron falls back to the raw string for malformed steps
(*/15 * * * * still → Every 15 minutes; */15x, */0, 0 */2x, 0 0 */3x → raw).

End-to-end through the real CronCreateTool (+ real CronScheduler):

cron_create("5x * * * *")  → jobs_created=0  error="Invalid value: \"5x\""   (rejected)
cron_create("*/5 * * * *") → jobs_created=1  "Scheduled <id> (Every 5 minutes)"  (valid)

A/B vs origin/main — the bug, demonstrated

                                  PR head                    origin/main (base)
parseCron("5x * * * *")           THROW Invalid value        PARSED (silently accepted)
nextFireTime("5x * * * *")        THROW                      2026-06-19T12:05:00Z
nextFireTime("5  * * * *")        2026-06-19T12:05:00Z       2026-06-19T12:05:00Z
  → malformed fires like "5"?     n/a (rejected)             YES  (identical fire time)
humanReadableCron("*/15x …")      "*/15x * * * *" (raw)      "Every 15 minutes"  (wrong)
humanReadableCron("*/0 …")        "*/0 * * * *" (raw)        "Every 0 minutes"   (nonsensical)
CronCreateTool("5x * * * *")      0 jobs + error             1 job: "Scheduled <id> (5x * * * *)"

The headline: on base, cron_create("5x * * * *") silently creates a job that fires at minute 5 — exactly the "unintended cadence" risk #5348 describes. On head it fails fast.

The new tests have teeth — both fail on base:

× parseCron     > throws on malformed numeric tokens   → expected [Function] to throw an error
× humanReadableCron > falls back for malformed step …  → expected 'Every 15 minutes' to be '*/15x * * * *'
Tests 2 failed | 25 skipped (27)   TEETH_VITEST_EXIT=1

Notes

  • Scoped purely to numeric-field validation; well-formed expressions are unaffected (valid controls all still parse and cron_create still schedules them).
  • */0 now also falls back in display (parsePositiveInteger requires > 0), matching parseCron's long-standing Invalid step rejection of zero steps — consistent.
  • Verified on Linux.
🇨🇳 中文版(点击展开)

✅ 本地真实运行验证(真实 tmux + 真实 CronCreateTool)— PR #5352

结论:按预期修复了 #5348,无回归,类型检查与 lint 均通过,可以合并。

我在隔离的 tmux 会话里,用真实的 cron 函数以及真实的 CronCreateTool + CronScheduler,针对 #5348 的非法输入做了验证。A/B 基线是 origin/main(落后 1 个提交;两个改动文件与 merge-base 逐字节一致,可干净应用)。

这个 PR 做了什么

parseCron() 原先用裸 parseInt(),会静默丢弃尾部多余字符,于是非法字段被“归一化”而非拒绝。该 PR 在转换前增加了整 token 的 /^\d+$/ 校验:单值非法抛 Invalid value;范围端点非法/多段范围抛 Invalid range;步长非法抛 Invalid stephumanReadableCron() 通过 parsePositiveInteger 应用同样的校验,因此非法步长表达式会回退为原始字符串,而不再被概括。

证据 — PR head

静态检查

vitest cronParser.test.ts + cronDisplay.test.ts → 27 passed   VITEST_EXIT=0
eslint(4 个改动文件)                          → ESLINT_EXIT=0
typecheck core (tsc --noEmit)                   → TYPECHECK_CORE_EXIT=0

真实运行 —— parseCron 拒绝每一个非法 token:

5x * * * *          THROW Invalid value: "5x"
1-5x * * * *        THROW Invalid range: "1-5x"
1-2-3 * * * *       THROW Invalid range: "1-2-3"
*/15garbage * * * * THROW Invalid step:  "15garbage"
1-10/3x * * * *     THROW Invalid step:  "3x"
5/2x * * * *        THROW Invalid step:  "2x"
合法对照(5、*/15、0 9 * * 1-5、1-5)→ 全部 OK(无回归)

humanReadableCron 对非法步长回退为原始字符串
*/15 * * * * 仍然 → Every 15 minutes*/15x*/00 */2x0 0 */3x → 原样返回)。

经由真实 CronCreateTool(+ 真实 CronScheduler)的端到端:

cron_create("5x * * * *")  → jobs_created=0  error="Invalid value: \"5x\""   (被拒绝)
cron_create("*/5 * * * *") → jobs_created=1  "Scheduled <id> (Every 5 minutes)"  (合法)

origin/main 的 A/B —— 把 bug 直接演示出来

                                  PR head                    origin/main (base)
parseCron("5x * * * *")           THROW Invalid value        PARSED(被静默接受)
nextFireTime("5x * * * *")        THROW                      2026-06-19T12:05:00Z
nextFireTime("5  * * * *")        2026-06-19T12:05:00Z       2026-06-19T12:05:00Z
  → 非法表达式是否像 "5" 一样触发?  不适用(已拒绝)          是(触发时间完全相同)
humanReadableCron("*/15x …")      "*/15x * * * *"(原样)     "Every 15 minutes"(错误)
humanReadableCron("*/0 …")        "*/0 * * * *"(原样)       "Every 0 minutes"(无意义)
CronCreateTool("5x * * * *")      0 个任务 + 报错             1 个任务:"Scheduled <id> (5x * * * *)"

重点:在 base 上,cron_create("5x * * * *") 会静默创建一个在每小时第 5 分钟触发的任务——正是 #5348 所描述的“非预期频率”风险。在 head 上它会快速失败。

新增测试是有效的(有“牙齿”)——两个在 base 上都失败:

× parseCron     > throws on malformed numeric tokens   → expected [Function] to throw an error
× humanReadableCron > falls back for malformed step …  → expected 'Every 15 minutes' to be '*/15x * * * *'
Tests 2 failed | 25 skipped (27)   TEETH_VITEST_EXIT=1

说明

  • 仅限于数字字段校验;合法表达式不受影响(合法对照全部仍能解析,cron_create 仍能正常排程)。
  • */0 现在在显示上也会回退(parsePositiveInteger 要求 > 0),与 parseCron 一直以来对零步长抛 Invalid step 的行为保持一致。
  • 在 Linux 上验证。

@wenshao
wenshao merged commit 97af6d7 into QwenLM:main Jun 18, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(core): cron parser accepts malformed numeric fields with trailing junk

3 participants