mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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:
committed by
GitHub
parent
19354c9a6a
commit
819d15481d
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user