Constrain provider catalog entry paths [AI] (#81884)

* fix: constrain provider catalog entries to plugin root

* addressing review-skill

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-15 14:48:24 +05:30
committed by GitHub
parent d656087b31
commit eb1e6099d2
3 changed files with 349 additions and 15 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Constrain provider catalog entry paths [AI]. (#81884) Thanks @pgondhi987.
- Require canonical node platform IDs [AI]. (#81880) Thanks @pgondhi987.
- Agents/Azure OpenAI Responses: default unset Azure OpenAI API versions to `preview` so `/openai/v1/responses` calls use Azure's current Responses API route. (#82026) Thanks @leoge007.
- Control UI/WebChat: compact the desktop chat header controls into a single aligned row so the session, model, thinking, and action controls no longer waste vertical space. Thanks @BunsDev.

View File

@@ -1427,6 +1427,243 @@ describe("loadPluginManifestRegistry", () => {
);
});
it("ignores legacy provider discovery entries outside the plugin root", () => {
const root = makeTempDir();
const pluginDir = path.join(root, "plugin");
const outsideDir = path.join(root, "outside");
mkdirSafe(pluginDir);
mkdirSafe(outsideDir);
writeManifest(pluginDir, {
id: "outside-provider",
providers: ["outside-provider"],
providerDiscoveryEntry: "../outside/provider-discovery.js",
configSchema: { type: "object" },
});
fs.writeFileSync(
path.join(outsideDir, "provider-discovery.js"),
"export default {};\n",
"utf8",
);
const registry = loadSingleCandidateRegistry({
idHint: "outside-provider",
rootDir: pluginDir,
origin: "bundled",
});
expect(registry.plugins[0]?.providerDiscoverySource).toBeUndefined();
expectDiagnosticFields(registry, {
level: "warn",
pluginId: "outside-provider",
source: path.join(pluginDir, "openclaw.plugin.json"),
messageIncludes: "providerDiscoveryEntry must resolve inside the plugin root",
});
});
it("ignores absolute provider discovery entries", () => {
const dir = makeTempDir();
const outsideDir = makeTempDir();
const outsideEntry = path.join(outsideDir, "provider-discovery.js");
fs.writeFileSync(outsideEntry, "export default {};\n", "utf8");
writeManifest(dir, {
id: "absolute-provider",
providers: ["absolute-provider"],
providerDiscoveryEntry: outsideEntry,
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "absolute-provider",
rootDir: dir,
origin: "bundled",
});
expect(registry.plugins[0]?.providerDiscoverySource).toBeUndefined();
expectDiagnosticFields(registry, {
level: "warn",
pluginId: "absolute-provider",
source: path.join(dir, "openclaw.plugin.json"),
messageIncludes: "providerDiscoveryEntry must resolve inside the plugin root",
});
});
it("ignores provider catalog entries that resolve outside the plugin root", () => {
const dir = makeTempDir();
const outsideDir = makeTempDir();
const outsideEntry = path.join(outsideDir, "provider-catalog.js");
fs.writeFileSync(outsideEntry, "export default {};\n", "utf8");
writeManifest(dir, {
id: "absolute-catalog",
providers: ["absolute-catalog"],
providerCatalogEntry: outsideEntry,
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "absolute-catalog",
rootDir: dir,
origin: "bundled",
});
expect(registry.plugins[0]?.providerDiscoverySource).toBeUndefined();
expectDiagnosticFields(registry, {
level: "warn",
pluginId: "absolute-catalog",
source: path.join(dir, "openclaw.plugin.json"),
messageIncludes: "providerCatalogEntry must resolve inside the plugin root",
});
});
it("ignores provider discovery entries that resolve through a symlink outside the plugin root", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const outsideDir = makeTempDir();
const outsideEntry = path.join(outsideDir, "provider-discovery.js");
const linkedEntry = path.join(dir, "provider-discovery.js");
fs.writeFileSync(outsideEntry, "export default {};\n", "utf8");
try {
fs.symlinkSync(outsideEntry, linkedEntry);
} catch {
return;
}
writeManifest(dir, {
id: "symlink-provider",
providers: ["symlink-provider"],
providerDiscoveryEntry: "./provider-discovery.js",
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "symlink-provider",
rootDir: dir,
origin: "bundled",
});
expect(registry.plugins[0]?.providerDiscoverySource).toBeUndefined();
expectDiagnosticFields(registry, {
level: "warn",
pluginId: "symlink-provider",
source: path.join(dir, "openclaw.plugin.json"),
messageIncludes: "providerDiscoveryEntry must resolve inside the plugin root",
});
});
it("ignores provider discovery .js fallbacks that resolve outside the plugin root", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const outsideDir = makeTempDir();
const outsideEntry = path.join(outsideDir, "provider-discovery.js");
const linkedEntry = path.join(dir, "provider-discovery.js");
fs.writeFileSync(outsideEntry, "export default {};\n", "utf8");
try {
fs.symlinkSync(outsideEntry, linkedEntry);
} catch {
return;
}
writeManifest(dir, {
id: "fallback-symlink-provider",
providers: ["fallback-symlink-provider"],
providerDiscoveryEntry: "./provider-discovery.ts",
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "fallback-symlink-provider",
rootDir: dir,
origin: "bundled",
});
expect(registry.plugins[0]?.providerDiscoverySource).toBeUndefined();
expectDiagnosticFields(registry, {
level: "warn",
pluginId: "fallback-symlink-provider",
source: path.join(dir, "openclaw.plugin.json"),
messageIncludes: "providerDiscoveryEntry must resolve inside the plugin root",
});
});
it("ignores non-bundled provider discovery entries that are hardlinked", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const outsideDir = makeTempDir();
const outsideEntry = path.join(outsideDir, "provider-discovery.js");
const linkedEntry = path.join(dir, "provider-discovery.js");
fs.writeFileSync(outsideEntry, "export default {};\n", "utf8");
try {
fs.linkSync(outsideEntry, linkedEntry);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw err;
}
writeManifest(dir, {
id: "hardlink-provider",
providers: ["hardlink-provider"],
providerDiscoveryEntry: "./provider-discovery.js",
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "hardlink-provider",
rootDir: dir,
origin: "config",
});
expect(registry.plugins[0]?.providerDiscoverySource).toBeUndefined();
expectDiagnosticFields(registry, {
level: "warn",
pluginId: "hardlink-provider",
source: path.join(dir, "openclaw.plugin.json"),
messageIncludes: "providerDiscoveryEntry must resolve inside the plugin root",
});
});
it("ignores non-bundled provider discovery .js fallbacks that are hardlinked", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const outsideDir = makeTempDir();
const outsideEntry = path.join(outsideDir, "provider-discovery.js");
const linkedEntry = path.join(dir, "provider-discovery.js");
fs.writeFileSync(outsideEntry, "export default {};\n", "utf8");
try {
fs.linkSync(outsideEntry, linkedEntry);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw err;
}
writeManifest(dir, {
id: "fallback-hardlink-provider",
providers: ["fallback-hardlink-provider"],
providerDiscoveryEntry: "./provider-discovery.ts",
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "fallback-hardlink-provider",
rootDir: dir,
origin: "config",
});
expect(registry.plugins[0]?.providerDiscoverySource).toBeUndefined();
expectDiagnosticFields(registry, {
level: "warn",
pluginId: "fallback-hardlink-provider",
source: path.join(dir, "openclaw.plugin.json"),
messageIncludes: "providerDiscoveryEntry must resolve inside the plugin root",
});
});
it("preserves activation and setup descriptors from plugin manifests", () => {
const dir = makeTempDir();
writeManifest(dir, {

View File

@@ -50,16 +50,11 @@ import {
resolveOfficialExternalPluginId,
resolveOfficialExternalPluginInstall,
} from "./official-external-plugin-catalog.js";
import { isPathInside, safeRealpathSync } from "./path-safety.js";
import { isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js";
import type { PluginKind } from "./plugin-kind.types.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import type { PluginDependencySpecMap } from "./status-dependencies.js";
/**
* Resolve a plugin source path, falling back from .ts to .js when the
* .ts file doesn't exist on disk (e.g. in dist builds where only .js
* is emitted but the manifest still references the .ts entry).
*/
function resolvePluginSourcePath(sourcePath: string): string {
if (fs.existsSync(sourcePath)) {
return sourcePath;
@@ -73,6 +68,89 @@ function resolvePluginSourcePath(sourcePath: string): string {
return sourcePath;
}
function isPluginRootPath(params: {
rootPath: string;
targetPath: string;
rootRealPath: string;
rejectHardlinks?: boolean;
targetMustExist?: boolean;
}): boolean {
const resolvedTargetPath = path.resolve(params.targetPath);
const resolvedRootPath = path.resolve(params.rootPath);
if (!isPathInside(resolvedRootPath, resolvedTargetPath)) {
return false;
}
const targetRealPath = safeRealpathSync(resolvedTargetPath);
if (!targetRealPath) {
return params.targetMustExist !== true;
}
if (!isPathInside(params.rootRealPath, targetRealPath)) {
return false;
}
if (params.rejectHardlinks === true) {
const targetStat = safeStatSync(resolvedTargetPath);
if (!targetStat || targetStat.nlink > 1) {
return false;
}
}
return true;
}
function resolveManifestPluginSourcePath(params: {
rootDir: string;
manifestPath: string;
pluginId: string;
entryName: "providerCatalogEntry" | "providerDiscoveryEntry";
entry: string;
rejectHardlinks: boolean;
diagnostics: PluginDiagnostic[];
}): string | undefined {
const pushDiagnostic = () => {
params.diagnostics.push({
level: "warn",
pluginId: sanitizeForLog(params.pluginId),
source: sanitizeForLog(params.manifestPath),
message: `plugin manifest ${params.entryName} must resolve inside the plugin root; ignoring entry`,
});
};
if (path.isAbsolute(params.entry)) {
pushDiagnostic();
return undefined;
}
const rootPath = path.resolve(params.rootDir);
const rootRealPath = safeRealpathSync(rootPath) ?? rootPath;
const sourcePath = path.resolve(rootPath, params.entry);
if (
!isPluginRootPath({
rootPath,
targetPath: sourcePath,
rootRealPath,
rejectHardlinks: params.rejectHardlinks,
targetMustExist: fs.existsSync(sourcePath),
})
) {
pushDiagnostic();
return undefined;
}
const resolvedSourcePath = resolvePluginSourcePath(sourcePath);
if (
!isPluginRootPath({
rootPath,
targetPath: resolvedSourcePath,
rootRealPath,
rejectHardlinks: params.rejectHardlinks,
targetMustExist: fs.existsSync(resolvedSourcePath),
})
) {
pushDiagnostic();
return undefined;
}
return resolvedSourcePath;
}
export type PluginManifestContractListKey =
| "speechProviders"
| "externalAuthProviders"
@@ -366,11 +444,25 @@ function buildRecord(params: {
manifest: PluginManifest;
candidate: PluginCandidate;
manifestPath: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks: boolean;
schemaCacheKey?: string;
configSchema?: Record<string, unknown>;
bundledChannelConfigCollector?: BundledChannelConfigCollector;
trustedOfficialInstall?: boolean;
}): PluginManifestRecord {
const providerSourceEntry =
params.manifest.providerCatalogEntry !== undefined
? {
entryName: "providerCatalogEntry" as const,
entry: params.manifest.providerCatalogEntry,
}
: params.manifest.providerDiscoveryEntry !== undefined
? {
entryName: "providerDiscoveryEntry" as const,
entry: params.manifest.providerDiscoveryEntry,
}
: undefined;
const manifestChannelConfigs =
params.candidate.origin === "bundled" && params.bundledChannelConfigCollector
? params.bundledChannelConfigCollector({
@@ -413,15 +505,17 @@ function buildRecord(params: {
kind: params.manifest.kind,
channels: params.manifest.channels ?? [],
providers: params.manifest.providers ?? [],
providerDiscoverySource:
(params.manifest.providerCatalogEntry ?? params.manifest.providerDiscoveryEntry)
? resolvePluginSourcePath(
path.resolve(
params.candidate.rootDir,
params.manifest.providerCatalogEntry ?? params.manifest.providerDiscoveryEntry!,
),
)
: undefined,
providerDiscoverySource: providerSourceEntry
? resolveManifestPluginSourcePath({
rootDir: params.candidate.rootDir,
manifestPath: params.manifestPath,
pluginId: params.manifest.id,
entryName: providerSourceEntry.entryName,
entry: providerSourceEntry.entry,
rejectHardlinks: params.rejectHardlinks,
diagnostics: params.diagnostics,
})
: undefined,
modelSupport: params.manifest.modelSupport,
modelCatalog: params.manifest.modelCatalog,
modelPricing: params.manifest.modelPricing,
@@ -941,6 +1035,8 @@ export function loadPluginManifestRegistry(
manifest: manifest as PluginManifest,
candidate,
manifestPath: manifestRes.manifestPath,
diagnostics,
rejectHardlinks,
schemaCacheKey,
configSchema,
trustedOfficialInstall: isTrustedOfficialPluginInstall({