mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 20:24:46 +00:00
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:
committed by
GitHub
parent
d656087b31
commit
eb1e6099d2
@@ -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.
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user