cron: add lifecycle hooks for job execution#45465
cron: add lifecycle hooks for job execution#45465Arry8 wants to merge 3 commits intoopenclaw:mainfrom
Conversation
Greptile SummaryThis PR introduces a lifecycle hook system for cron jobs, allowing user-defined scripts to run at
Confidence Score: 3/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/cron/service/timer.ts
Line: 657-664
Comment:
**`afterRun` hooks silently skip on `beforeRun` abort**
The early `return` on a `beforeRun` abort (lines 658–664) exits the function before `afterRun` hooks are reached (line 710). The comment at line 710 states `afterRun` hooks "always fire regardless of outcome", but a `beforeRun` abort is itself an outcome — and `afterRun` never fires in this path.
If an `afterRun` hook is used for cleanup, audit logging, or metrics recording, it will be silently missed whenever a `beforeRun` hook aborts the job. This could lead to incomplete audit trails or unreleased resources that the hook was expected to clean up.
To fix this, extract the abort-result build into a variable and fall through to the shared `afterRun` block rather than returning early:
```typescript
let abortResult: TimedCronRunOutcome | undefined;
if (beforeEntries.length > 0) {
const beforeResult = await runCronHooks("beforeRun", makeHookCtx("beforeRun"), beforeEntries);
if (beforeResult.aborted) {
abortResult = {
jobId: id,
status: "skipped",
error: `hook aborted: ${beforeResult.reason ?? "unknown"}`,
startedAt,
endedAt: state.deps.nowMs(),
};
}
}
if (abortResult) {
// still run afterRun hooks before returning
const afterRunEntries = loadHookEntries("afterRun", state.deps.cronConfig, job);
if (afterRunEntries.length > 0) {
await runCronHooks("afterRun", makeHookCtx("afterRun", { status: "skipped", durationMs: abortResult.endedAt - abortResult.startedAt }), afterRunEntries);
}
return abortResult;
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/cron/hooks.ts
Line: 13
Comment:
**`result` field is defined but never populated**
`CronHookContext` declares a `result?: unknown` field, but it is never set when `makeHookCtx` builds the context object in `timer.ts`. Hook script authors who reference `ctx.result` expecting the job's core result will always receive `undefined`.
Either remove the field to avoid confusion, or populate it in `timer.ts` with `coreResult` (or the raw `executeJobCoreWithTimeout` return value) so hook scripts can actually use it. The PR description's hook contract comment (`ctx.hookPoint, ctx.workflow, ctx.job, ctx.status …`) doesn't mention `ctx.result` either, which suggests it was left in the type by accident.
```suggestion
job: Pick<CronJob, "id" | "name" | "agentId" | "schedule">;
error?: string;
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/cron/hooks.test.ts
Line: 166-206
Comment:
**`runCronHooks` abort path is untested**
The three `runCronHooks` tests cover empty entries and a missing module, but the core `beforeRun` abort contract — a hook returning `{ abort: true, reason: '...' }` — has no test. Given that abort is the most security-sensitive behaviour (it gates whether a job executes at all), this path should be explicitly covered:
```typescript
it("aborts execution when a beforeRun hook returns { abort: true }", async () => {
const abortHook = vi.fn().mockResolvedValue({ abort: true, reason: "test abort" });
vi.doMock("./abort-hook.cjs", () => ({ default: abortHook }));
// or use a loadHookModule mock strategy consistent with the test setup
});
it("returns aborted:false when abort is false", async () => {
// hook returns { abort: false } — should not abort
});
```
Also consider a test where `reason` is omitted — `isAbortResult` still triggers but `reason` should default to `"aborted by hook"` per line 108 of `hooks.ts`.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: a8043de |
a8043de to
0a65002
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a8043de1af
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1660fbeb30
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
@coderabbitai review |
xkonjin
left a comment
There was a problem hiding this comment.
Code Review
Summary: Well-structured addition. The type definitions are clean, the filter system is expressive, and the test coverage is thorough. A few issues worth addressing before merge:
🔴 Security: loadHookModule accepts arbitrary data: URIs in tests but likely require()/import() from disk in production
The implementation dynamically imports scripts via path. If cron job configs are user-editable (jobs.json), a malicious per-job hook entry could load any script on the filesystem. Since CronJobHooksConfig accepts plain string paths with no validation, there's no guard against:
{ "hooks": { "beforeRun": ["../../secrets.env"] } }Recommendation: validate hook script paths against a configured allowlist or restrict them to workspace-relative paths within a specific directory (e.g. hooks/). At minimum, reject absolute paths and ../ traversal in per-job hook entries.
🟡 HOOK_TIMEOUT_MS = 10_000 is hardcoded and not configurable
A 10-second timeout per hook may be too short for some post-completion hooks (e.g. sending summaries, writing to external services) and too long for beforeRun guard hooks. Consider exposing this as a configurable field on CronHookEntry or CronConfig.
🟡 Hook execution errors are swallowed silently for non-beforeRun points
From the test:
it("logs warning and continues when script module is not found", ...)This is the right behavior for resilience, but the log level should be error when the script exists but throws at runtime (vs. warn for module-not-found). Users debugging a broken onFailure hook would benefit from a clearer signal.
🟡 afterRun vs afterComplete/onFailure ordering not documented
The type CronLifecycleHookPoint = "beforeRun" | "afterComplete" | "onFailure" | "afterRun" implies afterRun fires regardless of success/failure (like a finally block), while afterComplete and onFailure are conditional. This invariant should be documented in the type definition and in the README/config docs, otherwise users will misuse afterRun expecting conditional behavior.
💡 meta object is shared but not typed
The meta: Record<string, unknown> on CronHookContext is a useful escape hatch for hook-to-hook communication within a run. Consider adding a brief JSDoc comment noting this is the intended use, and whether hooks can mutate it for downstream hooks to read.
💡 inlineHook test helper is clever but fragile
The data-URI import trick works in Node.js but may not work in all environments (Bun, edge runtimes). If the test suite is ever run in a non-Node context, these tests will silently fail or throw. A comment noting the Node-only requirement would help future contributors.
Test coverage is excellent — the filter combinations, skipGlobal, and abort semantics are all well exercised. The main gaps are the path traversal security concern and the error logging level distinction.
|
@coderabbitai review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cdfea188b3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
All review findings addressedGreptile (3 findings — fixed in 1660fbe)
Codex Connector P1 (2 findings — fixed in cdfea18)
Codex Connector P2 (3 findings — fixed in 1660fbe and faab903)
Local integration test (passed)Hooks confirmed firing on a live OC instance. Hook log files created for |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: faab90352b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Review findings addressed (41c7282)xkonjin (human review) — all 6 items fixed
Latest Codex review (faab903) — both P1/P2 fixed
Additional improvements
All checks pass: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 41c7282452
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e88721b09e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ece1ec2097
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const normalized = path.normalize(scriptPath); | ||
| if (normalized.startsWith("..")) { | ||
| return false; | ||
| } | ||
| return true; |
There was a problem hiding this comment.
Reject non-filesystem per-job hook specifiers
Per-job hooks are described as restricted because jobs.json may be less trusted, but this validator only blocks absolute paths and .. traversal. Values like data:text/javascript,... (or ~-prefixed paths) still pass here, then loadHookModule() imports URL schemes directly / expands ~, so per-job config can escape the intended path guard and execute arbitrary modules outside the workspace. Restrict per-job entries to safe relative filesystem paths (for example, reject URI schemes and ~ prefixes).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 8f13017: isValidJobHookPath now rejects URL-scheme specifiers (npm:, node:, data:, https:, etc.) before the path-traversal check. Per-job hooks are restricted to relative filesystem paths; global hooks in openclaw.json remain unrestricted since they are admin-controlled. Added four test cases to the suite.
| await persist(state); | ||
| armTimer(state); | ||
| }); | ||
| return; |
There was a problem hiding this comment.
Emit finished event on manual beforeRun aborts
This early return on the manual-run abort path skips the later emit(... action: "finished") block in finishPreparedManualRun, so hook-aborted cron.run executions are persisted as skipped but never broadcast as completed. That makes downstream consumers miss these runs (for example, run-log appends in the cron event handler), creating inconsistent observability versus timer/catch-up executions that always emit a finished event.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 8f13017: the beforeRun abort path in finishPreparedManualRun now emits a finished event (status: skipped, error, runAtMs, durationMs, nextRunAtMs) inside the locked block after persisting, so gateway/UI listeners receive the completion signal.
Add a hook system that runs user-defined scripts at cron job lifecycle points (beforeRun, afterComplete, onFailure, afterRun). Hooks are configured via openclaw.json cron.hooks (global) and per-job hooks in the job store. Supports priority ordering, job/agent filtering, skipGlobal overrides, and abort capability on beforeRun hooks.
ece1ec2 to
6145a07
Compare
|
Rebased and squashed this branch onto the current What changed:
No functional changes — same behavior, cleaner history. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6145a07b4d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
|
||
| const hookMeta: Record<string, unknown> = {}; | ||
| // Derive OC home from the cron store path for stable hook script resolution. | ||
| const hookBasePath = path.resolve(path.dirname(state.deps.storePath), ".."); |
There was a problem hiding this comment.
Resolve hook scripts against config/workspace root
This base path calculation makes relative hook imports depend on where cron.store is located, not on a stable OpenClaw workspace/config root: hookBasePath is computed from state.deps.storePath and then used by loadHookModule for all relative scripts. Since cron.store is user-configurable and hook scripts are documented as workspace-relative, moving the cron store file (for backups/volumes) will silently remap paths like workspace/scripts/audit.cjs to the wrong directory and cause hooks to stop loading unless users switch to absolute paths.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Already addressed: both runDueJob (timer.ts) and finishPreparedManualRun (ops.ts) compute hookBasePath = path.resolve(path.dirname(state.deps.storePath), "..") which resolves to OC home (~/.openclaw/) independently of process.cwd(). Relative hook paths are resolved against this stable root via resolveUserPath.
- isValidJobHookPath: reject URL-scheme specifiers (npm:, node:, data:, https:, etc.) in per-job hook paths; only relative filesystem paths allowed - finishPreparedManualRun: emit finished event on beforeRun abort so gateway/UI listeners see the run as complete - add URL-scheme rejection tests to isValidJobHookPath suite
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8f130174bc
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (path.isAbsolute(scriptPath)) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
Trim per-job hook paths before safety checks
The per-job path guard checks the raw scriptPath, but hook loading later resolves the same value through resolveUserPath(...), which trims whitespace first. A jobs.json value like " /etc/passwd" (or a spaced traversal path) bypasses path.isAbsolute(...) here and is then trimmed into an absolute path during import, so per-job hooks can execute files outside the intended relative workspace scope.
Useful? React with 👍 / 👎.
| const jobScripts = job.hooks?.[hookPoint]; | ||
| const jobEntries: ResolvedEntry[] = []; | ||
| if (jobScripts) { | ||
| for (const script of jobScripts) { | ||
| if (!isValidJobHookPath(script)) { |
There was a problem hiding this comment.
Validate hook entry types before iterating
This loop assumes job.hooks[hookPoint] is a string array, but cron jobs are persisted in jobs.json and hook values are not runtime-normalized before this read path. If a malformed entry like [123] is present, isValidJobHookPath(123) throws in path.isAbsolute, which aborts the run before normal result finalization and can leave jobs marked as running until stale-run recovery clears them.
Useful? React with 👍 / 👎.
beforeRun/afterComplete/onFailure/afterRun hooks now receive ctx.payload so scripts can inspect kind, command, message, etc. and make decisions (abort, conditional logic, audit) without guessing from the job name. - Add payload: CronPayload to CronHookContext type (hooks.ts) - Pass structuredClone(job.payload) in all three makeHookCtx sites (timer.ts scheduled runs, timer.ts catch-up runs, ops.ts manual runs) - hooks.test.ts: 3 new tests — kind-based abort, non-matching kind, payload field access via ctx.meta
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dbe6470ada
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (path.isAbsolute(scriptPath)) { | ||
| return false; |
There was a problem hiding this comment.
Trim per-job hook paths before enforcing path restrictions
isValidJobHookPath performs its absolute/traversal checks on the raw scriptPath, so entries like " /etc/passwd" or " ../outside/hook.cjs" pass validation here, but later loadHookModule resolves the same value through resolveUserPath, which trims first (src/utils.ts) and then imports the escaped path. That bypasses the per-job safety boundary for jobs.json hooks and allows loading arbitrary filesystem modules outside the intended relative scope.
Useful? React with 👍 / 👎.
| if (jobScripts) { | ||
| for (const script of jobScripts) { | ||
| if (!isValidJobHookPath(script)) { |
There was a problem hiding this comment.
Validate per-job hook entry types before path checks
loadHookEntries assumes every job.hooks[hookPoint] element is a string and calls isValidJobHookPath directly, but jobs.json is not schema-validated at load time, so malformed entries like [123] make path.isAbsolute throw. In timer/manual execution paths this exception bubbles out before normal result finalization, which can leave runningAtMs markers uncleared and block subsequent runs until stale-run recovery.
Useful? React with 👍 / 👎.
|
Hey @vincentkoc — you recently merged some cron work (#45459). This PR adds lifecycle hooks for cron job execution and has been sitting open with all Codex findings addressed. Would you be able to take a look when you get a chance? Happy to address any feedback. |
|
Codex review: found issues before merge. Summary Reproducibility: yes. Source inspection shows current main has no cron lifecycle hook config or dispatch, and the PR defects are reproducible by following persisted job hook values through loadHookEntries, isValidJobHookPath, resolveUserPath, and importFileModule. Next step before merge Security Review findings
Review detailsBest possible solution: Keep this as an implementation candidate only after maintainer approval, then harden persisted hook validation, normalize paths before safety checks, use stable imports or bounded reload semantics, and document the trust boundary before merge. Do we have a high-confidence way to reproduce the issue? Yes. Source inspection shows current main has no cron lifecycle hook config or dispatch, and the PR defects are reproducible by following persisted job hook values through loadHookEntries, isValidJobHookPath, resolveUserPath, and importFileModule. Is this the best way to solve the issue? No. The feature direction may be useful, but this patch is not the best mergeable form until the path guard, persisted input validation, import caching behavior, docs/changelog, and code-execution trust boundary are resolved. Full review comments:
Overall correctness: patch is incorrect Security concerns:
What I checked:
Likely related people:
Remaining risk / open question:
Codex review notes: model gpt-5.5, reasoning high; reviewed against ecd562b2b5f0. |
Summary
beforeRun,afterComplete,onFailure,afterRun)openclaw.jsoncron.hookssection, or per-job viahooksfield in the job storeskipGlobaloverrides, and abort capability onbeforeRunhooksSupersedes #45239 (closed) with fixes for all three issues identified in Greptile review.
Fixes from review (#45239)
filter.workflownow enforced —matchesFilter()checksentry.filter.workflowagainst the workflow string passed from the calleragentIdfilter correct for agent-less jobs — undefinedagentIdis treated as non-matching when the filter is setsetTimeoutleak —createTimeout()returns aclear()handle, called in afinallyblock after each hookConfig example
{ "cron": { "hooks": { "beforeRun": [ { "script": "workspace/scripts/hooks/audit-log.cjs", "priority": 5 } ], "onFailure": [ { "script": "workspace/scripts/hooks/alert.cjs", "filter": { "workflow": ["cron"] } } ] } } }Hook script contract
Files changed
src/config/types.cron.ts— New types:CronHookEntry,CronLifecycleHookPoint,CronHooksConfig,CronJobHooksConfig; addedhooks?toCronConfigsrc/cron/types.ts— Addedhooks?: CronJobHooksConfigtoCronJobsrc/cron/hooks.ts— Hook runner module:loadHookEntries(),runCronHooks(), filter matching, timeout with cleanupsrc/cron/hooks.test.ts— 14 unit tests covering loading, merging, priority, all three filter types, abort, error isolationsrc/cron/service/timer.ts— Integrated hook calls intorunDueJob()at all 4 lifecycle pointsTest plan
pnpm buildpassespnpm checkpasses (lint + format)pnpm test src/cron/hooks.test.ts— 14/14 tests passopenclaw.json, trigger a cron job, verify hook fires