Skip to content

Add cron changed plugin hook#72773

Merged
amknight merged 2 commits intomainfrom
ak/cron-changed-hook
Apr 28, 2026
Merged

Add cron changed plugin hook#72773
amknight merged 2 commits intomainfrom
ak/cron-changed-hook

Conversation

@amknight
Copy link
Copy Markdown
Member

@amknight amknight commented Apr 27, 2026

Summary

  • add a typed cron_changed plugin hook emitted from gateway cron lifecycle events
  • add tests for gateway hook dispatch and plugin hook wiring
  • document the hook as a gateway startup/lifecycle API surface

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation gateway Gateway runtime size: L maintainer Maintainer-authored PR labels Apr 27, 2026
@amknight amknight force-pushed the ak/cron-changed-hook branch from 5e465aa to 852d377 Compare April 27, 2026 11:11
@amknight amknight force-pushed the ak/cron-changed-hook branch 3 times, most recently from c5464b6 to d074005 Compare April 27, 2026 11:18
@amknight amknight marked this pull request as ready for review April 27, 2026 12:33
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 27, 2026

🔒 Aisle Security Analysis

We found 5 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Untrusted plugins can obtain gateway CronService handle via ctx.getCron() and manipulate cron jobs
2 🟠 High Sensitive runtime configuration exposed to plugins via cron_changed hook context
3 🟡 Medium Sensitive CronJob payload fields leaked to plugins via cron_changed hook
4 🟡 Medium Sensitive CronJob data broadcast over gateway "cron" event (CronEvent.job snapshot)
5 🟡 Medium Unbounded async execution of cron_changed plugin hooks can exhaust resources
1. 🟠 Untrusted plugins can obtain gateway CronService handle via ctx.getCron() and manipulate cron jobs
Property Value
Severity High
CWE CWE-269
Location src/gateway/server-cron.ts:212-220

Description

In runCronChangedHook, the plugin hook context exposes getCron() which returns the gateway-owned CronService instance cast to PluginHookGatewayCronService.

  • This is only a TypeScript cast; at runtime the returned object is the full CronService with additional powerful methods (e.g., run, enqueueRun, wake, status, listPage, getJob, etc.).
  • Any plugin registered for the new cron_changed hook can call these methods without passing through the gateway’s normal authorization boundaries, enabling a confused-deputy/privilege-escalation path:
    • modify/add/remove cron jobs globally
    • force-run jobs or wake the system (potentially triggering system events/actions under gateway authority)
    • read cron job metadata/state that may belong to other agents/sessions

Vulnerable code:

const hookCtx: PluginHookGatewayContext = {
  config: getRuntimeConfig(),
  getCron: () => cron as PluginHookGatewayCronService,
};

Recommendation

Do not expose the concrete CronService instance to plugins.

  • Return a capability-limited wrapper object that only implements the intended safe surface area, and do not leak the underlying instance.
  • Additionally, enforce authorization/scoping (e.g., only allow operations on jobs owned by the calling plugin, or provide read-only access for cron_changed).

Example (capability wrapper):

const cronApi: PluginHookGatewayCronService = {
  list: (opts) => cron.list(opts).then(jobs => jobs.map(toPluginCronJob)),
  add: (input) => { throw new Error("not permitted"); },
  update: (id, patch) => { throw new Error("not permitted"); },
  remove: (id) => { throw new Error("not permitted"); },
};

const hookCtx: PluginHookGatewayContext = {
  config: getRuntimeConfig(),
  getCron: () => cronApi,
};

If mutation must be supported, add explicit policy checks in the wrapper before calling into cron (e.g., verify plugin id, job ownership tags, or a configured allowlist).

2. 🟠 Sensitive runtime configuration exposed to plugins via cron_changed hook context
Property Value
Severity High
CWE CWE-200
Location src/gateway/server-cron.ts:212-220

Description

The cron_changed plugin hook receives a PluginHookGatewayContext that includes the full runtime configuration object.

  • getRuntimeConfig() returns the active runtime OpenClawConfig (loaded from disk/env substitutions)
  • This config commonly contains sensitive values (provider API keys/tokens, auth secrets, filesystem paths, etc.)
  • Any plugin implementing cron_changed will receive this config on each cron event, expanding the opportunities for exfiltration/misuse of secrets by third-party/untrusted plugins

Vulnerable code:

