Skip to content

fix: fire before_reset plugin hook from gateway sessions.reset#29969

Closed
mcaxtr wants to merge 2 commits intoopenclaw:mainfrom
mcaxtr:25074-before-reset-hook
Closed

fix: fire before_reset plugin hook from gateway sessions.reset#29969
mcaxtr wants to merge 2 commits intoopenclaw:mainfrom
mcaxtr:25074-before-reset-hook

Conversation

@mcaxtr
Copy link
Copy Markdown
Contributor

@mcaxtr mcaxtr commented Feb 28, 2026

Summary

Describe the problem and fix in 2–5 bullets:

  • Problem: Plugin api.on('before_reset', ...) handlers never fired when a session reset was triggered via the gateway sessions.reset RPC (e.g., from the TUI /new or /reset command). The gateway path only called triggerInternalHook() (internal hook bus) but never invoked hookRunner.runBeforeReset() (plugin hook runner). The auto-reply path in commands-core.ts correctly calls both.
  • Why it matters: Plugins relying on before_reset to save state or flush buffers received no signal for gateway-originated resets — the dominant reset path in production (TUI, web UI, mobile).
  • What changed: sessions.reset handler in src/gateway/server-methods/sessions.ts now checks hookRunner.hasHooks("before_reset") and calls hookRunner.runBeforeReset() fire-and-forget before the session is cleared, mirroring the pattern in commands-core.ts:90-130.
  • What did NOT change (scope boundary): The auto-reply path's hook dispatch is untouched. triggerInternalHook() calls unchanged. Plugin SDK, hook runner interface, and all other RPC handlers are unmodified.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

Plugins registering api.on('before_reset', ...) now receive hook events when a session is reset from any gateway-connected client (TUI, web UI, mobile apps). Previously this only fired on the auto-reply path. No config or API changes required.

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)
  • If any Yes, explain risk + mitigation: N/A

Repro + Verification

Environment

  • OS: Linux / macOS
  • Runtime/container: Node 22+
  • Model/provider: Any
  • Integration/channel (if any): Any gateway-connected client (TUI, web UI)
  • Relevant config (redacted): Any config with a plugin registering before_reset

Steps

  1. Install a plugin that registers api.on('before_reset', handler)
  2. Open the TUI or any gateway client and issue /new or /reset
  3. Observe whether the plugin handler fires

Expected

  • Plugin before_reset handler fires with session messages and reason before the session is cleared

Actual

  • Plugin handler never fires; hookRunner.runBeforeReset() was never called from the gateway path

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

New test file src/gateway/server-methods/sessions.before-reset-hook.test.ts (4 tests) covers: hook fires when handlers registered, does not fire when no handlers, correct reason propagation, transcript JSONL read and messages extracted.

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios: Traced both code paths (commands-core.ts auto-reply and sessions.ts gateway) side by side. Confirmed the gateway path previously lacked hookRunner.runBeforeReset(). Verified new code follows the same guard pattern used elsewhere. Uses target.agentId (properly resolved by resolveGatewaySessionStoreTarget) instead of naive string splitting.
  • Edge cases checked: No session file present (fires hook with empty messages, logs verbose warning). reason: "new" propagates correctly. Hook errors caught and logged without blocking reset.
  • What you did not verify: End-to-end test with a live plugin on a running gateway instance.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)
  • If yes, exact upgrade steps: N/A

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: Revert src/gateway/server-methods/sessions.ts to remove the before_reset hook block
  • Files/config to restore: src/gateway/server-methods/sessions.ts
  • Known bad symptoms reviewers should watch for: A plugin's before_reset handler throwing an unhandled error that escapes the try/catch (should be swallowed by the catch + logVerbose)

Risks and Mitigations

  • Risk: A misbehaving plugin before_reset handler could hang or be slow
    • Mitigation: The hook fires fire-and-forget; the respond() call and session store update proceed immediately, so RPC response latency is unaffected.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e567df43e8

ℹ️ 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".

@mcaxtr
Copy link
Copy Markdown
Contributor Author

mcaxtr commented Feb 28, 2026

@greptile please re-review this PR.

