Skip to content

fix(plugins): eagerly install bundled runtime deps#70138

Closed
ngutman wants to merge 4 commits into
mainfrom
fix/bundled-plugin-runtime-deps-install
Closed

fix(plugins): eagerly install bundled runtime deps#70138
ngutman wants to merge 4 commits into
mainfrom
fix/bundled-plugin-runtime-deps-install

Conversation

@ngutman

@ngutman ngutman commented Apr 22, 2026

Copy link
Copy Markdown
Member

Summary

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • 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

Root Cause (if applicable)

  • Root cause: packaged installs excluded dist/extensions/*/node_modules/**, and normal postinstall runs skipped bundled runtime dependency installation unless an eager-install env var was set.
  • Missing detection / guardrail: user installs did not fail when the nested install path failed, so packaged installs could silently continue with missing bundled runtime deps.
  • Contributing context (if known): release-check already forces eager install under a dedicated env flag, which masked the mismatch between release validation and normal end-user installs.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file:
    • test/scripts/postinstall-bundled-plugins.test.ts
    • src/plugins/stage-bundled-plugin-runtime-deps.test.ts
  • Scenario the test should lock in:
    • packaged installs eagerly install bundled runtime deps by default
    • packaged installs fail fast when that install step fails
    • explicit opt-out via OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS=0 still works
  • Why this is the smallest reliable guardrail:
    • these tests exercise the actual postinstall decision boundary without needing a full publish/release lane.
  • Existing test that already covers this (if any):
    • the packed install smoke and bundled channel entry smoke cover the installed layout verification path.
  • If no new test is added, why not:
    • N/A

User-visible / Behavior Changes

  • Fresh packaged installs and updates now install bundled plugin runtime deps by default.
  • If that nested install fails, the packaged install now fails immediately with a clear postinstall error instead of warning and continuing into a broken runtime state.

Diagram (if applicable)

Before:
install package -> skip bundled runtime dep install by default -> bootstrap loads bundled entry -> Cannot find module

After:
install package -> install bundled runtime deps -> bundled entry loads successfully
               \-> install fails -> postinstall throws immediately

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (Yes)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)
  • If any Yes, explain risk + mitigation:
    • Packaged install already runs postinstall. This change makes the bundled runtime dependency install path the default packaged-install behavior, using the existing npm runner and failing fast on errors instead of silently continuing.

Repro + Verification

Environment

  • OS: macOS host verifying packaged tarball install
  • Runtime/container: Node/npm packaged install smoke
  • Model/provider: N/A
  • Integration/channel (if any): bundled channel entry smoke (Feishu, Slack, Nostr, WhatsApp, Telegram, bundled diffs)
  • Relevant config (redacted): none

Steps

  1. pnpm test test/scripts/postinstall-bundled-plugins.test.ts src/plugins/stage-bundled-plugin-runtime-deps.test.ts
  2. npm pack --ignore-scripts then npm install the tarball into a temp prefix
  3. Verify bundled runtime dep sentinels exist and run node scripts/test-built-bundled-channel-entry-smoke.mjs --package-root <installed package root>

Expected

  • packaged installs eagerly install bundled runtime deps
  • packaged installs fail on nested bundled-runtime install failure
  • bundled channel entry smoke passes from the installed layout

Actual

  • targeted tests pass
  • real packed-install smoke passes
  • bundled channel entry smoke passes from the installed package root

Evidence

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

Human Verification (required)

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

  • Verified scenarios:
    • pnpm test test/scripts/postinstall-bundled-plugins.test.ts src/plugins/stage-bundled-plugin-runtime-deps.test.ts
    • packed tarball install now materializes bundled runtime dep sentinels for Feishu/Slack/Nostr/WhatsApp/Telegram/diffs deps
    • OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK=1 node scripts/test-built-bundled-channel-entry-smoke.mjs --package-root <installed package root> passes
  • Edge cases checked:
    • explicit opt-out via OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS=0
    • packaged install failure path now throws with context instead of warning and continuing
  • What you did not verify:
    • full pnpm check:changed is still blocked on unrelated existing tsgo:core:test failures in src/agents/pi-embedded-runner/*

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (Yes)
  • Migration needed? (No)
  • If yes, exact upgrade steps:
    • OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS=0|false|off now explicitly disables the default eager packaged-install behavior.

Risks and Mitigations

  • Risk: offline/air-gapped installs that cannot reach the registry now fail during packaged postinstall instead of appearing successful.
    • Mitigation: this is intentional fail-fast behavior because bundled entrypoints are expected to work immediately after install; silent success left users in a broken runtime state.

@aisle-research-bot

aisle-research-bot Bot commented Apr 22, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

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

# Severity Title
1 🟠 High Postinstall triggers network npm install for bundled plugin deps by default (unpinned ranges, no lockfile)
2 🟠 High Path traversal and arbitrary install target via unvalidated dependency name in bundled plugin postinstall
3 🟡 Medium Path traversal via unvalidated bundled runtime dependency name in sentinel path construction
1. 🟠 Postinstall triggers network `npm install` for bundled plugin deps by default (unpinned ranges, no lockfile)
Property Value
Severity High
CWE CWE-494
Location scripts/postinstall-bundled-plugins.mjs:496-512

Description

The packaged install path now eagerly installs bundled plugin runtime dependencies during postinstall by default unless OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS is explicitly set to 0/false/off.

Impact:

  • Runs npm install automatically during installation, causing unwanted network access in environments that expect offline/reproducible installs.
  • The dependency versions come from bundled plugin package.json and are only validated as a semver range (e.g., ^1.60.0), meaning the resolved version can change over time.
  • The install environment explicitly disables lockfile generation (npm_config_package_lock: "false"), removing a primary integrity/reproducibility control.
  • Although --ignore-scripts is used (good: blocks lifecycle scripts), this still expands the supply-chain attack surface (dependency confusion/registry compromise/typosquatting) by fetching packages at install time.

Vulnerable behavior (key excerpts):

function shouldEagerInstallBundledPluginDeps(env = process.env) {
  const normalized = env?.[EAGER_BUNDLED_PLUGIN_DEPS_ENV]?.trim().toLowerCase();
  return !normalized || !DISABLED_EAGER_BUNDLED_PLUGIN_DEPS_VALUES.has(normalized);
}

export function createBundledRuntimeDependencyInstallEnv(env = process.env) {
  return {
    ...createNestedNpmInstallEnv(env),
    npm_config_legacy_peer_deps: "true",
    npm_config_package_lock: "false",
    npm_config_save: "false",
  };
}

export function createBundledRuntimeDependencyInstallArgs(missingSpecs) {
  return ["install", "--ignore-scripts", ...missingSpecs];
}

And the eager install is executed in runBundledPluginPostinstall() for packaged installs when deps are missing.

Recommendation

Treat eager installation of dependencies as an opt-in operation and make installs reproducible.

Recommended changes:

  1. Default to not running network installs in postinstall; require explicit enablement (e.g., OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS=1).
  2. Prefer pinned versions (no ranges) for bundled plugin runtime deps, or ship a lockfile and use npm ci.
  3. Avoid disabling lockfiles. If you must install at postinstall time, enforce deterministic resolution:
    • ship a lockfile for the packaged distribution and run npm ci instead of npm install.
  4. Harden npm configuration for non-interactive install behavior and reduced attack surface (example):
export function createBundledRuntimeDependencyInstallEnv(env = process.env) {
  return {
    ...createNestedNpmInstallEnv(env),
    npm_config_ignore_scripts: "true", // defense-in-depth
    npm_config_audit: "false",
    npm_config_fund: "false",
    npm_config_update_notifier: "false",// consider pinning/controlling registry if appropriate:// npm_config_registry: "https://registry.npmjs.org/",
  };
}

export function createBundledRuntimeDependencyInstallArgs(missingSpecs) {
  return ["ci", "--ignore-scripts", ...missingSpecs];
}

Also ensure the bundled plugin dist/extensions/**/package.json content is immutable/trusted in the packaged artifact; if end-users can modify it before installation, they can force installation of attacker-chosen packages.