const hookCtx: PluginHookGatewayContext = {
  config: getRuntimeConfig(),
  getCron: () => cron as PluginHookGatewayCronService,
};
void hookRunner.runCronChanged(evt, hookCtx)

Recommendation

Avoid passing the full runtime config into plugin hooks.

Recommended options:

  1. Provide a sanitized/minimal config view intended for plugins (explicit allowlist):
import { toPluginSafeConfig } from "../plugins/config-sanitize.js";

const hookCtx: PluginHookGatewayContext = {
  config: toPluginSafeConfig(getRuntimeConfig()),
  getCron: () => cron as PluginHookGatewayCronService,
};

Where toPluginSafeConfig strips secrets (API keys, tokens, passwords), internal paths, and any values not required by the plugin SDK.

  1. Do not include config at all for this hook (or gate behind an explicit exposeConfigToPlugins setting).

Also document clearly in the plugin SDK whether hooks can access secrets, and treat any plugin that receives secrets as fully trusted.

3. 🟡 Sensitive CronJob payload fields leaked to plugins via cron_changed hook
Property Value
Severity Medium
CWE CWE-201
Location src/gateway/server-cron.ts:61-72

Description

cron_changed hook events expose an internal CronJob snapshot to plugins. toPluginCronJob() attempts to map the internal type to the public plugin SDK shape, but it uses structuredClone(job.payload) (and structuredClone(job.schedule)) without filtering keys.

Because CronJob.payload is a union that can include agentTurn payloads with additional internal/sensitive fields (e.g. model, fallbacks, toolsAllow, allowUnsafeExternalContent, and externalContentSource provenance), any extra properties present at runtime will be preserved and become readable by plugins even though PluginHookGatewayCronJob.payload is typed as only { kind, text }.

This is an information disclosure risk when plugins are untrusted or have weaker trust boundaries than the core gateway.

Vulnerable code:

payload: job.payload ? structuredClone(job.payload) : undefined,

Relevant internal payload shape includes additional fields beyond {kind,text}:

export type CronPayload = { kind: "systemEvent"; text: string } | CronAgentTurnPayload;// CronAgentTurnPayloadFields includes model/fallbacks/toolsAllow/externalContentSource/etc.

Recommendation

Do not clone and forward the internal payload verbatim. Instead, explicitly construct the public payload shape and drop/deny-list internal fields.

Example (allow only systemEvent payload):

function toPluginCronPayload(payload: CronPayload | undefined): { kind: string; text?: string } | undefined {
  if (!payload) return undefined;
  if (payload.kind === "systemEvent") {
    return { kind: payload.kind, text: payload.text };
  }// For agentTurn or other internal kinds, either omit entirely or provide a redacted summary
  return { kind: payload.kind };
}

function toPluginCronJob(job: CronJob): PluginHookGatewayCronJob {
  return {
    id: job.id,// ...
    payload: toPluginCronPayload(job.payload),
  };
}

If some agentTurn fields are intended to be public, define a separate public payload type and whitelist only those keys. Add tests to ensure extra keys (e.g. externalContentSource, toolsAllow) are not present in emitted plugin events.

4. 🟡 Sensitive CronJob data broadcast over gateway "cron" event (CronEvent.job snapshot)
Property Value
Severity Medium
CWE CWE-200
Location src/gateway/server-cron.ts:324-326

Description

CronEvent now optionally includes a full job snapshot, and the gateway broadcasts the raw event object to all websocket subscribers.

  • CronEvent.job is set to the full internal CronJob object at multiple emit sites.
  • The gateway’s onEvent handler immediately does params.broadcast("cron", evt, ...) with no redaction.
  • CronJob contains potentially sensitive fields such as:
    • payload (e.g., agent-turn message, which may include secrets/prompts or external content provenance)
    • delivery.to (for webhook mode this may be a URL and can embed credentials/tokens)
    • failureAlert destinations
    • sessionKey / agentId (internal routing identifiers)

This creates a risk of information disclosure to any connected/authorized UI clients (and any other consumers of the gateway broadcast channel) that were previously only receiving the smaller CronEvent without job contents.

Vulnerable code:

onEvent: (evt) => {
  params.broadcast("cron", evt, { dropIfSlow: true });// ...
}

Recommendation

Do not broadcast the internal CronEvent directly once it contains a CronJob snapshot.