@mcaxtr
Copy link
Copy Markdown
Contributor Author

mcaxtr commented Feb 28, 2026

@greptile review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Feb 28, 2026

Greptile Summary

This PR fixes a bug where plugin before_reset handlers never fired when sessions were reset via the gateway path (TUI /new, /reset commands). The gateway sessions.reset handler now correctly calls hookRunner.runBeforeReset() before clearing the session.

Key implementation details:

  • Eagerly reads transcript file (awaited) before archival can rename it, avoiding the race condition identified in previous iterations
  • Dispatches hook execution fire-and-forget to avoid blocking the reset response
  • Only fires after successful cleanup prechecks, matching the auto-reply path pattern
  • Uses properly resolved target.agentId instead of naive string splitting

Testing:

  • Comprehensive test coverage including hook firing, reason propagation, transcript parsing, and cleanup failure scenarios
  • Test file properly creates real JSONL files and validates message extraction

Evolution:
The PR went through multiple iterations to get the design right, balancing race condition avoidance with response latency - the final implementation (585c5e3) splits eager file read from fire-and-forget dispatch.

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • Score reflects solid implementation with proper race condition handling, comprehensive test coverage (4 test cases covering happy path, edge cases, and failure scenarios), clear documentation, and backward compatibility. The fix addresses a real bug where plugins never received reset notifications from the dominant reset path in production. No security concerns, no breaking changes, and implementation follows established patterns from the auto-reply path.
  • No files require special attention

Last reviewed commit: 585c5e3

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

@mcaxtr
Copy link
Copy Markdown
Contributor Author

mcaxtr commented Feb 28, 2026

Fixed in 22c3fdc — transcript is now read eagerly (awaited) before the fire-and-forget runBeforeReset call. This eliminates the race between readFile and archiveSessionTranscriptsForSession. The hook invocation itself remains fire-and-forget via .catch().

@greptile please re-review this PR.
@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

ℹ️ 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".

@mcaxtr
Copy link
Copy Markdown
Contributor Author

mcaxtr commented Feb 28, 2026

@greptile please re-review this PR.

@mcaxtr
Copy link
Copy Markdown
Contributor Author

mcaxtr commented Feb 28, 2026

Both Codex concerns addressed:

  • P1: Hook now fires after prechecks succeed (9b2539f). Added test for cleanup failure → hook not called.
  • P2: Transcript read is eager/awaited (22c3fdc). No race with archival.

@greptile please re-review this PR.
@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b2539f9e2

ℹ️ 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".

@mcaxtr
Copy link
Copy Markdown
Contributor Author

mcaxtr commented Feb 28, 2026

Addressed P2 in a6456b6 — transcript read moved after cleanup completes.

Current flow: triggerInternalHook → cleanup prechecks → read transcript → fire hook (fire-and-forget) → updateSessionStore → archive

@greptile please re-review this PR.
@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6456b684e

ℹ️ 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".

@mcaxtr
Copy link
Copy Markdown
Contributor Author

mcaxtr commented Feb 28, 2026

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6456b684e

ℹ️ 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".

@mcaxtr
Copy link
Copy Markdown
Contributor Author

mcaxtr commented Feb 28, 2026

@greptile please re-review this PR.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 585c5e3c7f

ℹ️ 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".

@mcaxtr
Copy link
Copy Markdown
Contributor Author

mcaxtr commented Feb 28, 2026

@greptile please re-review this PR.

@mcaxtr
Copy link
Copy Markdown
Contributor Author

mcaxtr commented Feb 28, 2026

Review note: Codex vs Greptile contradiction

The last two review cycles revealed a direct contradiction between the two bots:

  • Greptile (#2868004236) flagged the fire-and-forget IIFE pattern (commit 4cecc1b) because archiveSessionTranscriptsForSession calls fs.renameSync on the transcript file after updateSessionStore, causing an ENOENT race with the async readFile.
  • Codex (#2868008570) flagged the eager await readFile (commit 585c5e3) because it blocks the reset response path.

The current approach (585c5e3) is the correct trade-off:

  1. Eager read (awaited): local file I/O is O(ms), and the read MUST happen before archival renames the file — otherwise messages are lost.
  2. Fire-and-forget dispatch: only hookRunner.runBeforeReset() (the slow plugin RPC) is non-blocking via void ... .catch().

This is the only solution that avoids both the archival race AND keeps the slow hook dispatch off the response path.

@greptile @chatgpt-codex-connector please re-review this PR.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

ℹ️ 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".

@mcaxtr mcaxtr force-pushed the 25074-before-reset-hook branch from 585c5e3 to c1934b0 Compare March 1, 2026 00:23
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c1934b0195

ℹ️ 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".

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Mar 2, 2026

🔒 Aisle Security Analysis

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

# Severity Title
1 🟡 Medium Resource exhaustion in sessions.reset before_reset hook due to unbounded transcript read/parse
2 🔵 Low Arbitrary file read via unvalidated sessionFile path in sessions.reset before_reset hook

1. 🟡 Resource exhaustion in sessions.reset before_reset hook due to unbounded transcript read/parse

Property Value
Severity Medium
CWE CWE-400
Location src/gateway/server-methods/sessions.ts:492-513

Description

The sessions.reset handler reads the entire session transcript into memory and splits/parses it synchronously before sending the RPC response when a before_reset plugin hook is registered.

  • sessionFile points to a JSONL transcript that can grow large in normal operation (long LLM conversations) and can also contain attacker-influenced content (user message volume/size).
  • await fs.promises.readFile(sessionFile, "utf-8") loads the whole file into a single string (memory spike proportional to file size).
  • content.split("\n") allocates an additional array of all lines (additional memory overhead) and then JSON.parse is executed for each line (CPU-intensive and blocks the event loop).
  • messages.push(...) is unbounded, potentially accumulating a very large in-memory array.
  • Despite the comment claiming “fire-and-forget … to avoid blocking the response”, the expensive read/split/parse happens prior to respond(...), so the request can be used to tie up the gateway process.

Vulnerable code:

const content = await fs.promises.readFile(sessionFile, "utf-8");
for (const line of content.split("\n")) {
  ...
  const parsed = JSON.parse(line);
  if (parsed.type === "message" && parsed.message) {
    messages.push(parsed.message);
  }
}

Recommendation

Avoid reading/splitting the entire transcript in the request path. Prefer streaming + hard limits (bytes/lines/messages) and consider truncating to the last N messages.

Example: stream JSONL lines with a maximum file size and message cap:

import { createReadStream } from "node:fs";
import readline from "node:readline";

const MAX_TRANSCRIPT_BYTES = 5 * 1024 * 1024; // tune
const MAX_MESSAGES = 200; // tune

const stat = await fs.promises.stat(sessionFile);
if (stat.size > MAX_TRANSCRIPT_BYTES) {// Either skip, or only read the tail, or pass a truncated indicator
}

const messages: unknown[] = [];
const rl = readline.createInterface({
  input: createReadStream(sessionFile, { encoding: "utf-8" }),
  crlfDelay: Infinity,
});

for await (const line of rl) {
  if (messages.length >= MAX_MESSAGES) break;
  if (!line.trim()) continue;
  try {
    const parsed = JSON.parse(line);
    if (parsed?.type === "message" && parsed.message) messages.push(parsed.message);
  } catch {// skip malformed lines
  }
}

Additionally:

  • Cap messages by count and/or total JSON bytes (e.g., reuse capArrayByJsonBytes).
  • If the hook must see the transcript even after archival, consider archiving first and passing the archived path, then parsing asynchronously in a background task (so respond(...) is not delayed).

2. 🔵 Arbitrary file read via unvalidated sessionFile path in sessions.reset before_reset hook

Property Value
Severity Low
CWE CWE-22
Location src/gateway/server-methods/sessions.ts:495-501

Description

In sessions.reset, the new before_reset hook implementation reads entry.sessionFile directly from the session store and passes parsed content to plugin hooks.

  • entry.sessionFile is loaded from the persisted sessions store (loadSessionEntry(key)sessions.json). If this stored path is stale/corrupt or attacker-tampered (e.g., via sessions store manipulation, untrusted state sync/restore, or any bug that allows writing to the store), the gateway will read an arbitrary file path on disk.
  • The read uses fs.promises.readFile(sessionFile, "utf-8") with no containment check, no realpath/symlink defense, and no size limit.
  • The file contents are then forwarded to plugin code via hookRunner.runBeforeReset(...), which can exfiltrate sensitive host data.

Vulnerable code:

const sessionFile = entry?.sessionFile;
...
const content = await fs.promises.readFile(sessionFile, "utf-8");

Recommendation

Treat entry.sessionFile as untrusted metadata and only read transcripts through a path-resolution + boundary check.

Recommended changes:

  1. Resolve a safe transcript path using the existing session transcript helpers (these perform containment checks and realpath-based handling for legacy absolute paths):
import path from "node:path";
import { resolveSessionFilePath } from "../../config/sessions/paths.js";

const sessionsDir = path.dirname(storePath);
const safeTranscriptPath = entry?.sessionId
  ? resolveSessionFilePath(entry.sessionId, { sessionFile }, { sessionsDir, agentId: target.agentId })
  : null;
  1. Enforce boundary + size limits (and avoid following symlinks outside the sessions dir) by opening the file with the existing boundary-safe helper (or equivalent) and reading only up to a maximum size (or streaming JSONL line-by-line):
import { openBoundaryFile } from "../../infra/boundary-file-read.js";

const opened = await openBoundaryFile({
  absolutePath: safeTranscriptPath,
  rootPath: sessionsDir,
  boundaryLabel: "sessions dir",
  maxBytes: 2 * 1024 * 1024, // example cap
});
if (!opened.ok) {// fire hook with empty messages
} else {
  try {
    const content = fs.readFileSync(opened.fd, "utf-8");// parse jsonl...
  } finally {
    fs.closeSync(opened.fd);
  }
}
  1. Consider capping messages (count and/or total bytes) passed to plugins to prevent memory/CPU spikes on large transcripts.

Analyzed PR: #29969 at commit 65fcaf3

Last updated on: 2026-03-05T04:29:59Z

@mcaxtr mcaxtr force-pushed the 25074-before-reset-hook branch from 5f73cae to 2818a6e Compare March 3, 2026 04:11
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2818a6eda6

ℹ️ 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".

return fn(store);
},
),
ensureSessionRuntimeCleanup: vi.fn(async () => null),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Allow cleanup-failure test to reach the failure path

The shared mock hard-codes ensureSessionRuntimeCleanup to always succeed, so the later test that expects reset cleanup failure (does NOT fire hook when session runtime cleanup fails) never actually exercises that branch. In this setup, mocking waitForEmbeddedPiRunEnd has no effect because sessions.reset calls the mocked ensureSessionRuntimeCleanup directly, causing the assertion that runBeforeReset is not called to fail (or, if adjusted, to provide false confidence).

Useful? React with 👍 / 👎.

@mcaxtr mcaxtr force-pushed the 25074-before-reset-hook branch from 2818a6e to 65fcaf3 Compare March 5, 2026 04:02
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65fcaf3f82

ℹ️ 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".

return fn(store);
},
),
ensureSessionRuntimeCleanup: vi.fn(async () => null),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Let cleanup-failure test hit the real failure branch

ensureSessionRuntimeCleanup is globally mocked to always return null, so the later case that sets waitForEmbeddedPiRunEnd to false never triggers the cleanup-error path in sessions.reset. In this setup the reset flow still proceeds and can call runBeforeReset, which means the test either fails for the wrong reason or provides false confidence about failure handling. Override ensureSessionRuntimeCleanup in that test (or stop mocking it there) so the negative branch is actually exercised.

Useful? React with 👍 / 👎.

@openclaw-barnacle
Copy link
Copy Markdown

Closing this PR because the author has more than 10 active PRs in this repo. Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.

@openclaw-barnacle openclaw-barnacle bot closed this Mar 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Plugin api.on('command', ...) handlers never fire - triggerInternalHook and typedHooks are disconnected systems

2 participants