diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7fccb759e..ecdf387b58e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 46f268f80f0..7421cdbddf8 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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 diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md index 1797245ec5a..b8ff2d6b017 100644 --- a/docs/plugins/sdk-entrypoints.md +++ b/docs/plugins/sdk-entrypoints.md @@ -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. + **Looking for a walkthrough?** See [Channel Plugins](/plugins/sdk-channel-plugins) or [Provider Plugins](/plugins/sdk-provider-plugins) for step-by-step guides. diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 11f36048c9e..fd5148776fe 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -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; diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 8a4a401667a..063e968f93c 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -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,