Options:

  1. Broadcast a sanitized/public shape (recommended), similar to the plugin hook mapping, and explicitly omit/redact sensitive subtrees (payload, delivery, failureAlert, externalContentSource, etc.):
onEvent: (evt) => {
  const publicEvt = {
    action: evt.action,
    jobId: evt.jobId,// include only minimal safe fields
    ...(evt.job ? { job: { id: evt.job.id, name: evt.job.name, enabled: evt.job.enabled } } : {}),
    runAtMs: evt.runAtMs,
    durationMs: evt.durationMs,
    status: evt.status,
    summary: evt.summary,
    nextRunAtMs: evt.nextRunAtMs,
  };
  params.broadcast("cron", publicEvt, { dropIfSlow: true });
};
  1. If job details must be available to the UI, gate them behind an explicit authorization check and/or send them only to specific connection IDs that are authorized to view cron job payload/delivery details.

Also consider adding a unit test asserting that cron broadcast payload never includes payload/delivery/failureAlert (or other sensitive keys).

5. 🟡 Unbounded async execution of cron_changed plugin hooks can exhaust resources
Property Value
Severity Medium
CWE CWE-400
Location src/gateway/server-cron.ts:212-226

Description

The gateway fires cron_changed plugin hooks in a fire-and-forget manner without any backpressure or concurrency limit.

  • Every cron event calls runCronChangedHook() which starts hookRunner.runCronChanged(...) but does not await it.
  • runCronChanged() delegates to runVoidHook(), which executes all handlers and only resolves once all handlers complete.
  • There is no default timeout for cron_changed (timeouts are only configured for agent_end by default), so a slow/hung plugin hook can keep the Promise pending indefinitely.
  • If cron events occur frequently (many jobs, frequent ticks) this can create an unbounded number of in-flight hook executions, leading to memory/CPU growth and potential gateway instability (DoS).

Vulnerable code:

void hookRunner.runCronChanged(evt, hookCtx).catch((err) => {
  cronLogger.warn({ err: formatErrorMessage(err), jobId: evt.jobId }, "cron_changed hook failed");
});

Recommendation

Add backpressure for cron_changed hook execution, and enforce a timeout.

Options (choose one):

  1. Queue with bounded concurrency (recommended):
import pLimit from "p-limit";
const limit = pLimit(1); // or small N

const runCronChangedHook = (evt: PluginHookCronChangedEvent) => {
  const hookRunner = getGlobalHookRunner();
  if (!hookRunner?.hasHooks("cron_changed")) return;

  const hookCtx: PluginHookGatewayContext = {
    config: getRuntimeConfig(),
    getCron: () => cron as PluginHookGatewayCronService,
  };

  void limit(() => hookRunner.runCronChanged(evt, hookCtx))
    .catch((err) => cronLogger.warn({ err: formatErrorMessage(err), jobId: evt.jobId }, "cron_changed hook failed"));
};
  1. Configure a default timeout for cron_changed in DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK (still consider concurrency limiting):
const DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK = {
  agent_end: 30_000,
  cron_changed: 5_000,
};

This prevents unbounded buildup of pending promises when hooks are slow or never resolve.


Analyzed PR: #72773 at commit 71ef0da

Last updated on: 2026-04-28T06:26:34Z

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 27, 2026

Greptile Summary

This PR adds a typed cron_changed plugin hook dispatched from every gateway cron lifecycle event (added, updated, removed, started, finished). The internal CronEvent is extended with an optional job snapshot mapped to the public PluginHookGatewayCronJob shape via toPluginCronJob, which correctly shallow-copies state fields so downstream mutations don't pollute hook payloads. Snapshots are taken synchronously in onEvent, and hooks fire-and-forget with errors caught and logged. Test coverage for dispatch wiring, removed-job snapshots, and runtime-config propagation is solid.

Confidence Score: 4/5

Safe to merge — no logic bugs found; all findings are style/documentation P2s.

Implementation is correct: job snapshots are taken synchronously, the cron closure is safe from TDZ issues, removed-job lookup is logically sound, and hook errors are caught. All remaining comments are P2 style observations.

src/plugins/hook-types.ts — the payload field on PluginHookGatewayCronJob silently drops agentTurn-specific fields at the type level.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/plugins/hook-types.ts
Line: 621-624

Comment:
**`agentTurn` payload fields silently dropped from the public type**

