fix: validate plugin source entries before runtime inference (#69868)

Merged via squash.

Prepared head SHA: b67644cdda
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-21 20:12:19 -04:00
committed by GitHub
parent 19354c9a6a
commit 819d15481d
5 changed files with 165 additions and 13 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Discord: keep slash command follow-up chunks ephemeral when the command is configured for ephemeral replies, so long `/status` output no longer leaks fallback model or runtime details into the public channel. (#69869) thanks @gumadeiras.
- Plugins/discovery: reject package plugin source entries that escape the package directory before explicit runtime entries or inferred built JavaScript peers can be used. (#69868) thanks @gumadeiras.
## 2026.4.21

View File

@@ -507,18 +507,20 @@ Some pre-runtime plugin metadata intentionally lives in `package.json` under the
Important examples:
| Field | What it means |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `openclaw.extensions` | Declares native plugin entrypoints. |
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. |
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
| `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. |
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
| `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. |
| `openclaw.install.defaultChoice` | Preferred install path when multiple install sources are available. |
| `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22`. |
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-only channel surfaces load before the full channel plugin during startup. |
| Field | What it means |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `openclaw.extensions` | Declares native plugin entrypoints. Must stay inside the plugin package directory. |
| `openclaw.runtimeExtensions` | Declares built JavaScript runtime entrypoints for installed packages. Must stay inside the plugin package directory. |
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. Must stay inside the plugin package directory. |
| `openclaw.runtimeSetupEntry` | Declares the built JavaScript setup entrypoint for installed packages. Must stay inside the plugin package directory. |
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
| `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. |
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
| `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. |
| `openclaw.install.defaultChoice` | Preferred install path when multiple install sources are available. |
| `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22`. |
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-only channel surfaces load before the full channel plugin during startup. |
`openclaw.install.minHostVersion` is enforced during install and manifest
registry loading. Invalid values are rejected; newer-but-valid values skip the
@@ -530,6 +532,10 @@ runtime. The setup entry should expose channel metadata plus setup-safe config,
status, and secrets adapters; keep network clients, gateway listeners, and
transport runtimes in the main extension entrypoint.
Runtime entrypoint fields do not override package-boundary checks for source
entrypoint fields. For example, `openclaw.runtimeExtensions` cannot make an
escaping `openclaw.extensions` path loadable.
`openclaw.install.allowInvalidConfigRecovery` is intentionally narrow. It does
not make arbitrary broken configs installable. Today it only allows install
flows to recover from specific stale bundled-plugin upgrade failures, such as a

View File

@@ -34,6 +34,10 @@ TypeScript compilation. If an installed package only declares a TypeScript
source entry, OpenClaw will use a matching built `dist/*.js` peer when one
exists, then fall back to the TypeScript source.
All entry paths must stay inside the plugin package directory. Runtime entries
and inferred built JavaScript peers do not make an escaping `extensions` or
`setupEntry` source path valid.
<Tip>
**Looking for a walkthrough?** See [Channel Plugins](/plugins/sdk-channel-plugins)
or [Provider Plugins](/plugins/sdk-provider-plugins) for step-by-step guides.

View File

@@ -766,6 +766,35 @@ describe("discoverOpenClawPlugins", () => {
fs.writeFileSync(outside, "export default function () {}", "utf-8");
},
},
{
name: "blocks parent-segment TypeScript entries before built runtime inference",
expectedDiagnostic: "escapes" as const,
setup: (stateDir: string) => {
const globalExt = path.join(stateDir, "extensions", "escape-pack");
mkdirSafe(path.join(globalExt, "src"));
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/escape-pack",
extensions: ["../src/index.ts"],
});
fs.writeFileSync(path.join(globalExt, "src", "index.js"), "export default {}", "utf-8");
},
},
{
name: "blocks escaping source entries before explicit runtime entries",
expectedDiagnostic: "escapes" as const,
setup: (stateDir: string) => {
const globalExt = path.join(stateDir, "extensions", "escape-pack");
mkdirSafe(path.join(globalExt, "dist"));
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/escape-pack",
extensions: ["../src/index.ts"],
runtimeExtensions: ["./dist/index.js"],
});
fs.writeFileSync(path.join(globalExt, "dist", "index.js"), "export default {}", "utf-8");
},
},
{
name: "skips missing package extension entries without escape diagnostics",
expectedDiagnostic: "none" as const,
@@ -835,6 +864,38 @@ describe("discoverOpenClawPlugins", () => {
return true;
},
},
{
name: "rejects hardlinked TypeScript entries before built runtime inference",
expectedDiagnostic: "escapes" as const,
expectedId: "pack",
setup: (stateDir: string) => {
if (process.platform === "win32") {
return false;
}
const globalExt = path.join(stateDir, "extensions", "pack");
const outsideDir = path.join(stateDir, "outside");
const outsideFile = path.join(outsideDir, "escape.ts");
const linkedFile = path.join(globalExt, "escape.ts");
mkdirSafe(path.join(globalExt, "dist"));
mkdirSafe(outsideDir);
fs.writeFileSync(outsideFile, "export default {}", "utf-8");
fs.writeFileSync(path.join(globalExt, "dist", "escape.js"), "export default {}", "utf-8");
try {
fs.linkSync(outsideFile, linkedFile);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return false;
}
throw err;
}
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/pack",
extensions: ["./escape.ts"],
});
return true;
},
},
] as const)("$name", async ({ setup, expectedDiagnostic, expectedId }) => {
const stateDir = makeTempDir();
await expectRejectedPackageExtensionEntry({
@@ -845,6 +906,28 @@ describe("discoverOpenClawPlugins", () => {
});
});
it("blocks escaping setup entries before explicit runtime setup entries", async () => {
const stateDir = makeTempDir();
const globalExt = path.join(stateDir, "extensions", "escape-pack");
mkdirSafe(path.join(globalExt, "dist"));
writePluginPackageManifest({
packageDir: globalExt,
packageName: "@openclaw/escape-pack",
extensions: ["./dist/index.js"],
setupEntry: "../src/setup-entry.ts",
runtimeSetupEntry: "./dist/setup-entry.js",
});
fs.writeFileSync(path.join(globalExt, "dist", "index.js"), "export default {}", "utf-8");
fs.writeFileSync(path.join(globalExt, "dist", "setup-entry.js"), "export default {}", "utf-8");
const result = await discoverWithStateDir(stateDir, {});
const candidate = findCandidateById(result.candidates, "escape-pack");
expect(candidate).toBeDefined();
expect(candidate?.setupSource).toBeUndefined();
expectEscapesPackageDiagnostic(result.diagnostics);
});
it("ignores package manifests that are hardlinked aliases", async () => {
if (process.platform === "win32") {
return;

View File

@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { resolveBoundaryPathSync } from "../infra/boundary-path.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -619,6 +620,48 @@ function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean {
return origin === "config" || origin === "global";
}
function resolveSafePackageEntry(params: {
packageDir: string;
entryPath: string;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): { relativePath: string; existingSource?: string } | null {
const absolutePath = path.resolve(params.packageDir, params.entryPath);
if (fs.existsSync(absolutePath)) {
const existingSource = resolvePackageEntrySource({
packageDir: params.packageDir,
entryPath: params.entryPath,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
if (!existingSource) {
return null;
}
return {
relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/"),
existingSource,
};
}
try {
resolveBoundaryPathSync({
absolutePath,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
});
} catch {
params.diagnostics.push({
level: "error",
message: `extension entry escapes package directory: ${params.entryPath}`,
source: params.sourceLabel,
});
return null;
}
return { relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/") };
}
function listBuiltRuntimeEntryCandidates(entryPath: string): string[] {
if (!isTypeScriptPackageEntry(entryPath)) {
return [];
@@ -671,6 +714,17 @@ function resolvePackageRuntimeEntrySource(params: {
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const safeEntry = resolveSafePackageEntry({
packageDir: params.packageDir,
entryPath: params.entryPath,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
if (!safeEntry) {
return null;
}
if (params.runtimeEntryPath) {
const runtimeSource = resolvePackageEntrySource({
packageDir: params.packageDir,
@@ -685,7 +739,7 @@ function resolvePackageRuntimeEntrySource(params: {
}
if (shouldInferBuiltRuntimeEntry(params.origin)) {
for (const candidate of listBuiltRuntimeEntryCandidates(params.entryPath)) {
for (const candidate of listBuiltRuntimeEntryCandidates(safeEntry.relativePath)) {
const runtimeSource = resolveExistingPackageEntrySource({
packageDir: params.packageDir,
entryPath: candidate,
@@ -699,6 +753,10 @@ function resolvePackageRuntimeEntrySource(params: {
}
}
if (safeEntry.existingSource) {
return safeEntry.existingSource;
}
return resolvePackageEntrySource({
packageDir: params.packageDir,
entryPath: params.entryPath,