2. 🟠 Path traversal and arbitrary install target via unvalidated dependency name in bundled plugin postinstall
Property Value
Severity High
CWE CWE-22
Location scripts/postinstall-bundled-plugins.mjs:339-341

Description

runBundledPluginPostinstall() reads dependency entries from each bundled plugin's package.json and only validates the version/spec with semver/ranges/valid. The dependency name (object key) is not validated.

As a result, a malicious bundled plugin manifest can provide a dependency name such as ../tmp/evil (or other path-like strings). This name is then used in two security-sensitive ways:

  • Path traversal in sentinel checks: dependencySentinelPath(depName) splits on / and joins under node_modules/, allowing .. segments to escape node_modules.
  • Bypass of spec validation to install from arbitrary locations: missing specs are built as ${dep.name}@${dep.version}. With a path-like name, the resulting npm install argument can be interpreted by npm as a local path (or other non-registry specifier), despite the version being a valid semver range.

Vulnerable code:

function dependencySentinelPath(depName) {
  return join("node_modules", ...depName.split("/"), "package.json");
}
...
.map((dep) => `${dep.name}@${dep.version}`);

Recommendation

Validate bundled runtime dependency names before using them in paths or building npm install arguments.

Recommended approach:

  1. Reject any dependency name that is not a valid npm package name (including scoped names). Use validate-npm-package-name.
  2. Additionally, when building any filesystem path from a package name, ensure the resolved path stays within the intended root (<packageRoot>/node_modules).