The public `payload` type only exposes `{ kind?: string; text?: string }`, but `CronPayload` also includes `CronAgentTurnPayload` which carries `message`, `model`, `fallbacks`, `thinking`, `timeoutSeconds`, and other fields. `toPluginCronJob` assigns `job.payload` directly, so those extra fields are present at runtime but invisible to the type system — plugin authors querying an `agentTurn` job's payload won't get typed access to `message` or the model override. Consider widening the union or at minimum documenting which variant fields are exposed.

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/gateway/server-cron.test.ts
Line: 194-197

Comment:
**Assert mock called before indexing into `mock.calls`**

`calls[0]?.[1]` uses optional chaining, so if `runCronChangedMock` is never invoked, `hookCtx` is `undefined` and the test fails with `expect(undefined).toBe(runtimeCfg)` rather than a clear "mock was not called" message. Adding `expect(runCronChangedMock).toHaveBeenCalledTimes(1)` before the `mock.calls` access would surface the real failure immediately.

```suggestion
      expect(runCronChangedMock).toHaveBeenCalledTimes(1);
      const calls = runCronChangedMock.mock.calls as unknown[][];
```

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/service/state.ts
Line: 19-20

Comment:
**JSDoc comment overstates guarantee**

The comment says `job` is "Included for all actions", but the field is typed as `job?: CronJob`. In the `remove` path in `ops.ts`, `job` is set to `removedJob` which TypeScript types as `CronJob | undefined` (even though logically it's always defined when the event fires). Consider softening the comment to "Included for all actions where the job is accessible" or narrowing the type to `CronJob` and asserting non-null at the call site.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix: improve cron_changed hook correctne..." | Re-trigger Greptile

Comment thread src/plugins/hook-types.ts
Comment on lines 621 to 624
payload?: {
kind?: string;
text?: string;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 agentTurn payload fields silently dropped from the public type

The public payload type only exposes { kind?: string; text?: string }, but CronPayload also includes CronAgentTurnPayload which carries message, model, fallbacks, thinking, timeoutSeconds, and other fields. toPluginCronJob assigns job.payload directly, so those extra fields are present at runtime but invisible to the type system — plugin authors querying an agentTurn job's payload won't get typed access to message or the model override. Consider widening the union or at minimum documenting which variant fields are exposed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/hook-types.ts
Line: 621-624

Comment:
**`agentTurn` payload fields silently dropped from the public type**

The public `payload` type only exposes `{ kind?: string; text?: string }`, but `CronPayload` also includes `CronAgentTurnPayload` which carries `message`, `model`, `fallbacks`, `thinking`, `timeoutSeconds`, and other fields. `toPluginCronJob` assigns `job.payload` directly, so those extra fields are present at runtime but invisible to the type system — plugin authors querying an `agentTurn` job's payload won't get typed access to `message` or the model override. Consider widening the union or at minimum documenting which variant fields are exposed.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +194 to +197
);
} finally {
state.cron.stop();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Assert mock called before indexing into mock.calls

calls[0]?.[1] uses optional chaining, so if runCronChangedMock is never invoked, hookCtx is undefined and the test fails with expect(undefined).toBe(runtimeCfg) rather than a clear "mock was not called" message. Adding expect(runCronChangedMock).toHaveBeenCalledTimes(1) before the mock.calls access would surface the real failure immediately.

Suggested change
);
} finally {
state.cron.stop();
}
expect(runCronChangedMock).toHaveBeenCalledTimes(1);
const calls = runCronChangedMock.mock.calls as unknown[][];
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-cron.test.ts
Line: 194-197

Comment:
**Assert mock called before indexing into `mock.calls`**

`calls[0]?.[1]` uses optional chaining, so if `runCronChangedMock` is never invoked, `hookCtx` is `undefined` and the test fails with `expect(undefined).toBe(runtimeCfg)` rather than a clear "mock was not called" message. Adding `expect(runCronChangedMock).toHaveBeenCalledTimes(1)` before the `mock.calls` access would surface the real failure immediately.

