325 lines
9.4 KiB
TypeScript
325 lines
9.4 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import type { PluginCandidate } from "../plugins/discovery.js";
|
|
import { isPathInside, safeStatSync } from "../plugins/path-safety.js";
|
|
import type { PluginRecord, PluginRegistry } from "../plugins/registry.js";
|
|
import type { PluginDiagnostic, PluginLogger } from "../plugins/types.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
|
|
function safeRealpathOrResolve(value: string): string {
|
|
try {
|
|
return fs.realpathSync(value);
|
|
} catch {
|
|
return path.resolve(value);
|
|
}
|
|
}
|
|
|
|
type PathMatcher = {
|
|
exact: Set<string>;
|
|
dirs: string[];
|
|
};
|
|
|
|
type InstallTrackingRule = {
|
|
trackedWithoutPaths: boolean;
|
|
matcher: PathMatcher;
|
|
};
|
|
|
|
export type ExtensionHostProvenanceIndex = {
|
|
loadPathMatcher: PathMatcher;
|
|
installRules: Map<string, InstallTrackingRule>;
|
|
};
|
|
|
|
export function createExtensionHostPluginRecord(params: {
|
|
id: string;
|
|
name?: string;
|
|
description?: string;
|
|
version?: string;
|
|
source: string;
|
|
origin: PluginRecord["origin"];
|
|
workspaceDir?: string;
|
|
enabled: boolean;
|
|
configSchema: boolean;
|
|
}): PluginRecord {
|
|
return {
|
|
id: params.id,
|
|
name: params.name ?? params.id,
|
|
description: params.description,
|
|
version: params.version,
|
|
source: params.source,
|
|
origin: params.origin,
|
|
workspaceDir: params.workspaceDir,
|
|
enabled: params.enabled,
|
|
status: params.enabled ? "loaded" : "disabled",
|
|
toolNames: [],
|
|
hookNames: [],
|
|
channelIds: [],
|
|
providerIds: [],
|
|
gatewayMethods: [],
|
|
cliCommands: [],
|
|
services: [],
|
|
commands: [],
|
|
httpRoutes: 0,
|
|
hookCount: 0,
|
|
configSchema: params.configSchema,
|
|
configUiHints: undefined,
|
|
configJsonSchema: undefined,
|
|
};
|
|
}
|
|
|
|
export function recordExtensionHostPluginError(params: {
|
|
logger: PluginLogger;
|
|
registry: PluginRegistry;
|
|
record: PluginRecord;
|
|
seenIds: Map<string, PluginRecord["origin"]>;
|
|
pluginId: string;
|
|
origin: PluginRecord["origin"];
|
|
error: unknown;
|
|
logPrefix: string;
|
|
diagnosticMessagePrefix: string;
|
|
}): void {
|
|
const errorText = String(params.error);
|
|
const deprecatedApiHint =
|
|
errorText.includes("api.registerHttpHandler") && errorText.includes("is not a function")
|
|
? "deprecated api.registerHttpHandler(...) was removed; use api.registerHttpRoute(...) for plugin-owned routes or registerPluginHttpRoute(...) for dynamic lifecycle routes"
|
|
: null;
|
|
const displayError = deprecatedApiHint ? `${deprecatedApiHint} (${errorText})` : errorText;
|
|
params.logger.error(`${params.logPrefix}${displayError}`);
|
|
params.record.status = "error";
|
|
params.record.error = displayError;
|
|
params.registry.plugins.push(params.record);
|
|
params.seenIds.set(params.pluginId, params.origin);
|
|
params.registry.diagnostics.push({
|
|
level: "error",
|
|
pluginId: params.record.id,
|
|
source: params.record.source,
|
|
message: `${params.diagnosticMessagePrefix}${displayError}`,
|
|
});
|
|
}
|
|
|
|
export function pushExtensionHostDiagnostics(
|
|
diagnostics: PluginDiagnostic[],
|
|
append: PluginDiagnostic[],
|
|
): void {
|
|
diagnostics.push(...append);
|
|
}
|
|
|
|
function createPathMatcher(): PathMatcher {
|
|
return { exact: new Set<string>(), dirs: [] };
|
|
}
|
|
|
|
function addPathToMatcher(
|
|
matcher: PathMatcher,
|
|
rawPath: string,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): void {
|
|
const trimmed = rawPath.trim();
|
|
if (!trimmed) {
|
|
return;
|
|
}
|
|
const resolved = resolveUserPath(trimmed, env);
|
|
if (!resolved) {
|
|
return;
|
|
}
|
|
if (matcher.exact.has(resolved) || matcher.dirs.includes(resolved)) {
|
|
return;
|
|
}
|
|
const stat = safeStatSync(resolved);
|
|
if (stat?.isDirectory()) {
|
|
matcher.dirs.push(resolved);
|
|
return;
|
|
}
|
|
matcher.exact.add(resolved);
|
|
}
|
|
|
|
function matchesPathMatcher(matcher: PathMatcher, sourcePath: string): boolean {
|
|
if (matcher.exact.has(sourcePath)) {
|
|
return true;
|
|
}
|
|
return matcher.dirs.some((dirPath) => isPathInside(dirPath, sourcePath));
|
|
}
|
|
|
|
export function buildExtensionHostProvenanceIndex(params: {
|
|
config: OpenClawConfig;
|
|
normalizedLoadPaths: string[];
|
|
env: NodeJS.ProcessEnv;
|
|
}): ExtensionHostProvenanceIndex {
|
|
const loadPathMatcher = createPathMatcher();
|
|
for (const loadPath of params.normalizedLoadPaths) {
|
|
addPathToMatcher(loadPathMatcher, loadPath, params.env);
|
|
}
|
|
|
|
const installRules = new Map<string, InstallTrackingRule>();
|
|
const installs = params.config.plugins?.installs ?? {};
|
|
for (const [pluginId, install] of Object.entries(installs)) {
|
|
const rule: InstallTrackingRule = {
|
|
trackedWithoutPaths: false,
|
|
matcher: createPathMatcher(),
|
|
};
|
|
const trackedPaths = [install.installPath, install.sourcePath]
|
|
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
|
.filter(Boolean);
|
|
if (trackedPaths.length === 0) {
|
|
rule.trackedWithoutPaths = true;
|
|
} else {
|
|
for (const trackedPath of trackedPaths) {
|
|
addPathToMatcher(rule.matcher, trackedPath, params.env);
|
|
}
|
|
}
|
|
installRules.set(pluginId, rule);
|
|
}
|
|
|
|
return { loadPathMatcher, installRules };
|
|
}
|
|
|
|
function isTrackedByProvenance(params: {
|
|
pluginId: string;
|
|
source: string;
|
|
index: ExtensionHostProvenanceIndex;
|
|
env: NodeJS.ProcessEnv;
|
|
}): boolean {
|
|
const sourcePath = resolveUserPath(params.source, params.env);
|
|
const installRule = params.index.installRules.get(params.pluginId);
|
|
if (installRule) {
|
|
if (installRule.trackedWithoutPaths) {
|
|
return true;
|
|
}
|
|
if (matchesPathMatcher(installRule.matcher, sourcePath)) {
|
|
return true;
|
|
}
|
|
}
|
|
return matchesPathMatcher(params.index.loadPathMatcher, sourcePath);
|
|
}
|
|
|
|
function matchesExplicitInstallRule(params: {
|
|
pluginId: string;
|
|
source: string;
|
|
index: ExtensionHostProvenanceIndex;
|
|
env: NodeJS.ProcessEnv;
|
|
}): boolean {
|
|
const sourcePath = resolveUserPath(params.source, params.env);
|
|
const installRule = params.index.installRules.get(params.pluginId);
|
|
if (!installRule || installRule.trackedWithoutPaths) {
|
|
return false;
|
|
}
|
|
return matchesPathMatcher(installRule.matcher, sourcePath);
|
|
}
|
|
|
|
function resolveCandidateDuplicateRank(params: {
|
|
candidate: PluginCandidate;
|
|
manifestByRoot: Map<string, { id: string }>;
|
|
provenance: ExtensionHostProvenanceIndex;
|
|
env: NodeJS.ProcessEnv;
|
|
}): number {
|
|
const manifestRecord = params.manifestByRoot.get(params.candidate.rootDir);
|
|
const pluginId = manifestRecord?.id;
|
|
const isExplicitInstall =
|
|
params.candidate.origin === "global" &&
|
|
pluginId !== undefined &&
|
|
matchesExplicitInstallRule({
|
|
pluginId,
|
|
source: params.candidate.source,
|
|
index: params.provenance,
|
|
env: params.env,
|
|
});
|
|
|
|
switch (params.candidate.origin) {
|
|
case "config":
|
|
return 0;
|
|
case "workspace":
|
|
return 1;
|
|
case "global":
|
|
return isExplicitInstall ? 2 : 4;
|
|
case "bundled":
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
export function compareExtensionHostDuplicateCandidateOrder(params: {
|
|
left: PluginCandidate;
|
|
right: PluginCandidate;
|
|
manifestByRoot: Map<string, { id: string }>;
|
|
provenance: ExtensionHostProvenanceIndex;
|
|
env: NodeJS.ProcessEnv;
|
|
}): number {
|
|
const leftPluginId = params.manifestByRoot.get(params.left.rootDir)?.id;
|
|
const rightPluginId = params.manifestByRoot.get(params.right.rootDir)?.id;
|
|
if (!leftPluginId || leftPluginId !== rightPluginId) {
|
|
return 0;
|
|
}
|
|
return (
|
|
resolveCandidateDuplicateRank({
|
|
candidate: params.left,
|
|
manifestByRoot: params.manifestByRoot,
|
|
provenance: params.provenance,
|
|
env: params.env,
|
|
}) -
|
|
resolveCandidateDuplicateRank({
|
|
candidate: params.right,
|
|
manifestByRoot: params.manifestByRoot,
|
|
provenance: params.provenance,
|
|
env: params.env,
|
|
})
|
|
);
|
|
}
|
|
|
|
export function warnWhenExtensionAllowlistIsOpen(params: {
|
|
logger: PluginLogger;
|
|
pluginsEnabled: boolean;
|
|
allow: string[];
|
|
warningCacheKey: string;
|
|
warningCache: Set<string>;
|
|
discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>;
|
|
}): void {
|
|
if (!params.pluginsEnabled || params.allow.length > 0) {
|
|
return;
|
|
}
|
|
const nonBundled = params.discoverablePlugins.filter((entry) => entry.origin !== "bundled");
|
|
if (nonBundled.length === 0 || params.warningCache.has(params.warningCacheKey)) {
|
|
return;
|
|
}
|
|
const preview = nonBundled
|
|
.slice(0, 6)
|
|
.map((entry) => `${entry.id} (${entry.source})`)
|
|
.join(", ");
|
|
const extra = nonBundled.length > 6 ? ` (+${nonBundled.length - 6} more)` : "";
|
|
params.warningCache.add(params.warningCacheKey);
|
|
params.logger.warn(
|
|
`[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load: ${preview}${extra}. Set plugins.allow to explicit trusted ids.`,
|
|
);
|
|
}
|
|
|
|
export function warnAboutUntrackedLoadedExtensions(params: {
|
|
registry: PluginRegistry;
|
|
provenance: ExtensionHostProvenanceIndex;
|
|
logger: PluginLogger;
|
|
env: NodeJS.ProcessEnv;
|
|
}): void {
|
|
for (const plugin of params.registry.plugins) {
|
|
if (plugin.status !== "loaded" || plugin.origin === "bundled") {
|
|
continue;
|
|
}
|
|
if (
|
|
isTrackedByProvenance({
|
|
pluginId: plugin.id,
|
|
source: plugin.source,
|
|
index: params.provenance,
|
|
env: params.env,
|
|
})
|
|
) {
|
|
continue;
|
|
}
|
|
const message =
|
|
"loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records";
|
|
params.registry.diagnostics.push({
|
|
level: "warn",
|
|
pluginId: plugin.id,
|
|
source: plugin.source,
|
|
message,
|
|
});
|
|
params.logger.warn(
|
|
`[plugins] ${plugin.id}: ${message} (${safeRealpathOrResolve(plugin.source)})`,
|
|
);
|
|
}
|
|
}
|