Example:

import validateName from "validate-npm-package-name";
import { join, resolve, relative, isAbsolute } from "node:path";

function assertSafePackageName(name) {
  const result = validateName(name);
  if (!result.validForOldPackages && !result.validForNewPackages) {
    throw new Error(`unsafe bundled runtime dependency name: ${name}`);
  }
}

function safeDependencySentinelPath(packageRoot, depName) {
  assertSafePackageName(depName);
  const sentinel = join(packageRoot, "node_modules", ...depName.split("/"), "package.json");
  const rel = relative(join(packageRoot, "node_modules"), resolve(sentinel));
  if (rel.startsWith("..") || isAbsolute(rel)) {
    throw new Error(`dependency sentinel path escape for ${depName}`);
  }
  return sentinel;
}

Also consider building install specs using npm's npm-package-arg parser and rejecting any spec whose type is not a registry semver/range.

3. 🟡 Path traversal via unvalidated bundled runtime dependency name in sentinel path construction
Property Value
Severity Medium
CWE CWE-22
Location scripts/postinstall-bundled-plugins.mjs:339-341

Description

dependencySentinelPath(depName) builds a filesystem path from a dependency name taken directly from bundled plugin package.json dependency keys. While the code validates the dependency version/range with semver.validRange(), it does not validate the dependency name.

An attacker who can influence a bundled plugin manifest could supply a dependency name containing path traversal segments (e.g. ../...) which will be normalized by path.join() and escape node_modules/, causing the postinstall script to probe/read files outside the intended directory.

Impact:

  • runtimeDepNeedsInstall() calls existsSync() and readJson() on join(packageRoot, dep.sentinelPath).
  • With a crafted name like "../somewhere", dep.sentinelPath becomes "packageRoot/somewhere/package.json" instead of staying under packageRoot/node_modules/....
  • This is a classic path traversal primitive (even if current sinks are limited to existence checks / JSON reads).

Vulnerable code:

function dependencySentinelPath(depName) {
  return join("node_modules", ...depName.split("/"), "package.json");
}

Data flow:

  • input: dependency name keys from collectRuntimeDeps(packageJson) in discoverBundledPluginRuntimeDeps()
  • transformation: dependencySentinelPath(name)
  • sink: existsSync(join(packageRoot, dep.sentinelPath)) and readJson(join(packageRoot, dep.sentinelPath)) in runtimeDepNeedsInstall()

Recommendation

Validate dependency names before using them in filesystem paths (and ideally before forming install specs).

Suggested approach:

  • Enforce npm package name rules using validate-npm-package-name (or equivalent) and reject names with path traversal tokens.
  • Additionally, ensure no path segments are "." or ".." and disallow backslashes.

Example fix:

import validatePackageName from "validate-npm-package-name";

function assertSafeBundledRuntimeDependencyName(name) {
  if (typeof name !== "string") throw new Error("invalid dependency name");
  if (name.includes("\\")) throw new Error(`unsafe dependency name: ${name}`);
  for (const segment of name.split("/")) {
    if (segment === "." || segment === ".." || segment.length === 0) {
      throw new Error(`unsafe dependency name: ${name}`);
    }
  }
  const result = validatePackageName(name);
  if (!result.validForNewPackages) {
    throw new Error(`unsafe dependency name: ${name}`);
  }
}// in discoverBundledPluginRuntimeDeps loop:
assertSafeBundledRuntimeDependencyName(name);
assertSafeBundledRuntimeDependencySpec(name, version);

