openclaw - ✅(Solved) Fix channels add / onboard wizard register bundled extension dir as redundant plugins.load.paths entry [1 pull requests, 2 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#72740Fetched 2026-04-28 06:32:44
View on GitHub
Comments
2
Participants
2
Timeline
4
Reactions
0
Author
Participants
Timeline (top)
commented ×2closed ×1cross-referenced ×1

Root Cause

I only ran the repro in Docker, but the failing code path explicitly selects the bad branch because a plugin is bundled — independent of how OpenClaw was installed.

Fix Action

Fixed

PR fix notes

PR #72750: fix(channels): skip path-install registration for bundled plugins

Description (problem / solution / changelog)

Summary

Fixes the redundant plugins.load.paths entry + installs.json source: "path" install record that the wizard / channels add writes for bundled channels (verified for Discord; same code path for any bundled channel). The loader explicitly rejects this state and emits

plugins: ignored plugins.load.paths entry that points at OpenClaw's
current bundled plugin directory; remove this redundant path or run
openclaw doctor --fix

on every config read until the user manually edits the JSON or runs doctor --fix.

Closes #72740 (or refs, whichever fit you prefer).

What changed

  • New applyLocalPluginInstall helper in src/commands/onboarding-plugin-install.ts that owns "what to write for a local choice".
  • Helper short-circuits when the resolved localPath is the bundled directory (localPath === bundledLocalPath). Returns the config unchanged so the caller still records the channel config and reports installed: true.
  • Both call sites (primary local branch and the npm-fallback-to-local branch) now go through the helper; behavior for genuine path-installs (workspace plugins, pnpm openclaw checkouts) is unchanged.

Why this scope

The minimal change that resolves the writer/reader inconsistency without touching install record semantics. Longer-term it probably makes sense to introduce a source: "bundled" install record + doctor migration (option 3 in the issue), but that's a bigger blast-radius change with new install record type, schema/validation updates, uninstall behavior, etc. Happy to follow up with that as a separate PR if maintainers prefer.

I picked this option because:

  • The loader already treats bundled-pointing plugins.load.paths entries as ignored; not writing them in the first place is the simplest "stop creating state the reader rejects" fix.
  • caller (channels/add.ts) requires installed: true to proceed with channel config writes, so I kept that contract intact rather than routing bundled to skip.
  • AGENTS.md "manifest-first control plane" suggests bundled plugins shouldn't appear in installRecords at all; this PR removes the false source: "path" record without inventing a new record type.

Test plan

  • New test does not register a bundled plugin as a user path-install or load-paths entry in src/commands/onboarding-plugin-install.test.ts — fails on main, passes with the fix
  • All 14 existing tests in onboarding-plugin-install.test.ts still pass
  • pnpm check:changed (typecheck + lint + import cycles + auth/webhook guards) green
  • pnpm test:changed green
  • Repro from #72740 stops writing plugins.load.paths and stops creating the source: "path" install record on a fresh container; the loader warning no longer appears

Verified locally on macOS with the docker setup from #72740 plus a unit-level repro.

Changed files

  • src/commands/onboarding-plugin-install.test.ts (modified, +35/-0)
  • src/commands/onboarding-plugin-install.ts (modified, +41/-4)

Code Example

Config warnings:
- plugins: plugin: ignored plugins.load.paths entry that points at OpenClaw's
  current bundled plugin directory; remove this redundant path or run
  openclaw doctor --fix

---

# Fresh isolated state
mkdir -p /tmp/oc-repro/{config,workspace}
docker run --rm --user root \
  -v /tmp/oc-repro/config:/data \
  -v /tmp/oc-repro/workspace:/work \
  openclaw:local sh -c 'chown -R 1000:1000 /data /work'

# Onboard non-interactively, no channels
docker run --rm \
  -v /tmp/oc-repro/config:/home/node/.openclaw \
  -v /tmp/oc-repro/workspace:/home/node/.openclaw/workspace \
  --entrypoint node openclaw:local dist/index.js \
  onboard --mode local --no-install-daemon --non-interactive --accept-risk \
    --skip-channels --skip-skills --skip-search --skip-ui --skip-health --skip-bootstrap \
    --auth-choice skip --gateway-auth token --gateway-token TEST_TOKEN \
    --gateway-bind loopback --gateway-port 19789

# At this point ~/.openclaw/openclaw.json has NO plugins block. Good.

# Add Discord
docker run --rm \
  -v /tmp/oc-repro/config:/home/node/.openclaw \
  -v /tmp/oc-repro/workspace:/home/node/.openclaw/workspace \
  --entrypoint node openclaw:local dist/index.js \
  channels add --channel discord --token TEST.FAKE.TOKEN

---

"plugins": {
    "entries": { "discord": { "enabled": true } },
    "load": { "paths": ["/app/dist/extensions/discord"] }
  }

---

"installRecords": {
    "discord": {
      "source": "path",
      "spec": "@openclaw/discord",
      "sourcePath": "./dist/extensions/discord",
      "installedAt": "..."
    }
  }

---

if (opts.skipChannels ?? opts.skipProviders) {
  await prompter.note("Skipping channel setup.", "Channels");
} else {
  const { listChannelPlugins } = await import("../channels/plugins/index.js");
  const { setupChannels } = await import("../commands/onboard-channels.js");
  // ...
  nextConfig = await setupChannels(nextConfig, runtime, prompter, { /* ... */ });
}

---

const [{ buildAgentSummaries }, onboardChannels] = await Promise.all([
  import("../agents.config.js"),
  loadOnboardChannels(),
]);
// ...
let nextConfig = await onboardChannels.setupChannels(cfg, runtime, prompter, { /* ... */ });

---

function resolveInstallDefaultChoice(params: {
  cfg: OpenClawConfig;
  entry: OnboardingPluginInstallEntry;
  localPath?: string | null;
  bundledLocalPath?: string | null;
  hasNpmSpec: boolean;
}): InstallChoice {
  const { cfg, entry, localPath, bundledLocalPath, hasNpmSpec } = params;
  if (!hasNpmSpec) {
    return localPath ? "local" : "skip";
  }
  if (!localPath) {
    return "npm";
  }
  if (bundledLocalPath) {
    return "local";          // <-- bundled plugin always falls into the "local" branch
  }
  // ...
}

---

if (choice === "local" && localPath) {
  // ...
  next = addPluginLoadPath(enableResult.config, localPath);
  next = await recordLocalPluginInstall({ cfg: next, entry, localPath, npmSpec, workspaceDir });
  // ...
}
RAW_BUFFERClick to expand / collapse

Problem

openclaw channels add --channel discord ... (and presumably other bundled channels) registers the bundled extension directory /app/dist/extensions/discord as a user plugins.load.paths entry and an installs.json installRecords.<id>.source = "path" install. OpenClaw then warns about its own write on the very next config read:

Config warnings:
- plugins: plugin: ignored plugins.load.paths entry that points at OpenClaw's
  current bundled plugin directory; remove this redundant path or run
  openclaw doctor --fix

So channels add writes a config the loader knows is invalid and self-flags as redundant, but the writer never refuses or sanitizes the entry.

In a clean container this is "only" a noisy warning — the loader ignores the path and the gateway boots fine. In a less-clean install (existing runtime-deps mirror state, host bind mount with mismatched ownership, prior partial setup) it has appeared in user reports as plugin failed during register: ENOENT: no such file or directory, copyfile <X> -> <X> where source and destination are the same path. I have not been able to reproduce that secondary failure in a fresh container, so I'm only filing the first-order issue here.

Repro (clean, container, no prior state)

OpenClaw 2026.4.26, Docker Desktop on macOS, openclaw:local image built from main.

# Fresh isolated state
mkdir -p /tmp/oc-repro/{config,workspace}
docker run --rm --user root \
  -v /tmp/oc-repro/config:/data \
  -v /tmp/oc-repro/workspace:/work \
  openclaw:local sh -c 'chown -R 1000:1000 /data /work'

# Onboard non-interactively, no channels
docker run --rm \
  -v /tmp/oc-repro/config:/home/node/.openclaw \
  -v /tmp/oc-repro/workspace:/home/node/.openclaw/workspace \
  --entrypoint node openclaw:local dist/index.js \
  onboard --mode local --no-install-daemon --non-interactive --accept-risk \
    --skip-channels --skip-skills --skip-search --skip-ui --skip-health --skip-bootstrap \
    --auth-choice skip --gateway-auth token --gateway-token TEST_TOKEN \
    --gateway-bind loopback --gateway-port 19789

# At this point ~/.openclaw/openclaw.json has NO plugins block. Good.

# Add Discord
docker run --rm \
  -v /tmp/oc-repro/config:/home/node/.openclaw \
  -v /tmp/oc-repro/workspace:/home/node/.openclaw/workspace \
  --entrypoint node openclaw:local dist/index.js \
  channels add --channel discord --token TEST.FAKE.TOKEN

Observed

  • The channels add invocation prints plugins: plugin: ignored plugins.load.paths entry that points at OpenClaw's current bundled plugin directory on its own write.
  • ~/.openclaw/openclaw.json now contains:
    "plugins": {
      "entries": { "discord": { "enabled": true } },
      "load": { "paths": ["/app/dist/extensions/discord"] }
    }
  • ~/.openclaw/plugins/installs.json now contains:
    "installRecords": {
      "discord": {
        "source": "path",
        "spec": "@openclaw/discord",
        "sourcePath": "./dist/extensions/discord",
        "installedAt": "..."
      }
    }
  • discord is a bundled channel; this entry is exactly the redundant one the loader rejects.

Expected

channels add should detect that the channel id resolves to a bundled plugin and not write a plugins.load.paths entry or a source: "path" installRecords.<id> for it. Specifically:

  • No write to plugins.load.paths for any path that lives under the running runtime's dist/extensions/.
  • installRecords.<id>.source = "bundled" (or no install record at all) for ids that are already discovered as bundled plugins.

Same code path as the interactive wizard

The channels add CLI and the interactive onboard wizard both go through the same setupChannels() function. References pinned at cb9955dd5cf2e465c373f1ef99d074cdd0fbb60c:

src/wizard/setup.ts — wizard's channel step (what ./scripts/docker/setup.sh ultimately invokes when the user picks Discord during onboarding):

if (opts.skipChannels ?? opts.skipProviders) {
  await prompter.note("Skipping channel setup.", "Channels");
} else {
  const { listChannelPlugins } = await import("../channels/plugins/index.js");
  const { setupChannels } = await import("../commands/onboard-channels.js");
  // ...
  nextConfig = await setupChannels(nextConfig, runtime, prompter, { /* ... */ });
}

src/commands/channels/add.ts — the CLI used in the repro above:

const [{ buildAgentSummaries }, onboardChannels] = await Promise.all([
  import("../agents.config.js"),
  loadOnboardChannels(),
]);
// ...
let nextConfig = await onboardChannels.setupChannels(cfg, runtime, prompter, { /* ... */ });

src/commands/agents.commands.add.ts and src/commands/configure.wizard.ts reach the same setupChannels() similarly.

So users hitting Discord (bot token) during setup.sh's wizard, or running the documented channels add --channel discord --token <token> post-setup step, end up at the same writer. The CLI repro above is a faithful proxy for the wizard path.

Side observations (in case they help triage)

  • openclaw doctor --fix does clear the bad plugins.load.paths entry on a fresh container — so this is not the same as #53649 / #62976. Doctor is a one-shot remediation; the underlying writer is the bug.
  • Re-running channels add after doctor --fix (with the gateway up or with a second-account --name) does not re-add the path. Only the first registration of the channel triggers it.
  • Effect on users: the warning recurs on every gateway start and every config read until they manually edit JSON or run doctor --fix. In docker setups with bind-mounted host dirs and stale runtime-deps mirrors, this seems to be one of the contributors to the copyfile X -> X ENOENT we've seen reported, though I couldn't isolate a clean repro of that.

Environment

  • OpenClaw 2026.4.26
  • Docker Desktop 4.x on macOS, openclaw:local image built from current main
  • Host bind mount for ~/.openclaw
  • No third-party plugins, no upgrade migration

Scope: not Docker-specific (code evidence)

I only ran the repro in Docker, but the failing code path explicitly selects the bad branch because a plugin is bundled — independent of how OpenClaw was installed.

In src/commands/onboarding-plugin-install.ts at cb9955dd5cf2e465c373f1ef99d074cdd0fbb60c:

function resolveInstallDefaultChoice(params: {
  cfg: OpenClawConfig;
  entry: OnboardingPluginInstallEntry;
  localPath?: string | null;
  bundledLocalPath?: string | null;
  hasNpmSpec: boolean;
}): InstallChoice {
  const { cfg, entry, localPath, bundledLocalPath, hasNpmSpec } = params;
  if (!hasNpmSpec) {
    return localPath ? "local" : "skip";
  }
  if (!localPath) {
    return "npm";
  }
  if (bundledLocalPath) {
    return "local";          // <-- bundled plugin always falls into the "local" branch
  }
  // ...
}

And the "local" branch then unconditionally appends to plugins.load.paths and writes a source: "path" install record:

if (choice === "local" && localPath) {
  // ...
  next = addPluginLoadPath(enableResult.config, localPath);
  next = await recordLocalPluginInstall({ cfg: next, entry, localPath, npmSpec, workspaceDir });
  // ...
}

resolveBundledLocalPath resolves the running runtime's bundled directory regardless of install method, so the bundled branch is taken on Docker, npm global, and source-tree (pnpm openclaw) the same way. Only the absolute path string written into config differs per install:

  • Docker: /app/dist/extensions/discord
  • npm global (Linux): /usr/lib/node_modules/openclaw/dist/extensions/discord
  • npm global (macOS): ~/.npm-global/lib/node_modules/openclaw/dist/extensions/discord
  • Source tree: <repo>/dist/extensions/discord

Closed issue #53649 also reported a plugins.load.paths-area problem on a non-Docker (npm global) install, which is consistent with this surface being shared.

I'm happy to add a non-Docker repro if a maintainer wants confirmation before triage, but the code path itself isn't gated on Docker.

Suggested fix area

The writer is channels add (or whatever shared registration path it goes through). Owner-boundary-wise this looks like it belongs to the channels registration code (src/channels/**) rather than extensions/discord/** since the symptom is identical for any bundled channel and is shared infrastructure. Happy to take a stab at a PR if a maintainer confirms the right seam.

extent analysis

TL;DR

The issue can be fixed by modifying the setupChannels function to detect and handle bundled plugins, preventing the addition of redundant plugins.load.paths entries and source: "path" install records.

Guidance

  • Review the setupChannels function in src/commands/onboard-channels.js to understand how it handles bundled plugins.
  • Modify the resolveInstallDefaultChoice function in src/commands/onboarding-plugin-install.ts to return a different choice when a plugin is bundled, such as "bundled" or "skip".
  • Update the addPluginLoadPath and recordLocalPluginInstall functions to handle the new choice and avoid adding redundant entries.
  • Verify that the fix works by running the repro steps and checking the openclaw.json and installs.json files for the expected changes.

Example

function resolveInstallDefaultChoice(params: {
  cfg: OpenClawConfig;
  entry: OnboardingPluginInstallEntry;
  localPath?: string | null;
  bundledLocalPath?: string | null;
  hasNpmSpec: boolean;
}): InstallChoice {
  // ...
  if (bundledLocalPath) {
    return "bundled"; // Return a new choice for bundled plugins
  }
  // ...
}

Notes

  • The fix should be applied to the setupChannels function, which is shared by the channels add CLI and the interactive wizard.
  • The issue is not specific to Docker and can occur on other installation methods, such as npm global or source tree.

Recommendation

Apply a workaround by running openclaw doctor --fix to clear the redundant plugins.load.paths entry, and then modify the setupChannels function to handle bundled plugins correctly. This will prevent the issue from recurring and ensure that the configuration is valid.

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…

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 channels add / onboard wizard register bundled extension dir as redundant plugins.load.paths entry [1 pull requests, 2 comments, 2 participants]