mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
fix(plugins): repair configured runtime deps
This commit is contained in:
@@ -265,7 +265,7 @@ openclaw plugins deps --prune
|
||||
openclaw plugins deps --json
|
||||
```
|
||||
|
||||
`plugins deps` inspects the packaged runtime dependency stage for OpenClaw-owned bundled plugins. It is not the install/update path for third-party npm or ClawHub plugins.
|
||||
`plugins deps` inspects the packaged runtime dependency stage for OpenClaw-owned bundled plugins selected by plugin config, enabled/configured channels, configured model providers, or bundled manifest defaults. It is not the install/update path for third-party npm or ClawHub plugins.
|
||||
|
||||
Use `--repair` when a packaged install reports missing bundled runtime dependencies during Gateway startup or `plugins doctor`. Repair installs only missing enabled bundled-plugin deps with lifecycle scripts disabled. Use `--prune` to remove stale unknown external runtime-dependency roots left behind by older packaged layouts.
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
When sandboxing is enabled, doctor checks Docker images and offers to build or switch to legacy names if the current image is missing.
|
||||
</Accordion>
|
||||
<Accordion title="7b. Bundled plugin runtime deps">
|
||||
Doctor verifies runtime dependencies only for bundled plugins that are active in the current config or enabled by their bundled manifest default, for example `plugins.entries.discord.enabled: true`, legacy `channels.discord.enabled: true`, or a default-enabled bundled provider. If any are missing, doctor reports the packages and installs them in `openclaw doctor --fix` / `openclaw doctor --repair` mode. External plugins still use `openclaw plugins install` / `openclaw plugins update`; doctor does not install dependencies for arbitrary plugin paths.
|
||||
Doctor verifies runtime dependencies only for bundled plugins that are active in the current config or enabled by their bundled manifest default, for example `plugins.entries.discord.enabled: true`, legacy `channels.discord.enabled: true`, configured `models.providers.*` / agent model refs, or a default-enabled bundled plugin without provider ownership. If any are missing, doctor reports the packages and installs them in `openclaw doctor --fix` / `openclaw doctor --repair` mode. External plugins still use `openclaw plugins install` / `openclaw plugins update`; doctor does not install dependencies for arbitrary plugin paths.
|
||||
|
||||
During doctor repair, bundled runtime-dependency npm installs report spinner progress in TTY sessions and periodic line progress in piped/headless output. The Gateway and local CLI can also repair active bundled plugin runtime dependencies on demand before importing a bundled plugin. These installs are scoped to the plugin runtime install root, run with scripts disabled, do not write a package lock, and are guarded by an install-root lock so concurrent CLI or Gateway starts do not mutate the same `node_modules` tree at the same time.
|
||||
|
||||
|
||||
@@ -38,6 +38,23 @@ function writeBundledChannelOwnerPlugin(
|
||||
});
|
||||
}
|
||||
|
||||
function writeBundledProviderPlugin(
|
||||
root: string,
|
||||
id: string,
|
||||
providers: string[],
|
||||
dependencies: Record<string, string>,
|
||||
) {
|
||||
writeJson(path.join(root, "dist", "extensions", id, "package.json"), {
|
||||
dependencies,
|
||||
});
|
||||
writeJson(path.join(root, "dist", "extensions", id, "openclaw.plugin.json"), {
|
||||
id,
|
||||
providers,
|
||||
enabledByDefault: true,
|
||||
configSchema: { type: "object" },
|
||||
});
|
||||
}
|
||||
|
||||
function writeDefaultEnabledBundledChannelPlugin(
|
||||
root: string,
|
||||
id: string,
|
||||
@@ -528,6 +545,87 @@ describe("doctor bundled plugin runtime deps", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("repairs configured provider deps", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
|
||||
writeJson(path.join(root, "package.json"), { name: "openclaw" });
|
||||
writeBundledProviderPlugin(root, "anthropic-vertex", ["anthropic-vertex"], {
|
||||
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
||||
});
|
||||
const installed = createInstalledRuntimeDeps();
|
||||
|
||||
await maybeRepairBundledPluginRuntimeDeps({
|
||||
runtime: createRuntime(),
|
||||
prompter: createNonInteractivePrompter(),
|
||||
packageRoot: root,
|
||||
config: {
|
||||
plugins: { enabled: true },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic-vertex/claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
},
|
||||
installDeps: (params) => {
|
||||
installed.push(params);
|
||||
materializeRuntimeDeps(params);
|
||||
},
|
||||
});
|
||||
|
||||
expect(installed).toEqual([
|
||||
{
|
||||
installRoot: resolveBundledRuntimeDependencyPackageInstallRoot(root),
|
||||
missingSpecs: ["@anthropic-ai/vertex-sdk@^0.16.0"],
|
||||
installSpecs: ["@anthropic-ai/vertex-sdk@^0.16.0"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("repairs configured provider deps from provider aliases and subagent defaults", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
|
||||
writeJson(path.join(root, "package.json"), { name: "openclaw" });
|
||||
writeBundledProviderPlugin(root, "amazon-bedrock", ["amazon-bedrock"], {
|
||||
"bedrock-only": "1.0.0",
|
||||
});
|
||||
const installed = createInstalledRuntimeDeps();
|
||||
|
||||
await maybeRepairBundledPluginRuntimeDeps({
|
||||
runtime: createRuntime(),
|
||||
prompter: createNonInteractivePrompter(),
|
||||
packageRoot: root,
|
||||
config: {
|
||||
plugins: { enabled: true },
|
||||
models: {
|
||||
providers: {
|
||||
"aws-bedrock": {
|
||||
baseUrl: "",
|
||||
auth: "aws-sdk",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
model: "bedrock/claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
installDeps: (params) => {
|
||||
installed.push(params);
|
||||
materializeRuntimeDeps(params);
|
||||
},
|
||||
});
|
||||
|
||||
expect(installed).toEqual([
|
||||
{
|
||||
installRoot: resolveBundledRuntimeDependencyPackageInstallRoot(root),
|
||||
missingSpecs: ["bedrock-only@1.0.0"],
|
||||
installSpecs: ["bedrock-only@1.0.0"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("repairs missing deps during non-interactive doctor", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-bundled-"));
|
||||
writeJson(path.join(root, "package.json"), { name: "openclaw" });
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
|
||||
@@ -10,84 +8,12 @@ import {
|
||||
scanBundledPluginRuntimeDeps,
|
||||
type BundledRuntimeDepsInstallParams,
|
||||
} from "../plugins/bundled-runtime-deps.js";
|
||||
import { normalizePluginsConfig } from "../plugins/config-state.js";
|
||||
import { passesManifestOwnerBasePolicy } from "../plugins/manifest-owner-policy.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
const RUNTIME_DEPS_INSTALL_HEARTBEAT_MS = 15_000;
|
||||
|
||||
function collectPackagedRuntimeDepsRepairPluginIds(params: {
|
||||
bundledPluginsDir: string;
|
||||
config: OpenClawConfig;
|
||||
includeConfiguredChannels?: boolean;
|
||||
}): string[] {
|
||||
if (!fs.existsSync(params.bundledPluginsDir)) {
|
||||
return [];
|
||||
}
|
||||
const plugins = normalizePluginsConfig(params.config.plugins);
|
||||
const ids = new Set<string>();
|
||||
for (const entry of fs.readdirSync(params.bundledPluginsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const pluginDir = path.join(params.bundledPluginsDir, entry.name);
|
||||
let manifest: Record<string, unknown>;
|
||||
try {
|
||||
manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(pluginDir, "openclaw.plugin.json"), "utf-8"),
|
||||
) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const pluginId = typeof manifest.id === "string" && manifest.id ? manifest.id : entry.name;
|
||||
if (
|
||||
!passesManifestOwnerBasePolicy({
|
||||
plugin: { id: pluginId },
|
||||
normalizedConfig: plugins,
|
||||
allowRestrictiveAllowlistBypass: true,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (plugins.allow.includes(pluginId) || plugins.entries[pluginId]?.enabled === true) {
|
||||
ids.add(pluginId);
|
||||
continue;
|
||||
}
|
||||
const channels = Array.isArray(manifest.channels)
|
||||
? manifest.channels.filter((channel): channel is string => typeof channel === "string")
|
||||
: [];
|
||||
if (
|
||||
channels.some((channelId) => {
|
||||
const channelConfig = (params.config.channels as Record<string, unknown> | undefined)?.[
|
||||
channelId
|
||||
];
|
||||
if (!channelConfig || typeof channelConfig !== "object" || Array.isArray(channelConfig)) {
|
||||
return false;
|
||||
}
|
||||
if ((channelConfig as { enabled?: unknown }).enabled === false) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(channelConfig as { enabled?: unknown }).enabled === true ||
|
||||
params.includeConfiguredChannels === true
|
||||
);
|
||||
})
|
||||
) {
|
||||
ids.add(pluginId);
|
||||
continue;
|
||||
}
|
||||
const providers = Array.isArray(manifest.providers)
|
||||
? manifest.providers.filter((provider): provider is string => typeof provider === "string")
|
||||
: [];
|
||||
if (manifest.enabledByDefault === true && providers.length === 0 && channels.length === 0) {
|
||||
ids.add(pluginId);
|
||||
}
|
||||
}
|
||||
return [...ids].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function formatElapsedMs(elapsedMs: number): string {
|
||||
if (elapsedMs < 1000) {
|
||||
return `${elapsedMs}ms`;
|
||||
@@ -126,18 +52,9 @@ export async function maybeRepairBundledPluginRuntimeDeps(params: {
|
||||
}
|
||||
|
||||
const env = params.env ?? process.env;
|
||||
const bundledPluginsDir = path.join(packageRoot, "dist", "extensions");
|
||||
const effectivePluginIds = params.config
|
||||
? collectPackagedRuntimeDepsRepairPluginIds({
|
||||
bundledPluginsDir,
|
||||
config: params.config,
|
||||
includeConfiguredChannels: params.includeConfiguredChannels,
|
||||
})
|
||||
: undefined;
|
||||
const { deps, missing, conflicts } = scanBundledPluginRuntimeDeps({
|
||||
packageRoot,
|
||||
config: params.config,
|
||||
pluginIds: effectivePluginIds,
|
||||
includeConfiguredChannels: params.includeConfiguredChannels,
|
||||
env,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { normalizeProviderId } from "../agents/provider-id.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import { readRuntimeDepsJsonObject, type JsonObject } from "./bundled-runtime-deps-json.js";
|
||||
@@ -207,6 +208,88 @@ function passesRuntimeDepsPluginPolicy(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function addConfiguredProviderId(providerIds: Set<string>, value: unknown): void {
|
||||
if (typeof value !== "string") {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeProviderId(value);
|
||||
if (normalized) {
|
||||
providerIds.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function addConfiguredProviderFromModelRef(providerIds: Set<string>, value: unknown): void {
|
||||
if (typeof value !== "string") {
|
||||
return;
|
||||
}
|
||||
const providerId = value.split("/", 1)[0]?.trim();
|
||||
addConfiguredProviderId(providerIds, providerId);
|
||||
}
|
||||
|
||||
function addConfiguredProvidersFromModelConfig(providerIds: Set<string>, value: unknown): void {
|
||||
if (typeof value === "string") {
|
||||
addConfiguredProviderFromModelRef(providerIds, value);
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return;
|
||||
}
|
||||
addConfiguredProviderFromModelRef(providerIds, value.primary);
|
||||
if (Array.isArray(value.fallbacks)) {
|
||||
for (const fallback of value.fallbacks) {
|
||||
addConfiguredProviderFromModelRef(providerIds, fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectConfiguredProviderIds(config: OpenClawConfig): Set<string> {
|
||||
const providerIds = new Set<string>();
|
||||
for (const providerId of Object.keys(config.models?.providers ?? {})) {
|
||||
addConfiguredProviderId(providerIds, providerId);
|
||||
}
|
||||
for (const profile of Object.values(config.auth?.profiles ?? {})) {
|
||||
addConfiguredProviderId(providerIds, profile.provider);
|
||||
}
|
||||
for (const providerId of Object.keys(config.auth?.order ?? {})) {
|
||||
addConfiguredProviderId(providerIds, providerId);
|
||||
}
|
||||
|
||||
const defaults = config.agents?.defaults;
|
||||
addConfiguredProvidersFromModelConfig(providerIds, defaults?.model);
|
||||
addConfiguredProvidersFromModelConfig(providerIds, defaults?.imageModel);
|
||||
addConfiguredProvidersFromModelConfig(providerIds, defaults?.imageGenerationModel);
|
||||
addConfiguredProvidersFromModelConfig(providerIds, defaults?.videoGenerationModel);
|
||||
addConfiguredProvidersFromModelConfig(providerIds, defaults?.musicGenerationModel);
|
||||
addConfiguredProvidersFromModelConfig(providerIds, defaults?.pdfModel);
|
||||
addConfiguredProvidersFromModelConfig(providerIds, defaults?.subagents?.model);
|
||||
for (const providerId of Object.keys(defaults?.models ?? {})) {
|
||||
addConfiguredProviderFromModelRef(providerIds, providerId);
|
||||
}
|
||||
|
||||
for (const agent of config.agents?.list ?? []) {
|
||||
addConfiguredProvidersFromModelConfig(providerIds, agent.model);
|
||||
addConfiguredProvidersFromModelConfig(providerIds, agent.subagents?.model);
|
||||
}
|
||||
return providerIds;
|
||||
}
|
||||
|
||||
function isBundledProviderConfiguredForRuntimeDeps(params: {
|
||||
config: OpenClawConfig;
|
||||
providers: readonly string[];
|
||||
}): boolean {
|
||||
if (params.providers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const configuredProviderIds = collectConfiguredProviderIds(params.config);
|
||||
return params.providers.some((provider) =>
|
||||
configuredProviderIds.has(normalizeProviderId(provider)),
|
||||
);
|
||||
}
|
||||
|
||||
export function isBundledPluginConfiguredForRuntimeDeps(params: {
|
||||
config: OpenClawConfig;
|
||||
plugins: NormalizedPluginsConfig;
|
||||
@@ -280,7 +363,15 @@ export function isBundledPluginConfiguredForRuntimeDeps(params: {
|
||||
if (hasConfiguredChannel) {
|
||||
return true;
|
||||
}
|
||||
return manifest.enabledByDefault;
|
||||
if (
|
||||
isBundledProviderConfiguredForRuntimeDeps({
|
||||
config: params.config,
|
||||
providers: manifest.providers,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return manifest.enabledByDefault && manifest.providers.length === 0;
|
||||
}
|
||||
|
||||
function isBundledPluginExplicitlyDisabledForRuntimeDeps(params: {
|
||||
|
||||
@@ -1037,6 +1037,13 @@ describe("scanBundledPluginRuntimeDeps config policy", () => {
|
||||
deps: { "telegram-runtime": "2.0.0" },
|
||||
channels: ["telegram"],
|
||||
});
|
||||
writeBundledPluginPackage({
|
||||
packageRoot,
|
||||
pluginId: "amazon-bedrock",
|
||||
deps: { "bedrock-runtime": "3.0.0" },
|
||||
enabledByDefault: true,
|
||||
providers: ["amazon-bedrock"],
|
||||
});
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
@@ -1134,6 +1141,33 @@ describe("scanBundledPluginRuntimeDeps config policy", () => {
|
||||
includeConfiguredChannels: true,
|
||||
expectedDeps: ["alpha-runtime@1.0.0"],
|
||||
},
|
||||
{
|
||||
name: "includes configured model provider deps",
|
||||
config: { agents: { defaults: { model: "amazon-bedrock/claude-opus-4-7" } } },
|
||||
includeConfiguredChannels: false,
|
||||
expectedDeps: ["alpha-runtime@1.0.0", "bedrock-runtime@3.0.0"],
|
||||
},
|
||||
{
|
||||
name: "includes configured model provider deps from aliases",
|
||||
config: { models: { providers: { "aws-bedrock": { baseUrl: "", models: [] } } } },
|
||||
includeConfiguredChannels: false,
|
||||
expectedDeps: ["alpha-runtime@1.0.0", "bedrock-runtime@3.0.0"],
|
||||
},
|
||||
{
|
||||
name: "includes configured subagent model provider deps",
|
||||
config: { agents: { defaults: { subagents: { model: "bedrock/claude-sonnet-4-6" } } } },
|
||||
includeConfiguredChannels: false,
|
||||
expectedDeps: ["alpha-runtime@1.0.0", "bedrock-runtime@3.0.0"],
|
||||
},
|
||||
{
|
||||
name: "keeps configured provider deps behind restrictive allowlists",
|
||||
config: {
|
||||
plugins: { allow: ["alpha"] },
|
||||
agents: { defaults: { model: "amazon-bedrock/claude-opus-4-7" } },
|
||||
},
|
||||
includeConfiguredChannels: false,
|
||||
expectedDeps: ["alpha-runtime@1.0.0"],
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)("$name", ({ config, includeConfiguredChannels, expectedDeps }) => {
|
||||
|
||||
@@ -91,14 +91,20 @@ export function materializeBundledRuntimeMirrorFile(sourcePath: string, targetPa
|
||||
// Missing targets are expected before the mirror file is materialized.
|
||||
}
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o755 });
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
removeBundledRuntimeMirrorPathIfTypeChanged(targetPath, "file");
|
||||
const tempPath = createBundledRuntimeMirrorTempPath(targetPath);
|
||||
try {
|
||||
fs.linkSync(sourcePath, targetPath);
|
||||
return;
|
||||
} catch {
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
try {
|
||||
fs.linkSync(sourcePath, tempPath);
|
||||
} catch {
|
||||
fs.copyFileSync(sourcePath, tempPath);
|
||||
chmodBundledRuntimeMirrorFileReadable(sourcePath, tempPath);
|
||||
}
|
||||
fs.renameSync(tempPath, targetPath);
|
||||
} catch (error) {
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
chmodBundledRuntimeMirrorFileReadable(sourcePath, targetPath);
|
||||
}
|
||||
|
||||
function chmodBundledRuntimeMirrorFileReadable(sourcePath: string, targetPath: string): void {
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveBundledRuntimeDependencyInstallRoot } from "./bundled-runtime-deps.js";
|
||||
import { materializeBundledRuntimeMirrorFile } from "./bundled-runtime-mirror.js";
|
||||
import { prepareBundledPluginRuntimeRoot } from "./bundled-runtime-root.js";
|
||||
import { writeGeneratedRuntimeDepsManifest } from "./test-helpers/bundled-runtime-deps-fixtures.js";
|
||||
|
||||
@@ -37,6 +38,24 @@ function isBigIntStatOptions(options: unknown): boolean {
|
||||
}
|
||||
|
||||
describe("prepareBundledPluginRuntimeRoot", () => {
|
||||
it("keeps existing materialized root chunks when copy refresh fails", () => {
|
||||
const root = makeTempRoot();
|
||||
const source = path.join(root, "source.js");
|
||||
const target = path.join(root, "mirror", "source.js");
|
||||
fs.writeFileSync(source, "export const value = 'new';\n", "utf8");
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, "export const value = 'old';\n", "utf8");
|
||||
vi.spyOn(fs, "linkSync").mockImplementation(() => {
|
||||
throw new Error("EXDEV");
|
||||
});
|
||||
vi.spyOn(fs, "copyFileSync").mockImplementation(() => {
|
||||
throw new Error("ENOSPC");
|
||||
});
|
||||
|
||||
expect(() => materializeBundledRuntimeMirrorFile(source, target)).toThrow("ENOSPC");
|
||||
expect(fs.readFileSync(target, "utf8")).toBe("export const value = 'old';\n");
|
||||
});
|
||||
|
||||
it("materializes root JavaScript chunks in external mirrors", () => {
|
||||
const packageRoot = makeTempRoot();
|
||||
const stageDir = makeTempRoot();
|
||||
|
||||
@@ -46,6 +46,7 @@ export function writeBundledPluginRuntimeDepsPackage(params: {
|
||||
deps: Record<string, string>;
|
||||
enabledByDefault?: boolean;
|
||||
channels?: string[];
|
||||
providers?: string[];
|
||||
}): string {
|
||||
const pluginRoot = path.join(params.packageRoot, "dist", "extensions", params.pluginId);
|
||||
fs.mkdirSync(pluginRoot, { recursive: true });
|
||||
@@ -59,6 +60,7 @@ export function writeBundledPluginRuntimeDepsPackage(params: {
|
||||
id: params.pluginId,
|
||||
enabledByDefault: params.enabledByDefault === true,
|
||||
...(params.channels ? { channels: params.channels } : {}),
|
||||
...(params.providers ? { providers: params.providers } : {}),
|
||||
}),
|
||||
);
|
||||
return pluginRoot;
|
||||
|
||||
Reference in New Issue
Block a user