This keeps dependencySentinelPath() constrained to node_modules/<pkg>/package.json and prevents traversal.


Analyzed PR: #70138 at commit 4617949

Last updated on: 2026-04-22T12:20:48Z

@openclaw-barnacle openclaw-barnacle Bot added scripts Repository scripts size: M maintainer Maintainer-authored PR labels Apr 22, 2026
@greptile-apps

greptile-apps Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR changes bundled plugin runtime dependency installation from opt-in (only when OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS=1) to opt-out (enabled by default for packaged installs, disabled via =0|false|off), ensuring fresh installs don't silently ship in a broken state. It also extracts installation into installBundledPluginRuntimeDeps, adds a result.error guard for spawn failures, validates specifiers via isSafeBundledRuntimeDependencySpec, and converts malformed-manifest handling from silent-ignore to fail-fast.

Confidence Score: 5/5

Safe to merge; the behavior change is well-scoped, covered by targeted tests, and the only remaining note is a P2 suggestion to restore npm stderr in the error message.

All findings are P2 or lower. The core logic change (opt-out default, fail-fast, spawn-error surfacing) is correct and directly tested. The previously flagged result.error issue is resolved. The only remaining observation is that npm stderr/stdout is no longer included in failure messages, which affects diagnosability but not correctness.

No files require special attention.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: scripts/postinstall-bundled-plugins.mjs
Line: 553-554

Comment:
**npm stderr/stdout dropped from failure message**

The old code included `result.stderr` / `result.stdout` in the thrown error (`throw new Error(output || "npm install failed")`), which helped users diagnose why a registry/network install failed. The new path only surfaces the exit code (e.g. `"npm install failed (exit 1)"`), so transient or permanent network errors will appear as opaque failures. The new test confirms the "network unavailable" stderr is intentionally excluded from the expected message.

Consider appending captured output so the postinstall failure is self-diagnosable without needing to re-run:

```suggestion
    if (result.status !== 0) {
      const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
      throw new Error(output ? `npm install failed (exit ${result.status ?? "unknown"}): ${output}` : `npm install failed (exit ${result.status ?? "unknown"})`);
    }
```

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

Reviews (2): Last reviewed commit: "fix(plugins): harden bundled runtime dep..." | Re-trigger Greptile

Comment thread scripts/postinstall-bundled-plugins.mjs Outdated
@ngutman

ngutman commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

@aisle-research-bot @greptile-apps please re-review.

Follow-up changes since the last review:

  • surface spawnSync launch errors directly before checking exit status
  • stop echoing raw npm stdout/stderr in thrown postinstall errors
  • reject unsafe bundled runtime dependency specifiers before install
  • keep targeted postinstall tests and packed-install smoke green

Latest commit on this branch:

  • 458fcd9 — fix(plugins): harden bundled runtime dep postinstall

@aisle-research-bot

aisle-research-bot Bot commented Apr 22, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

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

# Severity Title
1 🟠 High Bundled plugin dependency spec validation can be bypassed to install from git/alias sources
1. 🟠 Bundled plugin dependency spec validation can be bypassed to install from git/alias sources
Property Value
Severity High
CWE CWE-20
Location scripts/postinstall-bundled-plugins.mjs:398-422

Description

discoverBundledPluginRuntimeDeps() enforces assertSafeBundledRuntimeDependencySpec() before building npm install args, but isSafeBundledRuntimeDependencySpec() only blocks a small set of leading prefixes and :///absolute paths.

As a result, npm-supported non-registry specifiers can still pass validation and cause npm install to fetch code from git or other non-registry sources, e.g.:

  • Git host shorthands: github:user/repo, gitlab:user/repo, bitbucket:user/repo, or even user/repo
  • Alias specs: npm:otherpkg@​github:user/repo (or npm:otherpkg@​file:../...)
  • Other protocols npm supports (e.g., patch:) that do not contain ://

Because the postinstall script runs during packaged installs and eagerly installs these dependencies, a compromised bundled plugin manifest could trigger fetching and installing attacker-controlled code outside the registry constraints the check appears to intend.

Vulnerable code:

function isSafeBundledRuntimeDependencySpec(spec) {
  const lower = normalized.toLowerCase();
  if (lower.startsWith("file:") || /* ... */ lower.startsWith("https:")) {
    return false;
  }
  if (normalized.includes("://") || normalized.startsWith("/") || normalized.startsWith("\\")) {
    return false;
  }
  return true;
}

Recommendation

Use npm’s own spec parser and implement an allowlist of registry-only spec forms.

For example, with npm-package-arg (used by npm itself), reject anything whose resolved type is not a registry version/range/tag:

import npa from "npm-package-arg";

function assertRegistryOnly(depName, spec) {
  const parsed = npa(`${depName}@${spec}`);// Accept only registry dependencies.
  if (parsed.type !== "tag" && parsed.type !== "version" && parsed.type !== "range") {
    throw new Error(`unsafe bundled runtime dependency spec for ${depName}: ${spec}`);
  }// Additionally disallow aliases if you want to prevent indirection.
  if (parsed.subSpec) {
    throw new Error(`unsafe bundled runtime dependency spec for ${depName}: ${spec}`);
  }
}

This blocks git shorthands (github:user/repo, user/repo), npm: aliases, patch:, and other non-registry sources without relying on brittle string-prefix checks.


Analyzed PR: #70138 at commit 458fcd9

Last updated on: 2026-04-22T11:35:08Z

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 458fcd97db

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/postinstall-bundled-plugins.mjs Outdated
@ngutman

ngutman commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

@aisle-research-bot thanks — I reviewed the latest findings against the intended scope of this PR.

What I changed in response:

  • tightened the new spec validator to reject local-path forms as well (../, ./, ~/, Windows drive paths), since that was a small hardening step directly adjacent to the validator introduced in this PR

What I am intentionally deferring from this PR:

  • broader registry-only spec parsing / alias hardening (for example npm: alias handling)
  • source-checkout detection based on .git
  • dependency-name path validation for dependencySentinelPath(...)

Why defer:

  • this PR is focused on fixing the packaged-install regression where bundled runtime deps were missing after install/update
  • the remaining items are defense-in-depth or pre-existing trust-boundary concerns rather than the original user-facing regression
  • changing source-vs-packaged detection or broadening manifest/name validation further would increase scope and regression risk for this PR

Verification still green after the local-path hardening:

  • pnpm test test/scripts/postinstall-bundled-plugins.test.ts src/plugins/stage-bundled-plugin-runtime-deps.test.ts
  • packed-install smoke + bundled channel entry smoke from the installed package root

@ngutman

ngutman commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

@codex review

Addressed the inline local-path spec feedback in the latest commit:

  • ac1e6f0 — fix(plugins): reject local bundled dep specs

Added targeted coverage for:

  • ../...
  • ./...
  • ~/...
  • C:\\...

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ac1e6f0374

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/postinstall-bundled-plugins.mjs Outdated
@steipete

Copy link
Copy Markdown
Contributor

Thanks for the quick green fix here. I’m going to close this one because the shape moves us in the wrong direction.

The important bit: we already repair bundled runtime dependencies at Gateway/plugin startup before importing enabled bundled plugins:

  • src/gateway/server-plugins.ts:461 calls loadOpenClawPlugins(...) during startup.
  • src/plugins/loader.ts:1899 limits runtime-dep repair to bundled + enabled plugins.
  • src/plugins/loader.ts:1903 calls ensureBundledPluginRuntimeDeps(...) before the plugin import path.
  • src/plugins/bundled-runtime-deps.ts:861 chooses plugin-local install when writable, otherwise OPENCLAW_PLUGIN_STAGE_DIR / STATE_DIRECTORY external staging.
  • src/channels/plugins/bundled.ts:232 does the same preparation for bundled channel setup/runtime paths.

This PR changes packaged postinstall to eagerly install all missing bundled plugin runtime deps from the OpenClaw package root. npm_config_save=false avoids package.json/lockfile edits, but the install still materializes deps under root node_modules, and it does it before we know which bundled plugin is active. That conflicts with the current contract documented in docs/tools/plugin.md: packaged installs do not eagerly install every bundled plugin runtime tree; startup repairs only the plugin that is actually active.

So the right fix should stay targeted/plugin-local/external-stage, or adjust the bootstrap/status path so it does not import an unprepared bundled entry. We should not add bundled plugin runtime deps to the root package install path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

maintainer Maintainer-authored PR scripts Repository scripts size: M

Projects

None yet

2 participants