diff --git a/CHANGELOG.md b/CHANGELOG.md index a5db8122cfd..8c95a09177f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 78a7f323c64..213bd535206 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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, { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 11e63e68e38..c29da59ed3f 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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; 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({