openclaw - ✅(Solved) Fix registerHook wrapper drops mutable context changes for agent:bootstrap hooks [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#75245Fetched 2026-05-01 05:36:16
View on GitHub
Comments
1
Participants
2
Timeline
2
Reactions
2
Timeline (top)
commented ×1cross-referenced ×1

registerHook() currently wraps plugin hook events by shallow-cloning event.context before invoking the plugin handler. That breaks mutable hook semantics for hooks such as agent:bootstrap, where core intentionally expects plugins to mutate event.context.bootstrapFiles before bootstrap files are converted into injected contextFiles.

The result is a very confusing failure mode: a plugin can log that it successfully changed bootstrap files, but the final model-visible prompt and systemPromptReport.injectedWorkspaceFiles still contain the original files.

This was found while debugging a subagent bootstrap limiter plugin, but the bug is generic to any hook that expects mutations to event.context to survive the handler call.

Root Cause

This is especially costly for subagents because it can inject large persona/operator files into every child run, increasing prompt size and giving subagents the wrong instructions.

Fix Action

Fix / Workaround

const result = await handler({ ...evt, context: readonlyContext });
if (result?.contextPatch?.bootstrapFiles) {
  evt.context.bootstrapFiles = result.contextPatch.bootstrapFiles;
}

PR fix notes

PR #75249: fix(plugins): preserve mutable context in plugin hook wrapper

Description (problem / solution / changelog)

Summary

registerHook() wraps plugin handlers by shallow-cloning event.context to inject pluginConfig:

// Before (broken)
return handler({ ...evt, context: { ...evt.context, pluginConfig } });

This silently discards mutations that mutable hook contracts require. agent:bootstrap is the primary victim: core reads event.context.bootstrapFiles after triggerInternalHook returns, expecting plugins to have mutated it. Instead, the plugin's assignment lands on the discarded clone.

The fix passes the original event.context object and temporarily augments it with pluginConfig via a try/finally cleanup:

const context = evt.context ?? {};
const hadPluginConfig = Object.prototype.hasOwnProperty.call(context, "pluginConfig");
const previousPluginConfig = context.pluginConfig;
context.pluginConfig = pluginConfig;
try {
  return await handler({ ...evt, context });
} finally {
  if (hadPluginConfig) context.pluginConfig = previousPluginConfig;
  else delete context.pluginConfig;
}

Invariants preserved:

  • pluginConfig is visible to the handler during execution ✓ (existing test: "injects plugin config into internal hook event context")
  • pluginConfig does not leak onto event.context after handler returns ✓ (new test)
  • Mutations to event.context.bootstrapFiles survive the wrapper ✓ (new test, regression for #75245)

Pre-implement audit

  1. Existing-helper check: pluginConfig injection pattern is unique to this wrapper — no shared helper to reuse.
  2. Shared-helper caller check: registerHook closure is only called from within createPluginRegistry and its createApi path — no external caller contract affected.
  3. Rival PR scan: no open PRs targeting registerHook wrapper or wrappedHandler context mutation.

Test plan

  • src/agents/bootstrap-hooks.test.ts — 3 tests pass (1 existing + 2 regression)
  • src/plugins/loader.test.ts — 131/131 pass (including "injects plugin config into internal hook event context" which verifies pluginConfig is still injected)

Fixes #75245.

🤖 Generated with Claude Code

Changed files

  • src/agents/bootstrap-hooks.test.ts (modified, +66/-0)
  • src/plugins/registry.ts (modified, +17/-3)

Code Example

event.context.bootstrapFiles = updatedFiles;

---

const wrappedHandler = async (evt) => {
  return handler({
    ...evt,
    context: {
      ...evt.context,
      pluginConfig
    }
  });
};

---

[plugins] [subagent-context-limiter] stripped SOUL.md from agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06 (... chars saved)
[plugins] [subagent-context-limiter] stripped IDENTITY.md from agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06 (... chars saved)
[plugins] [subagent-context-limiter] stripped USER.md from agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06 (... chars saved)
[plugins] [subagent-context-limiter] swapped AGENTS.md -> SUBAGENTS.md for agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06

---

[agent/embedded] workspace bootstrap file AGENTS.md is 43728 chars (limit 40000); truncating in injected context (sessionKey=agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06)
[agent/embedded] workspace bootstrap file SOUL.md is 43838 chars (limit 40000); truncating in injected context (sessionKey=agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06)

---

{
  "sessionKey": "agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06",
  "injectedWorkspaceFiles": [
    {
      "name": "AGENTS.md",
      "path": "/.../workspace-eva/AGENTS.md",
      "rawChars": 43728,
      "injectedChars": 39999,
      "truncated": true
    },
    {
      "name": "SOUL.md",
      "path": "/.../workspace-eva/SOUL.md",
      "rawChars": 43838,
      "injectedChars": 39999,
      "truncated": true
    },
    {
      "name": "TOOLS.md",
      "truncated": false
    },
    {
      "name": "IDENTITY.md",
      "truncated": false
    },
    {
      "name": "USER.md",
      "truncated": false
    }
  ]
}

---

api.registerHook("agent:bootstrap", async (event) => {
  const context = event.context;
  const files = context.bootstrapFiles;
  const updated = files
    .filter((file) => !["SOUL.md", "USER.md", "IDENTITY.md"].includes(file.name))
    .map((file) => file.name === "AGENTS.md"
      ? { ...file, name: "SUBAGENTS.md", path: subagentsPath, content: subagentsContent }
      : file);

  context.bootstrapFiles = updated;
});

---

async function applyBootstrapHookOverrides(params) {
  const event = createInternalHookEvent("agent", "bootstrap", sessionKey, {
    workspaceDir: params.workspaceDir,
    bootstrapFiles: params.files,
    cfg: params.config,
    sessionKey: params.sessionKey,
    sessionId: params.sessionId,
    agentId
  });

  await triggerInternalHook(event);
  const updated = event.context.bootstrapFiles;
  return Array.isArray(updated) ? updated : params.files;
}

---

export default function register(api) {
  api.registerHook("agent:bootstrap", async (event) => {
    event.context.bootstrapFiles = [
      {
        name: "SENTINEL.md",
        path: "/tmp/SENTINEL.md",
        content: "SENTINEL_BOOTSTRAP_CONTEXT"
      }
    ];
  }, { name: "bootstrap-sentinel" });
}

---

SENTINEL.md

---

AGENTS.md, SOUL.md, ... original workspace bootstrap files

---

const wrappedHandler = async (evt) => {
  const context = evt.context ?? {};
  const hadPluginConfig = Object.prototype.hasOwnProperty.call(context, "pluginConfig");
  const previousPluginConfig = context.pluginConfig;

  context.pluginConfig = pluginConfig;
  try {
    return await handler({
      ...evt,
      context
    });
  } finally {
    if (hadPluginConfig) context.pluginConfig = previousPluginConfig;
    else delete context.pluginConfig;
  }
};

---

const originalContext = evt.context ?? {};
const clonedContext = { ...originalContext, pluginConfig };
await handler({ ...evt, context: clonedContext });
Object.assign(originalContext, clonedContext);
delete originalContext.pluginConfig; // or restore previous

---

const result = await handler({ ...evt, context: readonlyContext });
if (result?.contextPatch?.bootstrapFiles) {
  evt.context.bootstrapFiles = result.contextPatch.bootstrapFiles;
}
RAW_BUFFERClick to expand / collapse

Summary

registerHook() currently wraps plugin hook events by shallow-cloning event.context before invoking the plugin handler. That breaks mutable hook semantics for hooks such as agent:bootstrap, where core intentionally expects plugins to mutate event.context.bootstrapFiles before bootstrap files are converted into injected contextFiles.

The result is a very confusing failure mode: a plugin can log that it successfully changed bootstrap files, but the final model-visible prompt and systemPromptReport.injectedWorkspaceFiles still contain the original files.

This was found while debugging a subagent bootstrap limiter plugin, but the bug is generic to any hook that expects mutations to event.context to survive the handler call.

Impact

Plugins using api.registerHook() to mutate context may appear to work locally but lose their changes before core consumes them.

Observed impact on agent:bootstrap:

  • plugin logs successful AGENTS.md -> SUBAGENTS.md swap
  • plugin logs successful removal of large workspace files like SOUL.md, USER.md, IDENTITY.md
  • actual child run still receives truncated AGENTS.md / SOUL.md
  • systemPromptReport.injectedWorkspaceFiles confirms the final prompt used the original bootstrap set

This is especially costly for subagents because it can inject large persona/operator files into every child run, increasing prompt size and giving subagents the wrong instructions.

Expected behavior

For an agent:bootstrap hook, if a plugin mutates:

event.context.bootstrapFiles = updatedFiles;

then core bootstrap resolution should use updatedFiles when building injected context and prompt warning/report metadata.

More generally, if a hook API exposes event.context as mutable, mutations should either:

  1. be applied to the original context object, or
  2. be explicitly merged back after the hook returns, or
  3. be documented/retyped as immutable and replaced with a return-value API.

The current behavior looks mutable but acts immutable.

Actual behavior

The plugin handler receives a cloned context object, mutates that clone, and logs success. Core then continues with the original unmodified context.

The relevant wrapper appears to be in the plugin loader:

const wrappedHandler = async (evt) => {
  return handler({
    ...evt,
    context: {
      ...evt.context,
      pluginConfig
    }
  });
};

Because context is shallow-cloned, assignments like context.bootstrapFiles = updated are lost.

Evidence from a real run

A subagent probe was spawned whose task was to report only what bootstrap files were visible in its context.

The custom plugin logged that it fired and swapped the files for the exact child session:

[plugins] [subagent-context-limiter] stripped SOUL.md from agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06 (... chars saved)
[plugins] [subagent-context-limiter] stripped IDENTITY.md from agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06 (... chars saved)
[plugins] [subagent-context-limiter] stripped USER.md from agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06 (... chars saved)
[plugins] [subagent-context-limiter] swapped AGENTS.md -> SUBAGENTS.md for agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06

Immediately after, core logged truncation for the original files on the same session key:

[agent/embedded] workspace bootstrap file AGENTS.md is 43728 chars (limit 40000); truncating in injected context (sessionKey=agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06)
[agent/embedded] workspace bootstrap file SOUL.md is 43838 chars (limit 40000); truncating in injected context (sessionKey=agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06)

The session's stored systemPromptReport confirmed the final prompt contained the original files:

{
  "sessionKey": "agent:main:subagent:e85eef1d-02e5-49cf-aed8-6f39076d2c06",
  "injectedWorkspaceFiles": [
    {
      "name": "AGENTS.md",
      "path": "/.../workspace-eva/AGENTS.md",
      "rawChars": 43728,
      "injectedChars": 39999,
      "truncated": true
    },
    {
      "name": "SOUL.md",
      "path": "/.../workspace-eva/SOUL.md",
      "rawChars": 43838,
      "injectedChars": 39999,
      "truncated": true
    },
    {
      "name": "TOOLS.md",
      "truncated": false
    },
    {
      "name": "IDENTITY.md",
      "truncated": false
    },
    {
      "name": "USER.md",
      "truncated": false
    }
  ]
}

The child model also reported seeing AGENTS.md / SOUL.md and not SUBAGENTS.md, matching the prompt report.

Why this is not just a custom plugin bug

The custom plugin is simple and uses the hook shape OpenClaw exposes:

api.registerHook("agent:bootstrap", async (event) => {
  const context = event.context;
  const files = context.bootstrapFiles;
  const updated = files
    .filter((file) => !["SOUL.md", "USER.md", "IDENTITY.md"].includes(file.name))
    .map((file) => file.name === "AGENTS.md"
      ? { ...file, name: "SUBAGENTS.md", path: subagentsPath, content: subagentsContent }
      : file);

  context.bootstrapFiles = updated;
});

Core appears to be designed to support this mutation path:

async function applyBootstrapHookOverrides(params) {
  const event = createInternalHookEvent("agent", "bootstrap", sessionKey, {
    workspaceDir: params.workspaceDir,
    bootstrapFiles: params.files,
    cfg: params.config,
    sessionKey: params.sessionKey,
    sessionId: params.sessionId,
    agentId
  });

  await triggerInternalHook(event);
  const updated = event.context.bootstrapFiles;
  return Array.isArray(updated) ? updated : params.files;
}

So the hook contract and core consumer imply mutable context. The plugin loader wrapper prevents that contract from working.

Relevant code path

High-level flow:

  1. resolveBootstrapFilesForRun(...) loads workspace bootstrap files.
  2. applyBootstrapHookOverrides(...) fires agent:bootstrap and expects event.context.bootstrapFiles to be possibly changed.
  3. resolveBootstrapContextForRun(...) calls buildBootstrapContextFiles(bootstrapFiles, ...) to build injected context.
  4. selection prompt assembly uses those contextFiles for buildEmbeddedSystemPrompt(...) and systemPromptReport.

The failure occurs between steps 2 and 3 because the plugin wrapper cloned event.context.

Minimal reproduction idea

Register a plugin hook:

export default function register(api) {
  api.registerHook("agent:bootstrap", async (event) => {
    event.context.bootstrapFiles = [
      {
        name: "SENTINEL.md",
        path: "/tmp/SENTINEL.md",
        content: "SENTINEL_BOOTSTRAP_CONTEXT"
      }
    ];
  }, { name: "bootstrap-sentinel" });
}

Then start any run that injects bootstrap files and inspect systemPromptReport.injectedWorkspaceFiles.

Expected:

SENTINEL.md

Actual with current wrapper semantics:

AGENTS.md, SOUL.md, ... original workspace bootstrap files

Possible fixes

Option A — preserve mutable context object in the wrapper

Pass the original context object to the handler and temporarily attach pluginConfig to it:

const wrappedHandler = async (evt) => {
  const context = evt.context ?? {};
  const hadPluginConfig = Object.prototype.hasOwnProperty.call(context, "pluginConfig");
  const previousPluginConfig = context.pluginConfig;

  context.pluginConfig = pluginConfig;
  try {
    return await handler({
      ...evt,
      context
    });
  } finally {
    if (hadPluginConfig) context.pluginConfig = previousPluginConfig;
    else delete context.pluginConfig;
  }
};

Pros:

  • preserves existing mutable hook contract
  • minimal behavioral change
  • fixes all context-mutating hooks, not just agent:bootstrap

Caution:

  • if multiple hooks run sequentially, pluginConfig is temporary per handler; persistent plugin config should not leak between handlers

Option B — merge mutable fields back after handler

Keep the clone but copy known mutable fields back:

const originalContext = evt.context ?? {};
const clonedContext = { ...originalContext, pluginConfig };
await handler({ ...evt, context: clonedContext });
Object.assign(originalContext, clonedContext);
delete originalContext.pluginConfig; // or restore previous

Pros:

  • preserves isolation during handler execution

Cons:

  • can accidentally merge pluginConfig
  • still relies on mutation semantics
  • less clear than Option A

Option C — make hook context immutable and add explicit return-value support

For example:

const result = await handler({ ...evt, context: readonlyContext });
if (result?.contextPatch?.bootstrapFiles) {
  evt.context.bootstrapFiles = result.contextPatch.bootstrapFiles;
}

Pros:

  • clearer contract long-term
  • easier to type and test

Cons:

  • larger API change
  • existing plugins that mutate context remain broken unless backward compatibility is added

Option D — special-case agent:bootstrap in core

Enforce subagent bootstrap substitution or a post-hook validation inside resolveBootstrapFilesForRun().

Pros:

  • could protect a high-impact path

Cons:

  • does not fix the generic registerHook() mutable-context bug
  • leaves other hooks with the same footgun

Recommended fix

I recommend Option A as the immediate fix, plus a regression test for agent:bootstrap mutation propagation.

Suggested regression test:

  1. Register an agent:bootstrap hook that replaces bootstrapFiles with a sentinel file.
  2. Resolve bootstrap context for a run.
  3. Assert:
    • returned bootstrapFiles contains SENTINEL.md
    • returned contextFiles contains SENTINEL_BOOTSTRAP_CONTEXT
    • returned files do not include original AGENTS.md / SOUL.md

A second test should assert that pluginConfig is visible to the handler but does not permanently leak onto the shared event context after handler completion.

Versions / environment

Observed on OpenClaw 2026.4.27 installed from npm.

This was originally noticed after upgrading from a local branch / 2026.4.24 install while testing subagent bootstrap behavior.

extent analysis

TL;DR

The most likely fix for the issue is to preserve the mutable context object in the wrapper by passing the original context object to the handler and temporarily attaching pluginConfig to it.

Guidance

  • The issue arises from the plugin loader wrapper cloning the event.context object, causing mutations made by plugins to be lost.
  • To fix this, the wrapper should pass the original context object to the handler, allowing mutations to persist.
  • One possible solution is to implement Option A, which preserves the mutable context object in the wrapper.
  • Another approach is to add explicit return-value support, as described in Option C, but this would require larger API changes.
  • It's essential to add regression tests to ensure the fix works correctly and doesn't introduce new issues.

Example

const wrappedHandler = async (evt) => {
  const context = evt.context ?? {};
  const hadPluginConfig = Object.prototype.hasOwnProperty.call(context, "pluginConfig");
  const previousPluginConfig = context.pluginConfig;

  context.pluginConfig = pluginConfig;
  try {
    return await handler({
      ...evt,
      context
    });
  } finally {
    if (hadPluginConfig) context.pluginConfig = previousPluginConfig;
    else delete context.pluginConfig;
  }
};

Notes

  • The recommended fix, Option A, is a minimal change that preserves the existing mutable hook contract.
  • However, it's crucial to consider the potential implications of temporary pluginConfig attachment and ensure it doesn't leak between handlers.
  • Additional testing and verification are necessary to ensure the fix works as expected and doesn't introduce new issues.

Recommendation

Apply the recommended fix, Option A, to preserve the mutable context object in the wrapper, and add regression tests to ensure the fix works correctly.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

For an agent:bootstrap hook, if a plugin mutates:

event.context.bootstrapFiles = updatedFiles;

then core bootstrap resolution should use updatedFiles when building injected context and prompt warning/report metadata.

More generally, if a hook API exposes event.context as mutable, mutations should either:

  1. be applied to the original context object, or
  2. be explicitly merged back after the hook returns, or
  3. be documented/retyped as immutable and replaced with a return-value API.

The current behavior looks mutable but acts immutable.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix registerHook wrapper drops mutable context changes for agent:bootstrap hooks [1 pull requests, 1 comments, 2 participants]