```suggestion
      expect(runCronChangedMock).toHaveBeenCalledTimes(1);
      const calls = runCronChangedMock.mock.calls as unknown[][];
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/cron/service/state.ts Outdated
Comment on lines +19 to +20
/** Snapshot of the job at the time of the event. Included for all actions. */
job?: CronJob;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 JSDoc comment overstates guarantee

The comment says job is "Included for all actions", but the field is typed as job?: CronJob. In the remove path in ops.ts, job is set to removedJob which TypeScript types as CronJob | undefined (even though logically it's always defined when the event fires). Consider softening the comment to "Included for all actions where the job is accessible" or narrowing the type to CronJob and asserting non-null at the call site.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cron/service/state.ts
Line: 19-20

Comment:
**JSDoc comment overstates guarantee**

The comment says `job` is "Included for all actions", but the field is typed as `job?: CronJob`. In the `remove` path in `ops.ts`, `job` is set to `removedJob` which TypeScript types as `CronJob | undefined` (even though logically it's always defined when the event fires). Consider softening the comment to "Included for all actions where the job is accessible" or narrowing the type to `CronJob` and asserting non-null at the call site.

How can I resolve this? If you propose a fix, please make it concise.

- Fix PluginHookGatewayCronDeliveryStatus: replace 'error' with 'unknown'
  to match internal CronDeliveryStatus enum
- Add job snapshot to CronEvent so removed events carry the deleted job
- Extract pickDefined helper, replace 14-field verbose spread mapping
- Add toPluginCronJob mapper for explicit internal→public type boundary
- Fix schedule union: use literal-only kind discriminants for TS narrowing
- Use loadConfig() (runtime) instead of params.cfg (startup) in hook ctx
- Use formatErrorMessage instead of String(err) for stack preservation
- Fix pre-existing getCron TS2322 with explicit cast (matches gateway_start)
- Re-export supporting types from hooks.ts for plugin consumers
- Add tests: removed events with job, finished with full fields, runtime cfg
@amknight amknight force-pushed the ak/cron-changed-hook branch from 7837b07 to 71ef0da Compare April 28, 2026 06:23
@amknight amknight merged commit f155a5f into main Apr 28, 2026
68 checks passed
@amknight amknight deleted the ak/cron-changed-hook branch April 28, 2026 11:34
vincentkoc added a commit that referenced this pull request Apr 28, 2026
- docs/plugins/hooks.md: add `cron_changed` to the Lifecycle hook catalog and
  a Gateway lifecycle paragraph describing its typed event payload, run
  status, delivery status, and removed-event job snapshot, so plugin authors
  picking up f155a5f (#72773) have a canonical reference beyond the
  sdk-overview bullet that already shipped in the same SHA.
- docs/help/environment.md: add a "Legacy environment variables" section for
  aa1834a so users see that `CLAWDBOT_*` and `MOLTBOT_*` prefixes are now
  ignored and trigger an `OPENCLAW_LEGACY_ENV_VARS` deprecation warning,
  with a rename example to `OPENCLAW_*`.
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
* feat: add cron changed plugin hook

* fix: improve cron_changed hook correctness and code quality

- Fix PluginHookGatewayCronDeliveryStatus: replace 'error' with 'unknown'
  to match internal CronDeliveryStatus enum
- Add job snapshot to CronEvent so removed events carry the deleted job
- Extract pickDefined helper, replace 14-field verbose spread mapping
- Add toPluginCronJob mapper for explicit internal→public type boundary
- Fix schedule union: use literal-only kind discriminants for TS narrowing
- Use loadConfig() (runtime) instead of params.cfg (startup) in hook ctx
- Use formatErrorMessage instead of String(err) for stack preservation
- Fix pre-existing getCron TS2322 with explicit cast (matches gateway_start)
- Re-export supporting types from hooks.ts for plugin consumers
- Add tests: removed events with job, finished with full fields, runtime cfg
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
- docs/plugins/hooks.md: add `cron_changed` to the Lifecycle hook catalog and
  a Gateway lifecycle paragraph describing its typed event payload, run
  status, delivery status, and removed-event job snapshot, so plugin authors
  picking up cd03596 (openclaw#72773) have a canonical reference beyond the
  sdk-overview bullet that already shipped in the same SHA.
- docs/help/environment.md: add a "Legacy environment variables" section for
  b832211 so users see that `CLAWDBOT_*` and `MOLTBOT_*` prefixes are now
  ignored and trigger an `OPENCLAW_LEGACY_ENV_VARS` deprecation warning,
  with a rename example to `OPENCLAW_*`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation gateway Gateway runtime maintainer Maintainer-authored PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant