Skip to content

cron: add lifecycle hooks for job execution#3

Merged
Arry8 merged 9 commits intomainfrom
feature/cron-lifecycle-hooks
Mar 14, 2026
Merged

cron: add lifecycle hooks for job execution#3
Arry8 merged 9 commits intomainfrom
feature/cron-lifecycle-hooks

Conversation

@Arry8
Copy link
Copy Markdown
Owner

@Arry8 Arry8 commented Mar 13, 2026

Summary

  • Adds configurable lifecycle hooks (beforeRun, afterComplete, onFailure, afterRun) to cron job execution
  • Global hooks in openclaw.json cron.hooks + per-job overrides in jobs.json
  • Priority ordering, workflow/job/agent filtering, abort capability on beforeRun

Internal review PR

This PR is for CodeRabbit review before submitting upstream.

Summary by CodeRabbit

  • New Features

    • Cron lifecycle hooks added: beforeRun, afterComplete, onFailure, afterRun with global config and per-job overrides (skip-global).
    • Hook controls: filtering by workflow/job ID/name/agent, priority ordering, per-hook timeouts; hooks run in timer, manual, and startup flows with abort/status propagation.
    • Cron job schema and configuration now accept hooks in job definitions.
  • Tests

    • Extensive tests covering loading, validation, filtering, ordering, execution, abort semantics, and error/log handling.

Arry8 added 4 commits March 13, 2026 17:30
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.
- Enforce workflow filter in matchesFilter() instead of leaving it as
  a no-op. The workflow string is now threaded from loadHookEntries.
- Treat undefined job.agentId as non-matching when filter.agentId is
  set, preventing hooks from firing on unintended agent-less jobs.
- Clear setTimeout handle after hook completion via createTimeout()
  pattern, preventing stale timer accumulation.
- Add tests for all three fixes (14 total).
@Arry8
Copy link
Copy Markdown
Owner Author

Arry8 commented Mar 13, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a cron lifecycle hook system: new hook types and schema, hook loader/runner with filtering, priorities and timeouts, path validation, and integration into scheduled/manual cron flows where beforeRun can abort and afterComplete/onFailure/afterRun are invoked; includes comprehensive tests.

Changes

Cohort / File(s) Summary
Type Definitions
src/config/types.cron.ts, src/cron/types.ts
New exported types: CronHookEntry, CronLifecycleHookPoint, CronHooksConfig, CronJobHooksConfig. CronConfig and CronJob extended with optional hooks and per-job skipGlobal.
Schema Validation
src/config/zod-schema.ts, src/gateway/protocol/schema/cron.ts
Adds CronHookEntry zod/schema and extends cron.hooks and per-job hooks schema to accept beforeRun, afterComplete, onFailure, afterRun arrays plus skipGlobal.
Hook Core
src/cron/hooks.ts
New hook runtime: CronHookContext, CronHookRunResult, loadHookEntries, runCronHooks, isValidJobHookPath, module loader, filter matching, priority sorting, per-entry timeouts, and abort semantics for beforeRun.
Timer & Ops Integration
src/cron/service/timer.ts, src/cron/service/ops.ts
Integrates hooks into scheduled and manual runs: builds hook context, runs beforeRun (may abort and cause afterRun with skipped status), executes core job, then triggers afterComplete/onFailure and always afterRun with duration/status/error.
Tests
src/cron/hooks.test.ts
Adds tests for loading/merging/filtering/priorities/skipGlobal, path validation, inline data-URI hook modules, timeout and abort behavior, and logging/error cases.
Utilities
src/utils.ts
resolveUserPath signature extended with optional base parameter to resolve non-absolute paths relative to a provided base.
Schema/Manifest
src/config/zod-schema.ts, package.json
Runtime schema updated for cron hooks; minor manifest edit present.
sequenceDiagram
    participant Timer as Timer Service
    participant HookMgr as Hook Manager
    participant HookMod as Hook Module
    participant Job as Job Execution

    rect rgba(100, 150, 200, 0.5)
    Note over Timer,HookMgr: beforeRun Phase
    Timer->>HookMgr: loadHookEntries(beforeRun, cronConfig, job, workflow?)
    HookMgr-->>Timer: filtered & sorted entries
    Timer->>HookMod: runCronHooks(beforeRun, ctx, entries)
    HookMod->>HookMod: execute hook scripts sequentially
    alt Hook returns abort
        HookMod-->>Timer: { abort: true, reason }
        Timer->>HookMgr: loadHookEntries(afterRun, ...)
        Timer->>HookMod: runCronHooks(afterRun, ctx(skipped))
        HookMod-->>Timer: afterRun result
        Timer-->>Timer: return skipped result
    else No abort
        HookMod-->>Timer: continue
    end
    end

    rect rgba(150, 200, 100, 0.5)
    Note over Timer,Job: Job Execution
    Timer->>Job: execute cron job
    Job-->>Timer: result (success/error)
    end

    rect rgba(200, 150, 100, 0.5)
    Note over Timer,HookMgr: afterComplete / onFailure Phase
    alt Job succeeded
        Timer->>HookMgr: loadHookEntries(afterComplete, ...)
        Timer->>HookMod: runCronHooks(afterComplete, ctx)
    else Job failed
        Timer->>HookMgr: loadHookEntries(onFailure, ...)
        Timer->>HookMod: runCronHooks(onFailure, ctx)
    end
    HookMod-->>Timer: result (logged, non-fatal)
    end

    rect rgba(200, 100, 150, 0.5)
    Note over Timer,HookMod: afterRun Phase (Always)
    Timer->>HookMgr: loadHookEntries(afterRun, ...)
    Timer->>HookMod: runCronHooks(afterRun, ctx)
    HookMod-->>Timer: result (logged, non-fatal)
    Timer-->>Timer: return final job result
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I tapped the clocks and stitched the hooks in place,

before and after, each script finds its space.
Filters and priorities tuned just so,
If beforeRun balks, I skip and softly go.
A rabbit's cheer for cron that now can flow.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description is significantly incomplete relative to the template, missing critical sections like Change Type, Scope, Linked Issues, User-visible Changes, Security Impact, Repro + Verification, Evidence, Human Verification, Compatibility, Failure Recovery, and Risks/Mitigations. Complete the pull request description by filling in all required sections from the template, particularly Security Impact, Verification steps, and Risk assessment for the new hook execution system.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding lifecycle hooks for cron job execution, which aligns with the substantial changes across types, schemas, tests, and service implementations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/cron-lifecycle-hooks
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/cron/service/timer.ts (1)

700-733: Clarify "fire-and-forget" comment.

The comment on line 700 says "fire-and-forget, errors logged" but the hooks are awaited. While this is technically correct since runCronHooks handles errors internally, the comment could be misleading to future maintainers.

📝 Suggested comment clarification
-      // afterComplete / onFailure hooks (fire-and-forget, errors logged).
+      // afterComplete / onFailure hooks (errors caught internally, do not affect job outcome).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cron/service/timer.ts` around lines 700 - 733, The "fire-and-forget,
errors logged" comment is misleading because the hook invocations
(loadHookEntries, runCronHooks with makeHookCtx for
afterComplete/onFailure/afterRun) are awaited; update the comment to state that
hooks are awaited but runCronHooks handles errors internally and logs them
(i.e., "awaited hooks; errors are caught and logged by runCronHooks"), so future
readers understand the hooks run to completion and errors are not propagated.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/cron/hooks.test.ts`:
- Around line 25-30: The module-scoped noopLog (debug/info/warn/error) uses
vi.fn() mocks that can leak call state across tests; update the test setup to
either call vi.clearAllMocks() (or vi.resetAllMocks()) in a beforeEach hook or
recreate noopLog inside a beforeEach so each test gets fresh vi.fn() instances,
and ensure tests referencing noopLog (e.g., assertions on noopLog.warn) rely on
those cleared/reset mocks.

---

Nitpick comments:
In `@src/cron/service/timer.ts`:
- Around line 700-733: The "fire-and-forget, errors logged" comment is
misleading because the hook invocations (loadHookEntries, runCronHooks with
makeHookCtx for afterComplete/onFailure/afterRun) are awaited; update the
comment to state that hooks are awaited but runCronHooks handles errors
internally and logs them (i.e., "awaited hooks; errors are caught and logged by
runCronHooks"), so future readers understand the hooks run to completion and
errors are not propagated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 72d114c8-c0a5-4c52-80aa-c7745a595c75

📥 Commits

Reviewing files that changed from the base of the PR and between a8043de and cdfea18.

📒 Files selected for processing (6)
  • src/config/types.cron.ts
  • src/config/zod-schema.ts
  • src/cron/hooks.test.ts
  • src/cron/hooks.ts
  • src/cron/service/timer.ts
  • src/cron/types.ts

Comment thread src/cron/hooks.test.ts
Arry8 added 2 commits March 13, 2026 18:19
- runStartupCatchupCandidate now runs the full beforeRun/afterComplete/
  onFailure/afterRun hook lifecycle, matching runDueJob behavior.
  Previously, jobs replayed after a gateway restart silently bypassed
  all hooks, creating inconsistent auditing behavior.

- loadHookModule checks path.isAbsolute() before the URL-scheme regex
  so Windows drive-letter paths (e.g. C:\hooks\audit.cjs) are treated
  as file paths rather than URL specifiers.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/cron/hooks.ts`:
- Around line 85-102: Move the timeout creation to before loading and executing
the hook so slow imports/top-level awaits are bounded: create an
AbortController/timeout via createTimeout(HOOK_TIMEOUT_MS) (or extend
createTimeout to return an AbortSignal) before calling
loadHookModule(entry.script), pass the resulting AbortSignal on the hook
invocation/context (ctx) so the hook function (hookFn) can detect cancellation,
and ensure timeout.clear() is still called in a finally block; also update any
loadHookModule/hookFn invocation to respect/forward the signal so a timed-out
hook can stop performing side effects.

In `@src/cron/service/timer.ts`:
- Around line 637-736: Extract the duplicated hook orchestration and core
execution logic into a single helper (e.g., runCronWithHooks) that accepts
(state, job, executeCoreFn) and performs: making hook context (reuse
makeHookCtx), running beforeRun (and abort handling + afterRun-on-abort),
invoking executeJobCoreWithTimeout (or provided executeCoreFn) with consistent
timeout normalization/logging, firing afterComplete/onFailure based on result,
then always firing afterRun; replace the duplicated blocks in this file and in
runStartupCatchupCandidate() and ensure executeJob() calls this new helper so
all entrypoints run identical hooks and logging behavior (use loadHookEntries,
runCronHooks, and preserve coreResult shape).
- Around line 659-675: The afterRun hook context for the aborted path doesn't
include the abort reason, so update the call that builds the afterRun context
(the makeHookCtx invocation used with runCronHooks and afterRunEntries) to
include the abort error detail (e.g., add an error or reason property populated
from beforeResult.reason or the catch-up equivalent) alongside status and
durationMs; do the same change in the catch-up branch so the always-run audit
hook can see why the job was skipped (refer to loadHookEntries, afterRunEntries,
runCronHooks, makeHookCtx and beforeResult).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f246f073-3ffd-4c8e-ac98-263c081fdf98

📥 Commits

Reviewing files that changed from the base of the PR and between 0204986 and faab903.

📒 Files selected for processing (2)
  • src/cron/hooks.ts
  • src/cron/service/timer.ts

Comment thread src/cron/hooks.ts
Comment on lines +85 to +102
for (const entry of entries) {
try {
const hookFn = await loadHookModule(entry.script);
if (typeof hookFn !== "function") {
ctx.log.warn(
{ hookPoint, script: entry.script },
"cron hook: module does not export a function, skipping",
);
continue;
}

const timeout = createTimeout(HOOK_TIMEOUT_MS);
let result: unknown;
try {
result = await Promise.race([hookFn(ctx), timeout.promise]);
} finally {
timeout.clear();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Does JavaScript Promise.race()cancel the losing promise, and does Node.jsimport()wait for an imported module's top-levelawait to finish before the returned promise resolves?

💡 Result:

  • Does Promise.race() cancel the “losing” promise(s)?
    No. Promise.race() just returns a new promise that settles when the first input promise settles; it does not cancel or stop the other async operations. JavaScript Promises don’t have built-in cancellation—cancellation has to be implemented at the operation level (e.g., via AbortController, library-specific cancel tokens, etc.). [1][2]

  • Does Node.js import() wait for an imported module’s top-level await before resolving?
    Yes. With top-level await, a module’s evaluation becomes asynchronous, and importers wait for that module to finish evaluating (i.e., for its top-level await to settle) before continuing / being considered finished. This is the intended ECMAScript behavior for modules with top-level await, and Node’s ESM docs reflect that module loading can be async (e.g., CommonJS require() only supports synchronous ES modules—i.e., ones without top-level await). [3][4][5]

Sources
[1] MDN – Promise.race() https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race
[2] MDN – Using promises (cancellation note) https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises
[3] TC39 – proposal-top-level-await (importers wait for evaluation) https://github.com/tc39/proposal-top-level-await
[4] MDN – await (Top level await behavior) https://developer.mozilla.org/docs/Web/JavaScript/Reference/Operators/await
[5] Node.js docs – ECMAScript modules (sync vs async ESM; top-level await implications) https://nodejs.org/docs/latest/api/esm.html


The timeout doesn't actually bound hook lifetime.

loadHookModule(entry.script) is awaited before createTimeout(), so a slow import or top-level await can block this loop indefinitely. After Promise.race() rejects due to timeout, the hook continues running because Promise.race() doesn't cancel the losing promise—only the caller stops waiting. A timed-out beforeRun hook can still mutate shared state or perform side effects after control has moved on.

Start the timeout before module loading and pass an AbortSignal into the hook context so the hook can detect and respect the timeout.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cron/hooks.ts` around lines 85 - 102, Move the timeout creation to before
loading and executing the hook so slow imports/top-level awaits are bounded:
create an AbortController/timeout via createTimeout(HOOK_TIMEOUT_MS) (or extend
createTimeout to return an AbortSignal) before calling
loadHookModule(entry.script), pass the resulting AbortSignal on the hook
invocation/context (ctx) so the hook function (hookFn) can detect cancellation,
and ensure timeout.clear() is still called in a finally block; also update any
loadHookModule/hookFn invocation to respect/forward the signal so a timed-out
hook can stop performing side effects.

Comment thread src/cron/service/timer.ts
Comment on lines +637 to +736
const hookMeta: Record<string, unknown> = {};
const makeHookCtx = (
hookPoint: "beforeRun" | "afterComplete" | "onFailure" | "afterRun",
extra?: Partial<CronHookContext>,
): CronHookContext => ({
hookPoint,
workflow: "cron",
job: { id: job.id, name: job.name, agentId: job.agentId, schedule: job.schedule },
meta: hookMeta,
log: state.deps.log,
...extra,
});

// beforeRun hooks — may abort the job.
const beforeEntries = loadHookEntries("beforeRun", state.deps.cronConfig, job);
if (beforeEntries.length > 0) {
const beforeResult = await runCronHooks(
"beforeRun",
makeHookCtx("beforeRun"),
beforeEntries,
);
if (beforeResult.aborted) {
const abortEndedAt = state.deps.nowMs();
const abortDurationMs = abortEndedAt - startedAt;
// afterRun hooks still fire on abort — they must always run.
const afterRunEntries = loadHookEntries("afterRun", state.deps.cronConfig, job);
if (afterRunEntries.length > 0) {
await runCronHooks(
"afterRun",
makeHookCtx("afterRun", { status: "skipped", durationMs: abortDurationMs }),
afterRunEntries,
);
}
return {
jobId: id,
status: "skipped",
error: `hook aborted: ${beforeResult.reason ?? "unknown"}`,
startedAt,
endedAt: abortEndedAt,
};
}
}

let coreResult: TimedCronRunOutcome;
try {
const result = await executeJobCoreWithTimeout(state, job);
return { jobId: id, ...result, startedAt, endedAt: state.deps.nowMs() };
coreResult = { jobId: id, ...result, startedAt, endedAt: state.deps.nowMs() };
} catch (err) {
const errorText = isAbortError(err) ? timeoutErrorMessage() : String(err);
state.deps.log.warn(
{ jobId: id, jobName: job.name, timeoutMs: jobTimeoutMs ?? null },
`cron: job failed: ${errorText}`,
);
return {
coreResult = {
jobId: id,
status: "error",
error: errorText,
startedAt,
endedAt: state.deps.nowMs(),
};
}

const durationMs = coreResult.endedAt - coreResult.startedAt;

// afterComplete / onFailure hooks (fire-and-forget, errors logged).
if (coreResult.status === "ok") {
const entries = loadHookEntries("afterComplete", state.deps.cronConfig, job);
if (entries.length > 0) {
await runCronHooks(
"afterComplete",
makeHookCtx("afterComplete", { status: "ok", durationMs }),
entries,
);
}
} else if (coreResult.status === "error") {
const entries = loadHookEntries("onFailure", state.deps.cronConfig, job);
if (entries.length > 0) {
await runCronHooks(
"onFailure",
makeHookCtx("onFailure", { error: coreResult.error, status: "error", durationMs }),
entries,
);
}
}

// afterRun hooks — always fire regardless of outcome.
const afterRunEntries = loadHookEntries("afterRun", state.deps.cronConfig, job);
if (afterRunEntries.length > 0) {
await runCronHooks(
"afterRun",
makeHookCtx("afterRun", {
status: coreResult.status,
error: coreResult.error,
durationMs,
}),
afterRunEntries,
);
}

return coreResult;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Move the lifecycle into one shared execution helper.

This block is duplicated here and in runStartupCatchupCandidate(), and the two copies already drifted on timeout normalization/logging. executeJob() later in the file also still skips hooks even though its own docstring says the run command and other callers use it, so the same job behaves differently depending on which entrypoint executed it. Pull the hook orchestration around core execution into one helper and reuse it everywhere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cron/service/timer.ts` around lines 637 - 736, Extract the duplicated
hook orchestration and core execution logic into a single helper (e.g.,
runCronWithHooks) that accepts (state, job, executeCoreFn) and performs: making
hook context (reuse makeHookCtx), running beforeRun (and abort handling +
afterRun-on-abort), invoking executeJobCoreWithTimeout (or provided
executeCoreFn) with consistent timeout normalization/logging, firing
afterComplete/onFailure based on result, then always firing afterRun; replace
the duplicated blocks in this file and in runStartupCatchupCandidate() and
ensure executeJob() calls this new helper so all entrypoints run identical hooks
and logging behavior (use loadHookEntries, runCronHooks, and preserve coreResult
shape).

Comment thread src/cron/service/timer.ts
Comment on lines +659 to +675
const abortEndedAt = state.deps.nowMs();
const abortDurationMs = abortEndedAt - startedAt;
// afterRun hooks still fire on abort — they must always run.
const afterRunEntries = loadHookEntries("afterRun", state.deps.cronConfig, job);
if (afterRunEntries.length > 0) {
await runCronHooks(
"afterRun",
makeHookCtx("afterRun", { status: "skipped", durationMs: abortDurationMs }),
afterRunEntries,
);
}
return {
jobId: id,
status: "skipped",
error: `hook aborted: ${beforeResult.reason ?? "unknown"}`,
startedAt,
endedAt: abortEndedAt,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Include the abort reason in afterRun.

The returned outcome includes error: hook aborted: ..., but the afterRun context on this path only gets status: "skipped" and durationMs. That leaves the always-run audit hook blind to why the run was skipped. The same omission exists in the catch-up copy below.

💡 Proposed fix
         if (beforeResult.aborted) {
           const abortEndedAt = state.deps.nowMs();
           const abortDurationMs = abortEndedAt - startedAt;
+          const abortError = `hook aborted: ${beforeResult.reason ?? "unknown"}`;
           // afterRun hooks still fire on abort — they must always run.
           const afterRunEntries = loadHookEntries("afterRun", state.deps.cronConfig, job);
           if (afterRunEntries.length > 0) {
             await runCronHooks(
               "afterRun",
-              makeHookCtx("afterRun", { status: "skipped", durationMs: abortDurationMs }),
+              makeHookCtx("afterRun", {
+                status: "skipped",
+                error: abortError,
+                durationMs: abortDurationMs,
+              }),
               afterRunEntries,
             );
           }
           return {
             jobId: id,
             status: "skipped",
-            error: `hook aborted: ${beforeResult.reason ?? "unknown"}`,
+            error: abortError,
             startedAt,
             endedAt: abortEndedAt,
           };
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const abortEndedAt = state.deps.nowMs();
const abortDurationMs = abortEndedAt - startedAt;
// afterRun hooks still fire on abort — they must always run.
const afterRunEntries = loadHookEntries("afterRun", state.deps.cronConfig, job);
if (afterRunEntries.length > 0) {
await runCronHooks(
"afterRun",
makeHookCtx("afterRun", { status: "skipped", durationMs: abortDurationMs }),
afterRunEntries,
);
}
return {
jobId: id,
status: "skipped",
error: `hook aborted: ${beforeResult.reason ?? "unknown"}`,
startedAt,
endedAt: abortEndedAt,
const abortEndedAt = state.deps.nowMs();
const abortDurationMs = abortEndedAt - startedAt;
const abortError = `hook aborted: ${beforeResult.reason ?? "unknown"}`;
// afterRun hooks still fire on abort — they must always run.
const afterRunEntries = loadHookEntries("afterRun", state.deps.cronConfig, job);
if (afterRunEntries.length > 0) {
await runCronHooks(
"afterRun",
makeHookCtx("afterRun", {
status: "skipped",
error: abortError,
durationMs: abortDurationMs,
}),
afterRunEntries,
);
}
return {
jobId: id,
status: "skipped",
error: abortError,
startedAt,
endedAt: abortEndedAt,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cron/service/timer.ts` around lines 659 - 675, The afterRun hook context
for the aborted path doesn't include the abort reason, so update the call that
builds the afterRun context (the makeHookCtx invocation used with runCronHooks
and afterRunEntries) to include the abort error detail (e.g., add an error or
reason property populated from beforeResult.reason or the catch-up equivalent)
alongside status and durationMs; do the same change in the catch-up branch so
the always-run audit hook can see why the job was skipped (refer to
loadHookEntries, afterRunEntries, runCronHooks, makeHookCtx and beforeResult).

Arry8 added 2 commits March 14, 2026 05:40
- Security: validate per-job hook paths (reject absolute + traversal)
- Add configurable timeoutMs per hook entry (default 10s)
- Distinguish error vs warn log level (runtime throw vs module-not-found)
- Resolve hook scripts from OC home (storePath parent) not process.cwd()
- Wire lifecycle hooks into finishPreparedManualRun (ops.ts)
- Add hooks field to TypeBox CronJobSchema (fixes gateway rejection)
- Add jobName filter for case-insensitive substring matching
- Document lifecycle ordering on CronLifecycleHookPoint type
- Add Node-only comment on inlineHook test helper
- 7 new tests (path traversal, jobName filter, isValidJobHookPath)
Add optional `base` parameter to resolveUserPath so config-derived paths
can resolve against a stable root (e.g. OC home) instead of process.cwd().
Hook module loading now uses this instead of manual path.resolve.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (2)
src/cron/service/timer.ts (1)

662-679: ⚠️ Potential issue | 🟡 Minor

Pass the abort reason into afterRun on skipped runs.

Both abort branches return error: hook aborted: ..., but the afterRun context only gets status: "skipped" and durationMs. That leaves always-run audit/cleanup hooks blind to why the run was skipped.

💡 Minimal shape of the change
+          const abortError = `hook aborted: ${beforeResult.reason ?? "unknown"}`;
           const afterRunEntries = loadHookEntries("afterRun", state.deps.cronConfig, job);
           if (afterRunEntries.length > 0) {
             await runCronHooks(
               "afterRun",
-              makeHookCtx("afterRun", { status: "skipped", durationMs: abortDurationMs }),
+              makeHookCtx("afterRun", {
+                status: "skipped",
+                error: abortError,
+                durationMs: abortDurationMs,
+              }),
               afterRunEntries,
             );
           }
           return {
             jobId: id,
             status: "skipped",
-            error: `hook aborted: ${beforeResult.reason ?? "unknown"}`,
+            error: abortError,

Mirror the same change in the catch-up block below.

Also applies to: 1040-1056

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cron/service/timer.ts` around lines 662 - 679, When a job is aborted you
currently call makeHookCtx("afterRun", { status: "skipped", durationMs }) so
afterRun hooks don't get the abort reason; update the afterRun context to
include the abort reason (e.g. { status: "skipped", durationMs: abortDurationMs,
reason: beforeResult.reason ?? "unknown" }) when you call runCronHooks after a
before-run abort (the block using beforeResult, loadHookEntries("afterRun"),
runCronHooks and makeHookCtx), and apply the identical change to the catch-up
abort branch that similarly returns status "skipped" and error.
src/cron/hooks.ts (1)

95-113: ⚠️ Potential issue | 🔴 Critical

Start the timeout before import and make timed-out hooks cooperatively cancelable.

loadHookModule() is awaited before createTimeout(), so a slow import or top-level await can block this loop indefinitely. After Promise.race() rejects, the losing hook keeps running, so a timed-out hook can still mutate shared state after the caller moved on.

Does Promise.race cancel losing promises, and does Node.js import() wait for a module's top-level await to finish before resolving?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cron/hooks.ts` around lines 95 - 113, Move the timeout creation to before
importing the hook and make hook execution cooperatively cancelable: create the
timeout (via createTimeout(timeoutMs) or an AbortController) before calling
loadHookModule(entry.script, ctx.basePath), pass an AbortSignal (or the timeout)
into loadHookModule and into the hook function invocation (hookFn(ctx, { signal
}) or similar) so top-level-await/imports and the hook body can observe
cancellation, use Promise.race([hookPromise, timeout.promise]) to await the
result, call timeout.clear() in finally, and after a timeout ensure you treat
the hook as aborted (log/continue) so a losing promise cannot silently mutate
shared state; update loadHookModule and hookFn call sites to accept and
propagate the signal.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/cron/hooks.ts`:
- Around line 144-150: The runtime type guard isAbortResult currently treats any
truthy abort value as an abort; change the check to require abort === true so
only a boolean true short-circuits. Update isAbortResult to assert "abort" in
value and (value as { abort: unknown }).abort === true (or typeof (value as
any).abort === "boolean" && (value as any).abort === true) so malformed values
like "{ abort: 'false' }" won't be treated as aborts while preserving the type
guard behavior.
- Around line 198-207: isValidJobHookPath currently only blocks absolute paths
and leading "..", allowing empty strings and scheme-like specifiers (e.g.,
data:, file:, http:) which loadHookModule treats as import specifiers; update
isValidJobHookPath to also return false for empty/whitespace strings and for any
value that looks like a URI/scheme (e.g., matches a scheme pattern like
/^[a-zA-Z][a-zA-Z0-9+.-]*:/ or contains "://") or starts with "//" so only
relative filesystem paths without schemes are accepted; keep existing
path.isAbsolute and path.normalize checks and perform the extra validations
after normalization to ensure loadHookModule cannot be fed a scheme-like
specifier.

In `@src/cron/service/ops.ts`:
- Around line 463-479: The abort branch inside the beforeRun hooks currently
returns early and skips the finalization that clears runningAtMs and emits
finished events; replace the bare return with the same finalization flow used
for normal completion: compute abortEndedAt/durationMs, run afterRun hooks (as
is done), then call the routine that performs the final locked update for manual
runs (the code path invoked after hooks/when not aborted — e.g., the same
function that clears runningAtMs, sets status/duration and emits finished
events), or inline those update steps here so prepareManualRun's persisted
runningAtMs is cleared and next-run/one-shot recomputation occurs. Ensure you
reference the existing helpers (prepareManualRun, loadHookEntries, runCronHooks,
makeHookCtx, state.deps.nowMs, executionJob, startedAt) and do not leave an
early return that bypasses the final locked update.

---

Duplicate comments:
In `@src/cron/hooks.ts`:
- Around line 95-113: Move the timeout creation to before importing the hook and
make hook execution cooperatively cancelable: create the timeout (via
createTimeout(timeoutMs) or an AbortController) before calling
loadHookModule(entry.script, ctx.basePath), pass an AbortSignal (or the timeout)
into loadHookModule and into the hook function invocation (hookFn(ctx, { signal
}) or similar) so top-level-await/imports and the hook body can observe
cancellation, use Promise.race([hookPromise, timeout.promise]) to await the
result, call timeout.clear() in finally, and after a timeout ensure you treat
the hook as aborted (log/continue) so a losing promise cannot silently mutate
shared state; update loadHookModule and hookFn call sites to accept and
propagate the signal.

In `@src/cron/service/timer.ts`:
- Around line 662-679: When a job is aborted you currently call
makeHookCtx("afterRun", { status: "skipped", durationMs }) so afterRun hooks
don't get the abort reason; update the afterRun context to include the abort
reason (e.g. { status: "skipped", durationMs: abortDurationMs, reason:
beforeResult.reason ?? "unknown" }) when you call runCronHooks after a
before-run abort (the block using beforeResult, loadHookEntries("afterRun"),
runCronHooks and makeHookCtx), and apply the identical change to the catch-up
abort branch that similarly returns status "skipped" and error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a8c6a295-2ec3-44d0-baed-6ed1472baf51

📥 Commits

Reviewing files that changed from the base of the PR and between faab903 and 41c7282.

📒 Files selected for processing (7)
  • src/config/types.cron.ts
  • src/config/zod-schema.ts
  • src/cron/hooks.test.ts
  • src/cron/hooks.ts
  • src/cron/service/ops.ts
  • src/cron/service/timer.ts
  • src/gateway/protocol/schema/cron.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/config/zod-schema.ts

Comment thread src/cron/hooks.ts
Comment on lines +144 to +150
function isAbortResult(value: unknown): value is { abort: boolean; reason?: string } {
return (
value != null &&
typeof value === "object" &&
"abort" in value &&
Boolean((value as { abort: unknown }).abort)
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Require abort === true before skipping a job.

Boolean(result.abort) treats any truthy value as an abort, so malformed results like { abort: "false" } or { abort: 1 } will still short-circuit execution.

🛠️ Narrow the runtime check
 function isAbortResult(value: unknown): value is { abort: boolean; reason?: string } {
   return (
     value != null &&
     typeof value === "object" &&
     "abort" in value &&
-    Boolean((value as { abort: unknown }).abort)
+    (value as { abort: unknown }).abort === true
   );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function isAbortResult(value: unknown): value is { abort: boolean; reason?: string } {
return (
value != null &&
typeof value === "object" &&
"abort" in value &&
Boolean((value as { abort: unknown }).abort)
);
function isAbortResult(value: unknown): value is { abort: boolean; reason?: string } {
return (
value != null &&
typeof value === "object" &&
"abort" in value &&
(value as { abort: unknown }).abort === true
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cron/hooks.ts` around lines 144 - 150, The runtime type guard
isAbortResult currently treats any truthy abort value as an abort; change the
check to require abort === true so only a boolean true short-circuits. Update
isAbortResult to assert "abort" in value and (value as { abort: unknown }).abort
=== true (or typeof (value as any).abort === "boolean" && (value as any).abort
=== true) so malformed values like "{ abort: 'false' }" won't be treated as
aborts while preserving the type guard behavior.

Comment thread src/cron/hooks.ts
Comment on lines +198 to +207
export function isValidJobHookPath(scriptPath: string): boolean {
// Reject absolute paths and traversal segments in per-job entries.
if (path.isAbsolute(scriptPath)) {
return false;
}
const normalized = path.normalize(scriptPath);
if (normalized.startsWith("..")) {
return false;
}
return true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Reject scheme-like and blank per-job hook paths.

isValidJobHookPath() only rejects absolute paths and leading .., so values like data:text/javascript,..., file:///tmp/hook.mjs, or "" still pass. loadHookModule() treats scheme-like strings as import specifiers, which lets a jobs.json hook bypass the intended relative-path restriction entirely.

🔒 Tighten the validator
 export function isValidJobHookPath(scriptPath: string): boolean {
-  // Reject absolute paths and traversal segments in per-job entries.
-  if (path.isAbsolute(scriptPath)) {
+  // Reject absolute paths, URL-like specifiers, blank values, and traversal
+  // segments in per-job entries.
+  if (
+    scriptPath.trim() === "" ||
+    path.isAbsolute(scriptPath) ||
+    /^[a-z][a-z0-9+.-]*:/i.test(scriptPath)
+  ) {
     return false;
   }
   const normalized = path.normalize(scriptPath);
-  if (normalized.startsWith("..")) {
+  if (normalized === "." || normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
     return false;
   }
   return true;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function isValidJobHookPath(scriptPath: string): boolean {
// Reject absolute paths and traversal segments in per-job entries.
if (path.isAbsolute(scriptPath)) {
return false;
}
const normalized = path.normalize(scriptPath);
if (normalized.startsWith("..")) {
return false;
}
return true;
export function isValidJobHookPath(scriptPath: string): boolean {
// Reject absolute paths, URL-like specifiers, blank values, and traversal
// segments in per-job entries.
if (
scriptPath.trim() === "" ||
path.isAbsolute(scriptPath) ||
/^[a-z][a-z0-9+.-]*:/i.test(scriptPath)
) {
return false;
}
const normalized = path.normalize(scriptPath);
if (normalized === "." || normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
return false;
}
return true;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cron/hooks.ts` around lines 198 - 207, isValidJobHookPath currently only
blocks absolute paths and leading "..", allowing empty strings and scheme-like
specifiers (e.g., data:, file:, http:) which loadHookModule treats as import
specifiers; update isValidJobHookPath to also return false for empty/whitespace
strings and for any value that looks like a URI/scheme (e.g., matches a scheme
pattern like /^[a-zA-Z][a-zA-Z0-9+.-]*:/ or contains "://") or starts with "//"
so only relative filesystem paths without schemes are accepted; keep existing
path.isAbsolute and path.normalize checks and perform the extra validations
after normalization to ensure loadHookModule cannot be fed a scheme-like
specifier.

Comment thread src/cron/service/ops.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/cron/hooks.ts (2)

68-74: Consider logging rejected per-job hook paths.

Invalid hook paths are silently skipped, which may make debugging difficult if a user misconfigures a job's hook path. A debug-level log would help diagnose why a hook isn't running.

💡 Optional: Add debug logging for rejected paths
+import { logVerbose } from "../globals.js";
+
   for (const script of jobScripts) {
     if (!isValidJobHookPath(script)) {
+      logVerbose(`Rejected invalid per-job hook path: ${script}`);
       continue;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cron/hooks.ts` around lines 68 - 74, Invalid per-job hook paths are
currently skipped silently in the loop over jobScripts; update the loop that
uses isValidJobHookPath to emit a debug-level log when a script is rejected
(include the script path and a short reason/context), and only push to
jobEntries with { script, priority: DEFAULT_PRIORITY } when valid. Add the debug
logging call next to the isValidJobHookPath check (e.g., logger.debug or
processLogger.debug) so rejected paths are visible during troubleshooting.

166-171: Add defensive check for job.name before calling toLowerCase().

If job.name is undefined, line 167 will throw. While CronJob likely always has a name, a defensive fallback would prevent runtime errors from malformed job data.

🛡️ Optional defensive fix
   if (f.jobName && f.jobName.length > 0) {
-    const nameLower = job.name.toLowerCase();
+    const nameLower = (job.name ?? "").toLowerCase();
     if (!f.jobName.some((pattern) => nameLower.includes(pattern.toLowerCase()))) {
       return false;
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cron/hooks.ts` around lines 166 - 171, The code calls
job.name.toLowerCase() without ensuring job.name exists, which can throw if job
data is malformed; update the block that defines nameLower (used in the
f.jobName check) to defensively handle missing names — e.g., check if job.name
is truthy before calling toLowerCase or use a safe fallback like an empty string
— so the f.jobName.some(...) check uses nameLower safely; refer to the symbols
f.jobName, job.name, and nameLower (and the surrounding filter logic) when
making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/cron/hooks.ts`:
- Around line 68-74: Invalid per-job hook paths are currently skipped silently
in the loop over jobScripts; update the loop that uses isValidJobHookPath to
emit a debug-level log when a script is rejected (include the script path and a
short reason/context), and only push to jobEntries with { script, priority:
DEFAULT_PRIORITY } when valid. Add the debug logging call next to the
isValidJobHookPath check (e.g., logger.debug or processLogger.debug) so rejected
paths are visible during troubleshooting.
- Around line 166-171: The code calls job.name.toLowerCase() without ensuring
job.name exists, which can throw if job data is malformed; update the block that
defines nameLower (used in the f.jobName check) to defensively handle missing
names — e.g., check if job.name is truthy before calling toLowerCase or use a
safe fallback like an empty string — so the f.jobName.some(...) check uses
nameLower safely; refer to the symbols f.jobName, job.name, and nameLower (and
the surrounding filter logic) when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bebab137-14e6-4cb3-921f-ddf99bcd4cc1

📥 Commits

Reviewing files that changed from the base of the PR and between 41c7282 and e88721b.

📒 Files selected for processing (2)
  • src/cron/hooks.ts
  • src/utils.ts

When a beforeRun hook aborted a manual run via finishPreparedManualRun,
the early return skipped the locked cleanup that clears runningAtMs.
This left the job stuck as "running" until STUCK_RUN_MS timed out.

Now the abort path applies a "skipped" result and clears state under
lock, matching the timer-path behavior.
@Arry8 Arry8 merged commit ff45fdf into main Mar 14, 2026
1 check passed
Arry8 pushed a commit that referenced this pull request Apr 5, 2026
* feat: add QQ Bot channel extension

* fix(qqbot): add setupWizard to runtime plugin for onboard re-entry

* fix: fix review

* fix: fix review

* chore: sync lockfile and config-docs baseline for qqbot extension

* refactor: 移除图床服务器相关代码

* fix

* docs: 新增 QQ Bot 插件文档并修正链接路径

* refactor: remove credential backup functionality and update setup logic

- Deleted the credential backup module to streamline the codebase.
- Updated the setup surface to handle client secrets more robustly, allowing for configured secret inputs.
- Simplified slash commands by removing unused hot upgrade compatibility checks and related functions.
- Adjusted types to use SecretInput for client secrets in QQBot configuration.
- Modified bundled plugin metadata to allow additional properties in the config schema.

* feat: 添加本地媒体路径解析功能,修正 QQBot 媒体路径处理

* feat: 添加本地媒体路径解析功能,修正 QQBot 媒体路径处理

* feat: remove qqbot-media and qqbot-remind skills, add tests for config and setup

- Deleted the qqbot-media and qqbot-remind skills documentation files.
- Added unit tests for qqbot configuration and setup processes, ensuring proper handling of SecretRef-backed credentials and account configurations.
- Implemented tests for local media path remapping, verifying correct resolution of media file paths.
- Removed obsolete channel and remind tools, streamlining the codebase.

* feat: 更新 QQBot 配置模式,添加音频格式和账户定义

* feat: 添加 QQBot 频道管理和定时提醒技能,更新媒体路径解析功能

* fix

* feat: 添加 /bot-upgrade 指令以查看 QQBot 插件升级指引

* feat: update reminder and qq channel skills

* feat: 更新remind工具投递目标地址格式

* feat: Refactor QQBot payload handling and improve code documentation

- Simplified and clarified the structure of payload interfaces for Cron reminders and media messages.
- Enhanced the parsing function to provide clearer error messages and improved validation.
- Updated platform utility functions for better cross-platform compatibility and clearer documentation.
- Improved text parsing utilities for better readability and consistency in emoji representation.
- Optimized upload cache management with clearer comments and reduced redundancy.
- Integrated QQBot plugin into the bundled channel plugins and updated metadata for installation.

* OK apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift

> openclaw@2026.3.26 check:bundled-channel-config-metadata /Users/yuehuali/code/PR/openclaw
> node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check

[bundled-channel-config-metadata] stale generated output at src/config/bundled-channel-config-metadata.generated.ts
 ELIFECYCLE  Command failed with exit code 1.
 ELIFECYCLE  Command failed with exit code 1.

* feat: 添加 QQBot 渠道配置及相关账户设置

* fix(qqbot): resolve 14 high-priority bugs from PR openclaw#52986 review

DM routing (7 fixes):
- #1: DM slash-command replies use sendDmMessage(guildId) instead of sendC2CMessage(senderId)
- #2: DM qualifiedTarget uses qqbot:dm:${guildId} instead of qqbot:c2c:${senderId}
- #3: sendTextChunks adds DM branch
- #4: sendMarkdownReply adds DM branch for text and Base64 images
- #5: parseAndSendMediaTags maps DM to targetType:dm + guildId
- #6: sendTextToTarget DM branch uses sendDmMessage; MessageTarget adds guildId field
- #7: handleImage/Audio/Video/FilePayload add DM branches

Other high-priority fixes:
- #8: Fix sendC2CVoiceMessage/sendGroupVoiceMessage parameter misalignment
- #9: broadcastMessage uses groupOpenid instead of member_openid for group users
- #10: Unify KnownUser storage - proactive.ts delegates to known-users.ts
- #11: Remove invalid recordKnownUser calls for guild/DM users
- #12: sendGroupMessage uses sendAndNotify to trigger onMessageSent hook
- #13: sendPhoto channel unsupported returns error field
- #14: sendTextAfterMedia adds channel and dm branches

Type fixes:
- DeliverEventContext adds guildId field
- MediaTargetContext.targetType adds dm variant
- sendPlainTextReply imgMediaTarget adds DM branch

* fix(qqbot): resolve 2 blockers + 7 medium-priority bugs from PR openclaw#52986 review

Blocker-1: Remove unused dmPolicy config knob
- dmPolicy was declared in schema/types/plugin.json but never consumed at runtime
- Removed from config-schema.ts, types.ts, and openclaw.plugin.json
- allowFrom remains active (already wired into framework command-auth)

Blocker-2: Gate sensitive slash commands with allowFrom authorization
- SlashCommand interface adds requireAuth?: boolean
- SlashCommandContext adds commandAuthorized: boolean
- /bot-logs set to requireAuth: true (reads local log files)
- matchSlashCommand rejects unauthorized senders for requireAuth commands
- trySlashCommandOrEnqueue computes commandAuthorized from allowFrom config

Medium-priority fixes:
- #15: Strip non-HTTP/non-local markdown image tags to prevent path leakage
- #16: applyQQBotAccountConfig clears clientSecret when setting clientSecretFile and vice versa
- #17: getAdminMarkerFile sanitizes accountId to prevent path traversal
- #18: URGENT_COMMANDS uses exact match instead of startsWith prefix match
- #19: isCronExpression validates each token starts with a cron-valid character
- #20: --token format validation rejects malformed input without colon separator
- #21: resolveDefaultQQBotAccountId checks QQBOT_APP_ID environment variable

* test(qqbot): add focused tests for slash command authorization path

- Unauthorized sender rejected for /bot-logs (requireAuth: true)
- Authorized sender allowed for /bot-logs
- Non-requireAuth commands (/bot-ping, /bot-help, /bot-version) work for all senders
- Unknown slash commands return null (passthrough)
- Non-slash messages return null
- Usage query (/bot-logs ?) also gated by auth check

* fix(qqbot): align global TTS fallback with framework config resolution

- Extract isGlobalTTSAvailable to utils/audio-convert.ts, mirroring core
  resolveTtsConfig logic: check auto !== 'off', fall back to legacy
  enabled boolean, default to off when neither is set.
- Add pre-check in reply-dispatcher before calling globalTextToSpeech to
  avoid unnecessary TTS calls and noisy error logs when TTS is not
  configured.
- Remove inline as any casts; use OpenClawConfig type throughout.
- Refactor handleAudioPayload into flat early-return structure with
  unified send path (plugin TTS → global fallback → send).

* fix(qqbot): break ESM circular dependency causing multi-account startup crash

The bundled gateway chunk had a circular static import on the channel
chunk (gateway -> outbound-deliver -> channel, while channel dynamically
imports gateway). When two accounts start concurrently via Promise.all,
the first dynamic import triggers module graph evaluation; the circular
reference causes api exports (including runDiagnostics) to resolve as
undefined before the module finishes evaluating.

Fix: extract chunkText and TEXT_CHUNK_LIMIT from channel.ts into a new
text-utils.ts leaf module. outbound-deliver.ts now imports from
text-utils.ts, breaking the cycle. channel.ts re-exports for backward
compatibility.

* fix(qqbot): serialize gateway module import to prevent multi-account startup race

When multiple accounts start concurrently via Promise.all, each calls
await import('./gateway.js') independently. Due to ESM circular
dependencies in the bundled output, the first import can resolve
transitive exports as undefined before module evaluation completes.

Fix: cache the dynamic import promise in a module-level variable so all
concurrent startAccount calls share the same import, ensuring the
gateway module is fully evaluated before any account uses it.

* refactor(qqbot): remove startup greeting logic

Remove getStartupGreetingPlan and related startup greeting delivery:
- Delete startup-greeting.ts (greeting plan, marker persistence)
- Delete admin-resolver.ts (admin resolution, greeting dispatch)
- Remove startup greeting calls from gateway READY/RESUMED handlers
- Remove isFirstReadyGlobal flag and adminCtx

* fix(qqbot): skip octal escape decoding for Windows local paths

Windows paths like C:\Users\1\file.txt contain backslash-digit sequences
that were incorrectly matched as octal escape sequences and decoded,
corrupting the file path. Detect Windows local paths (drive letter or UNC
prefix) and skip the octal decoding step for them.

* fix bot issue

* feat: 支持 TTS 自动开关并清理配置中的 clientSecretFile

* docs: 添加 QQBot 配置和消息处理的设计说明

* rebase

* fix(qqbot): align slash-command auth with shared command-auth model

Route requireAuth:true slash commands (e.g. /bot-logs) through the
framework's api.registerCommand() so resolveCommandAuthorization()
applies commands.allowFrom.qqbot precedence and qqbot: prefix
normalization before any handler runs.

- slash-commands.ts: registerCommand() now auto-routes by requireAuth
  into two maps (commands / frameworkCommands); getFrameworkCommands()
  exports the auth-required set for framework registration; bot-help
  lists both maps
- index.ts: registerFull() iterates getFrameworkCommands() and calls
  api.registerCommand() for each; handler derives msgType from ctx.from,
  sends file attachments via sendDocument, supports multi-account via
  ctx.accountId
- gateway.ts (inbound): replace raw allowFrom string comparison with
  qqbotPlugin.config.formatAllowFrom() to strip qqbot: prefix and
  uppercase before matching event.senderId
- gateway.ts (pre-dispatch): remove stale auth computation; commandAuthorized
  is true (requireAuth:true commands never reach matchSlashCommand)
- command-auth.test.ts: add regression tests for qqbot: prefix
  normalization in the inbound commandAuthorized computation
- slash-commands.test.ts: update /bot-logs tests to expect null
  (command routed to framework, not in local registry)

* rebase and solve conflict

* fix(qqbot): preserve mixed env setup credentials

---------

Co-authored-by: yuehuali <yuehuali@tencent.com>
Co-authored-by: walli <walli@tencent.com>
Co-authored-by: WideLee <limkuan24@gmail.com>
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
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.

1 participant