Merge remote-tracking branch 'origin/main' into release/2026.4.25

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Peter Steinberger
2026-04-26 11:39:46 +01:00
222 changed files with 8310 additions and 2964 deletions

View File

@@ -42,6 +42,16 @@ describe("activation planner", () => {
hooks: [],
origin: "bundled",
},
{
id: "browser",
commandAliases: [{ name: "browser" }],
providers: [],
channels: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "bundled",
},
{
id: "openai",
providers: ["openai"],
@@ -88,6 +98,15 @@ describe("activation planner", () => {
}),
).toEqual(["memory-core"]);
expect(
resolveManifestActivationPluginIds({
trigger: {
kind: "command",
command: "browser",
},
}),
).toEqual(["browser"]);
expect(
resolveManifestActivationPluginIds({
trigger: {

View File

@@ -59,6 +59,11 @@ export function buildLegacyBundledPath(localPath: string): string | null {
return bundledLeaf ? path.join(packaged.packageRoot, "extensions", bundledLeaf) : null;
}
export function buildLegacyBundledRootPath(localPath: string): string | null {
const packaged = findPackagedBundledRoot(localPath);
return packaged ? path.join(packaged.packageRoot, "extensions") : null;
}
export function buildBundledPluginLoadPathAliases(localPath: string): BundledPluginLoadPathAlias[] {
const legacyPath = buildLegacyBundledPath(localPath);
if (!legacyPath) {

View File

@@ -14,6 +14,7 @@ import {
isWritableDirectory,
resolveBundledRuntimeDependencyInstallRoot,
resolveBundledRuntimeDepsNpmRunner,
scanBundledPluginRuntimeDeps,
type BundledRuntimeDepsInstallParams,
} from "./bundled-runtime-deps.js";
@@ -41,6 +42,30 @@ function writeInstalledPackage(rootDir: string, packageName: string, version: st
);
}
function writeBundledPluginPackage(params: {
packageRoot: string;
pluginId: string;
deps: Record<string, string>;
enabledByDefault?: boolean;
channels?: string[];
}): string {
const pluginRoot = path.join(params.packageRoot, "dist", "extensions", params.pluginId);
fs.mkdirSync(pluginRoot, { recursive: true });
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify({ dependencies: params.deps }),
);
fs.writeFileSync(
path.join(pluginRoot, "openclaw.plugin.json"),
JSON.stringify({
id: params.pluginId,
enabledByDefault: params.enabledByDefault === true,
...(params.channels ? { channels: params.channels } : {}),
}),
);
return pluginRoot;
}
function statfsFixture(params: {
bavail: number;
bsize?: number;
@@ -587,6 +612,116 @@ describe("installBundledRuntimeDeps", () => {
});
});
describe("scanBundledPluginRuntimeDeps config policy", () => {
function setupPolicyPackageRoot(): string {
const packageRoot = makeTempDir();
writeBundledPluginPackage({
packageRoot,
pluginId: "alpha",
deps: { "alpha-runtime": "1.0.0" },
enabledByDefault: true,
});
writeBundledPluginPackage({
packageRoot,
pluginId: "telegram",
deps: { "telegram-runtime": "2.0.0" },
channels: ["telegram"],
});
return packageRoot;
}
it.each([
{
name: "includes default-enabled bundled plugins",
config: {},
includeConfiguredChannels: false,
expectedDeps: ["alpha-runtime@1.0.0"],
},
{
name: "keeps default-enabled bundled plugins behind restrictive allowlists",
config: { plugins: { allow: ["browser"] } },
includeConfiguredChannels: false,
expectedDeps: [],
},
{
name: "does not let explicit plugin entries bypass restrictive allowlists",
config: { plugins: { allow: ["browser"], entries: { alpha: { enabled: true } } } },
includeConfiguredChannels: false,
expectedDeps: [],
},
{
name: "lets deny override default-enabled bundled plugins",
config: { plugins: { deny: ["alpha"] } },
includeConfiguredChannels: false,
expectedDeps: [],
},
{
name: "lets disabled entries override default-enabled bundled plugins",
config: { plugins: { entries: { alpha: { enabled: false } } } },
includeConfiguredChannels: false,
expectedDeps: [],
},
{
name: "lets explicit bundled channel enablement bypass restrictive allowlists",
config: {
plugins: { allow: ["browser"] },
channels: { telegram: { enabled: true } },
},
includeConfiguredChannels: false,
expectedDeps: ["telegram-runtime@2.0.0"],
},
{
name: "keeps channel recovery behind restrictive allowlists",
config: {
plugins: { allow: ["browser"] },
channels: { telegram: { botToken: "123:abc" } },
},
includeConfiguredChannels: true,
expectedDeps: [],
},
{
name: "includes configured channels during recovery without restrictive allowlists",
config: { channels: { telegram: { botToken: "123:abc" } } },
includeConfiguredChannels: true,
expectedDeps: ["alpha-runtime@1.0.0", "telegram-runtime@2.0.0"],
},
{
name: "lets explicit channel disable override recovery",
config: { channels: { telegram: { botToken: "123:abc", enabled: false } } },
includeConfiguredChannels: true,
expectedDeps: ["alpha-runtime@1.0.0"],
},
])("$name", ({ config, includeConfiguredChannels, expectedDeps }) => {
const result = scanBundledPluginRuntimeDeps({
packageRoot: setupPolicyPackageRoot(),
config,
includeConfiguredChannels,
});
expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual(expectedDeps);
expect(result.conflicts).toEqual([]);
});
it("reads each bundled plugin manifest once per runtime-deps scan", () => {
const packageRoot = makeTempDir();
const pluginRoot = writeBundledPluginPackage({
packageRoot,
pluginId: "alpha",
deps: { "alpha-runtime": "1.0.0" },
enabledByDefault: true,
channels: ["alpha"],
});
const manifestPath = path.join(pluginRoot, "openclaw.plugin.json");
const readFileSyncSpy = vi.spyOn(fs, "readFileSync");
scanBundledPluginRuntimeDeps({ packageRoot, config: {} });
expect(
readFileSyncSpy.mock.calls.filter((call) => path.resolve(String(call[0])) === manifestPath),
).toHaveLength(1);
});
});
describe("ensureBundledPluginRuntimeDeps", () => {
it("installs plugin-local runtime deps when one is missing", () => {
const packageRoot = makeTempDir();

View File

@@ -341,9 +341,13 @@ function removeRuntimeDepsLockIfStale(lockDir: string, nowMs: number): boolean {
}
}
function withBundledRuntimeDepsInstallRootLock<T>(installRoot: string, run: () => T): T {
export function withBundledRuntimeDepsFilesystemLock<T>(
installRoot: string,
lockName: string,
run: () => T,
): T {
fs.mkdirSync(installRoot, { recursive: true });
const lockDir = path.join(installRoot, BUNDLED_RUNTIME_DEPS_LOCK_DIR);
const lockDir = path.join(installRoot, lockName);
const startedAt = Date.now();
let locked = false;
while (!locked) {
@@ -390,6 +394,10 @@ function withBundledRuntimeDepsInstallRootLock<T>(installRoot: string, run: () =
}
}
function withBundledRuntimeDepsInstallRootLock<T>(installRoot: string, run: () => T): T {
return withBundledRuntimeDepsFilesystemLock(installRoot, BUNDLED_RUNTIME_DEPS_LOCK_DIR, run);
}
function collectRuntimeDeps(packageJson: JsonObject): Record<string, unknown> {
return {
...(packageJson.dependencies as Record<string, unknown> | undefined),
@@ -877,17 +885,31 @@ export function resolveBundledRuntimeDepsNpmRunner(params: {
},
};
}
function readBundledPluginChannels(pluginDir: string): string[] {
type BundledPluginRuntimeDepsManifest = {
channels: string[];
enabledByDefault: boolean;
};
type BundledPluginRuntimeDepsManifestCache = Map<string, BundledPluginRuntimeDepsManifest>;
function readBundledPluginRuntimeDepsManifest(
pluginDir: string,
cache?: BundledPluginRuntimeDepsManifestCache,
): BundledPluginRuntimeDepsManifest {
const cached = cache?.get(pluginDir);
if (cached) {
return cached;
}
const manifest = readJsonObject(path.join(pluginDir, "openclaw.plugin.json"));
const channels = manifest?.channels;
if (!Array.isArray(channels)) {
return [];
}
return channels.filter((entry): entry is string => typeof entry === "string" && entry !== "");
}
function readBundledPluginEnabledByDefault(pluginDir: string): boolean {
return readJsonObject(path.join(pluginDir, "openclaw.plugin.json"))?.enabledByDefault === true;
const runtimeDepsManifest = {
channels: Array.isArray(channels)
? channels.filter((entry): entry is string => typeof entry === "string" && entry !== "")
: [],
enabledByDefault: manifest?.enabledByDefault === true,
};
cache?.set(pluginDir, runtimeDepsManifest);
return runtimeDepsManifest;
}
function isBundledPluginConfiguredForRuntimeDeps(params: {
@@ -895,6 +917,7 @@ function isBundledPluginConfiguredForRuntimeDeps(params: {
pluginId: string;
pluginDir: string;
includeConfiguredChannels?: boolean;
manifestCache?: BundledPluginRuntimeDepsManifestCache;
}): boolean {
const plugins = normalizePluginsConfig(params.config.plugins);
if (!plugins.enabled) {
@@ -907,11 +930,10 @@ function isBundledPluginConfiguredForRuntimeDeps(params: {
if (entry?.enabled === false) {
return false;
}
if (entry?.enabled === true) {
return true;
}
let hasExplicitChannelDisable = false;
for (const channelId of readBundledPluginChannels(params.pluginDir)) {
let hasConfiguredChannel = false;
const manifest = readBundledPluginRuntimeDepsManifest(params.pluginDir, params.manifestCache);
for (const channelId of manifest.channels) {
const normalizedChannelId = normalizeOptionalLowercaseString(channelId);
if (!normalizedChannelId) {
continue;
@@ -932,16 +954,32 @@ function isBundledPluginConfiguredForRuntimeDeps(params: {
channelConfig &&
typeof channelConfig === "object" &&
!Array.isArray(channelConfig) &&
(params.includeConfiguredChannels ||
(channelConfig as { enabled?: unknown }).enabled === true)
(channelConfig as { enabled?: unknown }).enabled === true
) {
return true;
}
if (
channelConfig &&
typeof channelConfig === "object" &&
!Array.isArray(channelConfig) &&
params.includeConfiguredChannels
) {
hasConfiguredChannel = true;
}
}
if (hasExplicitChannelDisable) {
return false;
}
return readBundledPluginEnabledByDefault(params.pluginDir);
if (plugins.allow.length > 0 && !plugins.allow.includes(params.pluginId)) {
return false;
}
if (entry?.enabled === true) {
return true;
}
if (hasConfiguredChannel) {
return true;
}
return manifest.enabledByDefault;
}
function shouldIncludeBundledPluginRuntimeDeps(params: {
@@ -950,6 +988,7 @@ function shouldIncludeBundledPluginRuntimeDeps(params: {
pluginId: string;
pluginDir: string;
includeConfiguredChannels?: boolean;
manifestCache?: BundledPluginRuntimeDepsManifestCache;
}): boolean {
if (params.pluginIds && !params.pluginIds.has(params.pluginId)) {
return false;
@@ -962,6 +1001,7 @@ function shouldIncludeBundledPluginRuntimeDeps(params: {
pluginId: params.pluginId,
pluginDir: params.pluginDir,
includeConfiguredChannels: params.includeConfiguredChannels,
manifestCache: params.manifestCache,
});
}
@@ -975,6 +1015,7 @@ function collectBundledPluginRuntimeDeps(params: {
conflicts: RuntimeDepConflict[];
} {
const versionMap = new Map<string, Map<string, Set<string>>>();
const manifestCache: BundledPluginRuntimeDepsManifestCache = new Map();
for (const entry of fs.readdirSync(params.extensionsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
@@ -989,6 +1030,7 @@ function collectBundledPluginRuntimeDeps(params: {
pluginId,
pluginDir,
includeConfiguredChannels: params.includeConfiguredChannels,
manifestCache,
})
) {
continue;

View File

@@ -5,9 +5,11 @@ import {
resolveBundledRuntimeDependencyInstallRoot,
resolveBundledRuntimeDependencyPackageRoot,
registerBundledRuntimeDependencyNodePath,
withBundledRuntimeDepsFilesystemLock,
} from "./bundled-runtime-deps.js";
const bundledRuntimeDepsRetainSpecsByInstallRoot = new Map<string, readonly string[]>();
const BUNDLED_RUNTIME_MIRROR_LOCK_DIR = ".openclaw-runtime-mirror.lock";
export function isBuiltBundledPluginRuntimeRoot(pluginRoot: string): boolean {
const extensionsDir = path.dirname(pluginRoot);
@@ -83,34 +85,40 @@ function mirrorBundledPluginRuntimeRoot(params: {
pluginRoot: string;
installRoot: string;
}): string {
const mirrorParent = prepareBundledPluginRuntimeDistMirror({
installRoot: params.installRoot,
pluginRoot: params.pluginRoot,
});
const mirrorRoot = path.join(mirrorParent, params.pluginId);
fs.mkdirSync(params.installRoot, { recursive: true });
try {
fs.chmodSync(params.installRoot, 0o755);
} catch {
// Best-effort only: staged roots may live on filesystems that reject chmod.
}
fs.mkdirSync(mirrorParent, { recursive: true });
try {
fs.chmodSync(mirrorParent, 0o755);
} catch {
// Best-effort only: the access check below will surface non-writable dirs.
}
fs.accessSync(mirrorParent, fs.constants.W_OK);
const tempDir = fs.mkdtempSync(path.join(mirrorParent, `.plugin-${params.pluginId}-`));
const stagedRoot = path.join(tempDir, "plugin");
try {
copyBundledPluginRuntimeRoot(params.pluginRoot, stagedRoot);
fs.rmSync(mirrorRoot, { recursive: true, force: true });
fs.renameSync(stagedRoot, mirrorRoot);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
return mirrorRoot;
return withBundledRuntimeDepsFilesystemLock(
params.installRoot,
BUNDLED_RUNTIME_MIRROR_LOCK_DIR,
() => {
const mirrorParent = prepareBundledPluginRuntimeDistMirror({
installRoot: params.installRoot,
pluginRoot: params.pluginRoot,
});
const mirrorRoot = path.join(mirrorParent, params.pluginId);
fs.mkdirSync(params.installRoot, { recursive: true });
try {
fs.chmodSync(params.installRoot, 0o755);
} catch {
// Best-effort only: staged roots may live on filesystems that reject chmod.
}
fs.mkdirSync(mirrorParent, { recursive: true });
try {
fs.chmodSync(mirrorParent, 0o755);
} catch {
// Best-effort only: the access check below will surface non-writable dirs.
}
fs.accessSync(mirrorParent, fs.constants.W_OK);
const tempDir = fs.mkdtempSync(path.join(mirrorParent, `.plugin-${params.pluginId}-`));
const stagedRoot = path.join(tempDir, "plugin");
try {
copyBundledPluginRuntimeRoot(params.pluginRoot, stagedRoot);
fs.rmSync(mirrorRoot, { recursive: true, force: true });
fs.renameSync(stagedRoot, mirrorRoot);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
return mirrorRoot;
},
);
}
function prepareBundledPluginRuntimeDistMirror(params: {
@@ -135,6 +143,9 @@ function prepareBundledPluginRuntimeDistMirror(params: {
try {
fs.symlinkSync(sourcePath, targetPath, entry.isDirectory() ? "junction" : "file");
} catch {
if (fs.existsSync(targetPath)) {
continue;
}
if (entry.isDirectory()) {
copyBundledPluginRuntimeRoot(sourcePath, targetPath);
} else if (entry.isFile()) {
@@ -211,17 +222,21 @@ function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void
` defaultExport = defaultExport.default;`,
`}`,
];
const content = [
`export * from ${JSON.stringify(normalizedSpecifier)};`,
...defaultForwarder,
"export { defaultExport as default };",
"",
].join("\n");
try {
if (fs.readFileSync(targetPath, "utf8") === content) {
return;
}
} catch {
// Missing or unreadable wrapper; rewrite below.
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(
targetPath,
[
`export * from ${JSON.stringify(normalizedSpecifier)};`,
...defaultForwarder,
"export { defaultExport as default };",
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(targetPath, content, "utf8");
}
function ensureOpenClawPluginSdkAlias(distRoot: string): void {
@@ -240,7 +255,14 @@ function ensureOpenClawPluginSdkAlias(distRoot: string): void {
"./plugin-sdk/*": "./plugin-sdk/*.js",
},
});
fs.rmSync(pluginSdkAliasDir, { recursive: true, force: true });
try {
if (fs.existsSync(pluginSdkAliasDir) && !fs.lstatSync(pluginSdkAliasDir).isDirectory()) {
fs.rmSync(pluginSdkAliasDir, { recursive: true, force: true });
}
} catch {
// Another process may be creating the alias at the same time; mkdir/write
// below will either converge or surface the real filesystem error.
}
fs.mkdirSync(pluginSdkAliasDir, { recursive: true });
for (const entry of fs.readdirSync(pluginSdkDir, { withFileTypes: true })) {
if (!entry.isFile() || path.extname(entry.name) !== ".js") {

View File

@@ -0,0 +1,109 @@
import fs from "node:fs";
import path from "node:path";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js";
function decodeMountInfoPath(value: string): string {
return value.replace(/\\([0-7]{3})/g, (_match, octal: string) =>
String.fromCharCode(Number.parseInt(octal, 8)),
);
}
export function parseLinuxMountInfoMountPoints(mountInfo: string): Set<string> {
const mountPoints = new Set<string>();
for (const line of mountInfo.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const fields = trimmed.split(" ");
const mountPoint = fields[4];
if (!mountPoint) {
continue;
}
mountPoints.add(path.resolve(decodeMountInfoPath(mountPoint)));
}
return mountPoints;
}
function readLinuxMountPoints(): Set<string> {
try {
return parseLinuxMountInfoMountPoints(fs.readFileSync("/proc/self/mountinfo", "utf8"));
} catch {
return new Set();
}
}
function isFilesystemMountPoint(targetPath: string): boolean {
try {
const target = fs.statSync(targetPath);
const parent = fs.statSync(path.dirname(targetPath));
return target.dev !== parent.dev || target.ino === parent.ino;
} catch {
return false;
}
}
function sourceOverlaysDisabled(env: NodeJS.ProcessEnv): boolean {
const raw = normalizeOptionalLowercaseString(env.OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS);
return raw === "1" || raw === "true";
}
export function isBundledSourceOverlayPath(params: {
sourcePath: string;
mountPoints?: ReadonlySet<string>;
}): boolean {
const resolved = path.resolve(params.sourcePath);
const mountPoints = params.mountPoints ?? readLinuxMountPoints();
return mountPoints.has(resolved) || isFilesystemMountPoint(resolved);
}
export function listBundledSourceOverlayDirs(params: {
bundledRoot?: string;
env?: NodeJS.ProcessEnv;
mountPoints?: ReadonlySet<string>;
}): string[] {
const env = params.env ?? process.env;
if (sourceOverlaysDisabled(env) || !params.bundledRoot) {
return [];
}
const legacyRoot = buildLegacyBundledRootPath(params.bundledRoot);
if (!legacyRoot || !fs.existsSync(legacyRoot)) {
return [];
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(legacyRoot, { withFileTypes: true });
} catch {
return [];
}
const mountPoints = params.mountPoints ?? readLinuxMountPoints();
const legacyRootMounted = isBundledSourceOverlayPath({
sourcePath: legacyRoot,
mountPoints,
});
const overlayDirs: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const sourceDir = path.join(legacyRoot, entry.name);
const bundledPeer = path.join(params.bundledRoot, entry.name);
if (!fs.existsSync(bundledPeer)) {
continue;
}
if (
!legacyRootMounted &&
!isBundledSourceOverlayPath({
sourcePath: sourceDir,
mountPoints,
})
) {
continue;
}
overlayDirs.push(sourceDir);
}
return overlayDirs.toSorted((left, right) => left.localeCompare(right));
}

View File

@@ -419,26 +419,26 @@ describe("resolveGatewayStartupPluginIds", () => {
enabledPluginIds: ["voice-call"],
modelId: "demo-cli/demo-model",
}),
["demo-channel", "browser", "voice-call"],
["demo-channel", "browser", "voice-call", "memory-core"],
],
[
"keeps bundled startup sidecars with enabledByDefault at idle startup",
{} as OpenClawConfig,
["demo-channel", "browser"],
["demo-channel", "browser", "memory-core"],
],
[
"keeps provider plugins out of idle startup when only provider config references them",
createStartupConfig({
providerIds: ["demo-provider"],
}),
["demo-channel", "browser"],
["demo-channel", "browser", "memory-core"],
],
[
"includes explicitly enabled non-channel sidecars in startup scope",
createStartupConfig({
enabledPluginIds: ["demo-global-sidecar", "voice-call"],
}),
["demo-channel", "browser", "voice-call", "demo-global-sidecar"],
["demo-channel", "browser", "voice-call", "memory-core", "demo-global-sidecar"],
],
[
"keeps default-enabled startup sidecars when a restrictive allowlist permits them",
@@ -453,7 +453,7 @@ describe("resolveGatewayStartupPluginIds", () => {
createStartupConfig({
channelIds: ["demo-channel", "demo-other-channel"],
}),
["demo-channel", "demo-other-channel", "browser"],
["demo-channel", "demo-other-channel", "browser", "memory-core"],
],
] as const)("%s", (_name, config, expected) => {
expectStartupPluginIdsCase({ config, expected });
@@ -501,7 +501,7 @@ describe("resolveGatewayStartupPluginIds", () => {
env: {
DEMO_CHANNEL_ANYTHING: "1",
} as NodeJS.ProcessEnv,
expected: ["demo-channel", "browser"],
expected: ["demo-channel", "browser", "memory-core"],
});
expect(
resolveConfiguredDeferredChannelPluginIds({
@@ -564,7 +564,7 @@ describe("resolveGatewayStartupPluginIds", () => {
},
} as OpenClawConfig,
env: {},
expected: ["browser"],
expected: ["browser", "memory-core"],
});
});
@@ -582,7 +582,7 @@ describe("resolveGatewayStartupPluginIds", () => {
env: {
OPENCLAW_STATE_DIR: "/tmp/openclaw-with-persisted-demo-channel",
} as NodeJS.ProcessEnv,
expected: ["browser"],
expected: ["browser", "memory-core"],
});
});
@@ -657,12 +657,22 @@ describe("resolveGatewayStartupPluginIds", () => {
});
});
it("includes the default memory slot plugin when the allowlist permits it", () => {
expectStartupPluginIdsCase({
config: createStartupConfig({
allowPluginIds: ["browser", "memory-core"],
noConfiguredChannels: true,
}),
expected: ["browser", "memory-core"],
});
});
it("does not include non-selected memory plugins only because they are enabled", () => {
expectStartupPluginIdsCase({
config: createStartupConfig({
enabledPluginIds: ["memory-lancedb"],
}),
expected: ["demo-channel", "browser"],
expected: ["demo-channel", "browser", "memory-core"],
});
});
@@ -672,7 +682,7 @@ describe("resolveGatewayStartupPluginIds", () => {
agentRuntimeId: "codex",
enabledPluginIds: ["codex"],
}),
expected: ["demo-channel", "browser", "codex"],
expected: ["demo-channel", "browser", "codex", "memory-core"],
});
});
@@ -682,7 +692,7 @@ describe("resolveGatewayStartupPluginIds", () => {
agentRuntimeIds: ["codex"],
enabledPluginIds: ["codex"],
}),
expected: ["demo-channel", "browser", "codex"],
expected: ["demo-channel", "browser", "codex", "memory-core"],
});
});
@@ -692,7 +702,7 @@ describe("resolveGatewayStartupPluginIds", () => {
enabledPluginIds: ["codex"],
}),
env: { OPENCLAW_AGENT_RUNTIME: "codex" },
expected: ["demo-channel", "browser", "codex"],
expected: ["demo-channel", "browser", "codex", "memory-core"],
});
});
@@ -702,7 +712,7 @@ describe("resolveGatewayStartupPluginIds", () => {
agentRuntimeId: "demo-cli",
enabledPluginIds: ["demo-provider-plugin"],
}),
expected: ["demo-channel", "browser", "demo-provider-plugin"],
expected: ["demo-channel", "browser", "demo-provider-plugin", "memory-core"],
});
});
@@ -715,7 +725,7 @@ describe("resolveGatewayStartupPluginIds", () => {
config: createStartupConfig({
agentRuntimeId: runtime,
}),
expected: ["demo-channel", "browser", pluginId],
expected: ["demo-channel", "browser", pluginId, "memory-core"],
});
});
@@ -738,7 +748,7 @@ describe("resolveGatewayStartupPluginIds", () => {
},
},
} as OpenClawConfig,
expected: ["demo-channel", "browser"],
expected: ["demo-channel", "browser", "memory-core"],
});
});
@@ -761,7 +771,7 @@ describe("resolveGatewayStartupPluginIds", () => {
},
},
} as OpenClawConfig,
expected: ["demo-channel", "browser"],
expected: ["demo-channel", "browser", "memory-core"],
});
});
});

View File

@@ -594,6 +594,7 @@ async function resolveCompatiblePackageVersion(params: {
requestedVersion?: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
}): Promise<
| {
ok: true;
@@ -617,6 +618,7 @@ async function resolveCompatiblePackageVersion(params: {
version: requestedVersion,
baseUrl: params.baseUrl,
token: params.token,
timeoutMs: params.timeoutMs,
});
} catch (error) {
return mapClawHubRequestError(error, {
@@ -747,6 +749,7 @@ export async function installPluginFromClawHub(
logger?: PluginInstallLogger;
mode?: "install" | "update";
extensionsDir?: string;
timeoutMs?: number;
dryRun?: boolean;
expectedPluginId?: string;
},
@@ -775,6 +778,7 @@ export async function installPluginFromClawHub(
name: parsed.name,
baseUrl: params.baseUrl,
token: params.token,
timeoutMs: params.timeoutMs,
});
} catch (error) {
return mapClawHubRequestError(error, {
@@ -787,6 +791,7 @@ export async function installPluginFromClawHub(
requestedVersion: parsed.version,
baseUrl: params.baseUrl,
token: params.token,
timeoutMs: params.timeoutMs,
});
if (!versionState.ok) {
return versionState;
@@ -821,6 +826,7 @@ export async function installPluginFromClawHub(
version: versionState.version,
baseUrl: params.baseUrl,
token: params.token,
timeoutMs: params.timeoutMs,
});
} catch (error) {
return buildClawHubInstallFailure(formatErrorMessage(error));
@@ -864,6 +870,7 @@ export async function installPluginFromClawHub(
logger: params.logger,
mode: params.mode,
extensionsDir: params.extensionsDir,
timeoutMs: params.timeoutMs,
dryRun: params.dryRun,
expectedPluginId: params.expectedPluginId,
});

View File

@@ -1,3 +1,4 @@
import fs from "node:fs";
import { describe, expect, it } from "vitest";
import {
getPluginCompatRecord,
@@ -8,6 +9,99 @@ import {
const datePattern = /^\d{4}-\d{2}-\d{2}$/u;
const knownDeprecatedSurfaceMarkers = [
{
code: "legacy-extension-api-import",
file: "src/extensionAPI.ts",
marker: "openclaw/extension-api is deprecated",
},
{
code: "memory-split-registration",
file: "src/plugins/memory-state.ts",
marker: "registerMemoryPromptSection",
},
{
code: "provider-static-capabilities-bag",
file: "src/plugins/types.ts",
marker: "Legacy static provider capability bag",
},
{
code: "provider-discovery-type-aliases",
file: "src/plugins/types.ts",
marker: "ProviderPluginDiscovery = ProviderPluginCatalog",
},
{
code: "provider-thinking-policy-hooks",
file: "src/plugins/types.ts",
marker: "Prefer `resolveThinkingProfile`",
},
{
code: "provider-external-oauth-profiles-hook",
file: "src/plugins/types.ts",
marker: "resolveExternalOAuthProfiles",
},
{
code: "agent-tool-result-harness-alias",
file: "src/plugins/agent-tool-result-middleware-types.ts",
marker: "AgentToolResultMiddlewareHarness",
},
{
code: "runtime-taskflow-legacy-alias",
file: "src/plugins/runtime/types-core.ts",
marker: "taskFlow",
},
{
code: "runtime-subagent-get-session-alias",
file: "src/plugins/runtime/types.ts",
marker: "getSessionMessages",
},
{
code: "runtime-stt-alias",
file: "src/plugins/runtime/types-core.ts",
marker: "stt",
},
{
code: "runtime-inbound-envelope-alias",
file: "src/plugins/runtime/types-channel.ts",
marker: "formatInboundEnvelope",
},
{
code: "channel-native-message-schema-helpers",
file: "src/plugin-sdk/channel-actions.ts",
marker: "createMessageToolButtonsSchema",
},
{
code: "channel-mention-gating-legacy-helpers",
file: "src/plugin-sdk/channel-inbound.ts",
marker: "resolveMentionGatingWithBypass",
},
{
code: "provider-web-search-core-wrapper",
file: "src/plugin-sdk/provider-web-search.ts",
marker: "createPluginBackedWebSearchProvider",
},
{
code: "approval-capability-approvals-alias",
file: "src/plugin-sdk/approval-delivery-helpers.ts",
marker: "approvals?: Partial<ChannelApprovalCapabilitySurfaces>",
},
{
code: "plugin-sdk-test-utils-alias",
file: "src/plugin-sdk/test-utils.ts",
marker: "Deprecated compatibility alias",
},
] as const;
function parseDate(date: string): Date {
return new Date(`${date}T00:00:00Z`);
}
function addUtcMonths(date: Date, months: number): Date {
const next = new Date(date);
next.setUTCMonth(next.getUTCMonth() + months);
return next;
}
describe("plugin compatibility registry", () => {
it("keeps compatibility codes unique and lookup-safe", () => {
const records = listPluginCompatRecords();
@@ -23,6 +117,13 @@ describe("plugin compatibility registry", () => {
for (const record of listDeprecatedPluginCompatRecords()) {
expect(record.deprecated, record.code).toMatch(datePattern);
expect(record.warningStarts, record.code).toMatch(datePattern);
expect(record.removeAfter, record.code).toMatch(datePattern);
if (!record.warningStarts || !record.removeAfter) {
throw new Error(`${record.code} is missing deprecation window dates`);
}
const maxRemoveAfter = addUtcMonths(parseDate(record.warningStarts), 3);
const removeAfter = parseDate(record.removeAfter);
expect(removeAfter <= maxRemoveAfter, record.code).toBe(true);
expect(record.replacement, record.code).toBeTruthy();
expect(record.docsPath, record.code).toMatch(/^\//u);
}
@@ -35,6 +136,16 @@ describe("plugin compatibility registry", () => {
expect(record.surfaces.length, record.code).toBeGreaterThan(0);
expect(record.diagnostics.length, record.code).toBeGreaterThan(0);
expect(record.tests.length, record.code).toBeGreaterThan(0);
for (const testPath of record.tests) {
expect(fs.existsSync(testPath), `${record.code}: ${testPath}`).toBe(true);
}
}
});
it("tracks known plugin-facing deprecated surfaces", () => {
for (const surface of knownDeprecatedSurfaceMarkers) {
expect(isPluginCompatCode(surface.code), surface.code).toBe(true);
expect(fs.readFileSync(surface.file, "utf8"), surface.file).toContain(surface.marker);
}
});
});

View File

@@ -1,5 +1,11 @@
import type { PluginCompatRecord } from "./types.js";
const CHANNEL_RUNTIME_SDK_SURFACE = ["openclaw/plugin-sdk/channel", "runtime"].join("-");
const LEGACY_CONFIG_MIGRATE_TEST_PATH = [
"src/commands/doctor/shared/legacy-config",
"migrate.test.ts",
].join("-");
export const PLUGIN_COMPAT_RECORDS = [
{
code: "legacy-before-agent-start",
@@ -8,6 +14,7 @@ export const PLUGIN_COMPAT_RECORDS = [
introduced: "2026-04-24",
deprecated: "2026-04-24",
warningStarts: "2026-04-24",
removeAfter: "2026-07-24",
replacement: "`before_model_resolve` and `before_prompt_build` hooks",
docsPath: "/plugins/sdk-migration",
surfaces: ["plugin hooks", "plugins inspect", "status diagnostics"],
@@ -34,6 +41,7 @@ export const PLUGIN_COMPAT_RECORDS = [
introduced: "2026-04-24",
deprecated: "2026-04-24",
warningStarts: "2026-04-24",
removeAfter: "2026-07-24",
replacement: "focused `openclaw/plugin-sdk/<subpath>` imports",
docsPath: "/plugins/sdk-migration",
surfaces: ["openclaw/plugin-sdk", "openclaw/plugin-sdk/compat"],
@@ -83,6 +91,7 @@ export const PLUGIN_COMPAT_RECORDS = [
introduced: "2026-04-24",
deprecated: "2026-04-24",
warningStarts: "2026-04-24",
removeAfter: "2026-07-24",
replacement: "`setup.providers[].envVars` and `providerAuthChoices`",
docsPath: "/plugins/manifest",
surfaces: ["openclaw.plugin.json providerAuthEnvVars", "provider setup"],
@@ -96,6 +105,7 @@ export const PLUGIN_COMPAT_RECORDS = [
introduced: "2026-04-24",
deprecated: "2026-04-24",
warningStarts: "2026-04-24",
removeAfter: "2026-07-24",
replacement: "`channelConfigs.<id>.schema` and setup descriptors",
docsPath: "/plugins/manifest",
surfaces: ["openclaw.plugin.json channelEnvVars", "channel setup"],
@@ -105,6 +115,18 @@ export const PLUGIN_COMPAT_RECORDS = [
"src/channels/plugins/setup-group-access.test.ts",
],
},
{
code: "activation-agent-harness-hint",
status: "active",
owner: "plugin-execution",
introduced: "2026-04-24",
replacement:
"top-level `cliBackends[]` for CLI aliases and future `agentRuntime` ownership metadata",
docsPath: "/plugins/manifest",
surfaces: ["activation.onAgentHarnesses", "activation planner"],
diagnostics: ["activation plan compat reason"],
tests: ["src/plugins/activation-planner.test.ts"],
},
{
code: "activation-provider-hint",
status: "active",
@@ -167,11 +189,12 @@ export const PLUGIN_COMPAT_RECORDS = [
introduced: "2026-04-24",
deprecated: "2026-04-25",
warningStarts: "2026-04-25",
removeAfter: "2026-07-25",
replacement: "`agentRuntime` config naming",
docsPath: "/plugins/sdk-agent-harness",
surfaces: ["agents.defaults.embeddedHarness", "model/provider runtime selection"],
diagnostics: ["agent runtime config compatibility"],
tests: ["src/agents/config.test.ts", "src/agents/runtime-selection.test.ts"],
tests: [LEGACY_CONFIG_MIGRATE_TEST_PATH],
},
{
code: "agent-harness-sdk-alias",
@@ -180,6 +203,7 @@ export const PLUGIN_COMPAT_RECORDS = [
introduced: "2026-04-24",
deprecated: "2026-04-25",
warningStarts: "2026-04-25",
removeAfter: "2026-07-25",
replacement: "`openclaw/plugin-sdk/agent-runtime`",
docsPath: "/plugins/sdk-agent-harness",
surfaces: ["openclaw/plugin-sdk/agent-harness", "openclaw/plugin-sdk/agent-harness-runtime"],
@@ -193,6 +217,7 @@ export const PLUGIN_COMPAT_RECORDS = [
introduced: "2026-04-24",
deprecated: "2026-04-25",
warningStarts: "2026-04-25",
removeAfter: "2026-07-25",
replacement: "`agentRuntime` ids and policy metadata",
docsPath: "/plugins/sdk-agent-harness",
surfaces: ["manifest/catalog execution policy", "runtime selection"],
@@ -217,12 +242,392 @@ export const PLUGIN_COMPAT_RECORDS = [
introduced: "2026-04-25",
deprecated: "2026-04-25",
warningStarts: "2026-04-25",
removeAfter: "2026-07-25",
replacement: "`openclaw plugins registry --refresh` and `openclaw doctor --fix`",
docsPath: "/cli/plugins#registry",
surfaces: ["OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY", "plugin registry reads"],
diagnostics: ["persisted-registry-disabled"],
tests: ["src/plugins/plugin-registry.test.ts"],
},
{
code: "plugin-registry-install-migration-env",
status: "deprecated",
owner: "config",
introduced: "2026-04-25",
deprecated: "2026-04-25",
warningStarts: "2026-04-25",
removeAfter: "2026-07-25",
replacement: "`openclaw plugins registry --refresh` and `openclaw doctor --fix`",
docsPath: "/cli/plugins#registry",
surfaces: [
"OPENCLAW_DISABLE_PLUGIN_REGISTRY_MIGRATION",
"OPENCLAW_FORCE_PLUGIN_REGISTRY_MIGRATION",
"package postinstall plugin registry migration",
],
diagnostics: ["postinstall migration skip", "postinstall migration force deprecation warning"],
tests: ["src/commands/doctor/shared/plugin-registry-migration.test.ts"],
},
{
code: "plugin-activate-entrypoint-alias",
status: "deprecated",
owner: "sdk",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`register(api)` plugin entrypoint",
docsPath: "/plugins/sdk-entrypoints",
surfaces: ["plugin module `activate(api)`", "plugin loader registration"],
diagnostics: ["loader compatibility path"],
tests: ["src/plugins/loader.test.ts"],
},
{
code: "setup-runtime-fallback",
status: "active",
owner: "setup",
introduced: "2026-04-24",
replacement: "`setup.requiresRuntime: false` with complete setup descriptors",
docsPath: "/plugins/manifest#setup-reference",
surfaces: ["setup-api runtime fallback", "setup.requiresRuntime omitted"],
diagnostics: ["setup registry runtime diagnostic"],
tests: ["src/plugins/setup-registry.test.ts", "src/plugins/setup-registry.runtime.test.ts"],
},
{
code: "provider-discovery-hook-alias",
status: "deprecated",
owner: "provider",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`catalog.run(...)` provider catalog hook",
docsPath: "/plugins/sdk-migration",
surfaces: ["provider plugin `discovery` hook", "provider catalog resolution"],
diagnostics: ["provider validation warning when catalog and discovery both register"],
tests: ["src/plugins/provider-discovery.test.ts", "src/plugins/provider-validation.test.ts"],
},
{
code: "channel-exposure-legacy-aliases",
status: "deprecated",
owner: "channel",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`openclaw.channel.exposure` metadata",
docsPath: "/plugins/sdk-setup",
surfaces: ["openclaw.channel.showConfigured", "openclaw.channel.showInSetup"],
diagnostics: ["channel exposure compatibility path"],
tests: ["src/commands/channel-setup/discovery.test.ts"],
},
{
code: "channel-runtime-sdk-alias",
status: "deprecated",
owner: "sdk",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement:
"focused channel SDK subpaths, especially `openclaw/plugin-sdk/channel-runtime-context`",
docsPath: "/plugins/sdk-migration",
surfaces: [CHANNEL_RUNTIME_SDK_SURFACE],
diagnostics: ["plugin SDK compatibility warning"],
tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"],
},
{
code: "command-auth-status-builders",
status: "deprecated",
owner: "sdk",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`openclaw/plugin-sdk/command-status`",
docsPath: "/plugins/sdk-migration",
surfaces: [
"openclaw/plugin-sdk/command-auth buildCommandsMessage",
"openclaw/plugin-sdk/command-auth buildCommandsMessagePaginated",
"openclaw/plugin-sdk/command-auth buildHelpMessage",
],
diagnostics: ["plugin SDK compatibility warning"],
tests: ["src/plugin-sdk/command-auth.test.ts"],
},
{
code: "clawdbot-config-type-alias",
status: "deprecated",
owner: "sdk",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`OpenClawConfig`",
docsPath: "/plugins/sdk-migration",
surfaces: ["openclaw/plugin-sdk `ClawdbotConfig` type export"],
diagnostics: ["plugin SDK compatibility warning"],
tests: ["src/plugins/contracts/plugin-sdk-index.test.ts"],
},
{
code: "legacy-extension-api-import",
status: "deprecated",
owner: "sdk",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement:
"injected `api.runtime.*` helpers or focused `openclaw/plugin-sdk/<subpath>` imports",
docsPath: "/plugins/sdk-migration",
surfaces: ["openclaw/extension-api"],
diagnostics: ["OPENCLAW_EXTENSION_API_DEPRECATED"],
tests: ["src/plugins/sdk-alias.test.ts", "src/index.test.ts"],
},
{
code: "memory-split-registration",
status: "deprecated",
owner: "sdk",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`api.registerMemoryCapability({ promptBuilder, flushPlanResolver, runtime })`",
docsPath: "/plugins/sdk-migration",
surfaces: [
"api.registerMemoryPromptSection",
"api.registerMemoryFlushPlan",
"api.registerMemoryRuntime",
"src/plugins/memory-state split registration helpers",
],
diagnostics: ["plugin SDK compatibility warning"],
tests: ["src/plugins/memory-state.test.ts", "src/plugins/loader.test.ts"],
},
{
code: "provider-static-capabilities-bag",
status: "deprecated",
owner: "provider",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement:
"explicit provider hooks such as `buildReplayPolicy`, `normalizeToolSchemas`, and `wrapStreamFn`",
docsPath: "/plugins/sdk-provider-plugins",
surfaces: ["ProviderPlugin.capabilities", "ProviderCapabilities"],
diagnostics: ["provider validation warning"],
tests: [
"src/plugins/provider-runtime.test.ts",
"src/plugins/contracts/provider-family-plugin-tests.test.ts",
],
},
{
code: "provider-discovery-type-aliases",
status: "deprecated",
owner: "provider",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement:
"`ProviderCatalogOrder`, `ProviderCatalogContext`, `ProviderCatalogResult`, and `ProviderPluginCatalog`",
docsPath: "/plugins/sdk-migration",
surfaces: [
"ProviderDiscoveryOrder",
"ProviderDiscoveryContext",
"ProviderDiscoveryResult",
"ProviderPluginDiscovery",
],
diagnostics: ["plugin SDK compatibility warning"],
tests: ["src/plugins/contracts/plugin-sdk-index.test.ts"],
},
{
code: "provider-thinking-policy-hooks",
status: "deprecated",
owner: "provider",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`resolveThinkingProfile`",
docsPath: "/plugins/sdk-provider-plugins",
surfaces: [
"ProviderPlugin.isBinaryThinking",
"ProviderPlugin.supportsXHighThinking",
"ProviderPlugin.resolveDefaultThinkingLevel",
],
diagnostics: ["provider runtime compatibility warning"],
tests: ["src/plugins/provider-runtime.test.ts"],
},
{
code: "provider-external-oauth-profiles-hook",
status: "deprecated",
owner: "provider",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`contracts.externalAuthProviders` plus `resolveExternalAuthProfiles`",
docsPath: "/plugins/sdk-provider-plugins",
surfaces: ["ProviderPlugin.resolveExternalOAuthProfiles"],
diagnostics: ["provider external auth fallback warning"],
tests: ["src/plugins/provider-runtime.test.ts"],
},
{
code: "agent-tool-result-harness-alias",
status: "deprecated",
owner: "agent-runtime",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`runtime` and `runtimes` agent tool-result middleware fields",
docsPath: "/plugins/sdk-agent-harness",
surfaces: [
"AgentToolResultMiddlewareHarness",
"AgentToolResultMiddlewareContext.harness",
"AgentToolResultMiddlewareOptions.harnesses",
"normalizeAgentToolResultMiddlewareHarnesses",
],
diagnostics: ["agent runtime compatibility warning"],
tests: [
"src/plugins/captured-registration.test.ts",
"src/agents/codex-app-server.extensions.test.ts",
],
},
{
code: "runtime-taskflow-legacy-alias",
status: "deprecated",
owner: "sdk",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`api.runtime.tasks.flows`",
docsPath: "/plugins/sdk-runtime",
surfaces: ["api.runtime.taskFlow", "api.runtime.tasks.flow"],
diagnostics: ["plugin runtime compatibility warning"],
tests: ["src/plugins/runtime/index.test.ts", "src/plugins/runtime/runtime-tasks.test.ts"],
},
{
code: "runtime-subagent-get-session-alias",
status: "deprecated",
owner: "sdk",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`api.runtime.subagent.getSessionMessages`",
docsPath: "/plugins/sdk-runtime",
surfaces: ["api.runtime.subagent.getSession"],
diagnostics: ["plugin runtime compatibility warning"],
tests: ["src/plugins/runtime/index.test.ts"],
},
{
code: "runtime-stt-alias",
status: "deprecated",
owner: "sdk",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`api.runtime.mediaUnderstanding.transcribeAudioFile`",
docsPath: "/plugins/sdk-runtime",
surfaces: ["api.runtime.stt.transcribeAudioFile"],
diagnostics: ["plugin runtime compatibility warning"],
tests: ["src/plugins/runtime/index.test.ts"],
},
{
code: "runtime-inbound-envelope-alias",
status: "deprecated",
owner: "channel",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`BodyForAgent` plus structured user-context blocks",
docsPath: "/plugins/sdk-runtime",
surfaces: ["api.runtime.channel.reply.formatInboundEnvelope"],
diagnostics: ["channel runtime compatibility warning"],
tests: ["src/plugins/runtime/index.test.ts"],
},
{
code: "channel-native-message-schema-helpers",
status: "deprecated",
owner: "channel",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "semantic `presentation` capabilities",
docsPath: "/plugins/sdk-migration",
surfaces: [
"openclaw/plugin-sdk/channel-actions createMessageToolButtonsSchema",
"openclaw/plugin-sdk/channel-actions createMessageToolCardSchema",
],
diagnostics: ["plugin SDK compatibility warning"],
tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"],
},
{
code: "channel-mention-gating-legacy-helpers",
status: "deprecated",
owner: "channel",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`resolveInboundMentionDecision({ facts, policy })`",
docsPath: "/plugins/sdk-migration",
surfaces: [
"openclaw/plugin-sdk/channel-inbound resolveMentionGating",
"openclaw/plugin-sdk/channel-inbound resolveMentionGatingWithBypass",
"openclaw/plugin-sdk/channel-mention-gating resolveMentionGating",
"openclaw/plugin-sdk/channel-mention-gating resolveMentionGatingWithBypass",
],
diagnostics: ["plugin SDK compatibility warning"],
tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"],
},
{
code: "provider-web-search-core-wrapper",
status: "deprecated",
owner: "provider",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "provider-owned `createTool(...)` on the returned `WebSearchProviderPlugin`",
docsPath: "/plugins/sdk-provider-plugins",
surfaces: ["openclaw/plugin-sdk/provider-web-search createPluginBackedWebSearchProvider"],
diagnostics: ["plugin SDK compatibility warning"],
tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"],
},
{
code: "approval-capability-approvals-alias",
status: "deprecated",
owner: "channel",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement:
"top-level `delivery`, `nativeRuntime`, `render`, and `native` approval capability fields",
docsPath: "/plugins/sdk-channel-plugins",
surfaces: ["createChannelApprovalCapability({ approvals })"],
diagnostics: ["plugin SDK compatibility warning"],
tests: ["src/plugin-sdk/approval-delivery-helpers.test.ts"],
},
{
code: "plugin-sdk-test-utils-alias",
status: "deprecated",
owner: "sdk",
introduced: "2026-04-24",
deprecated: "2026-04-26",
warningStarts: "2026-04-26",
removeAfter: "2026-07-26",
replacement: "`openclaw/plugin-sdk/testing`",
docsPath: "/plugins/sdk-migration",
surfaces: ["openclaw/plugin-sdk/test-utils"],
diagnostics: ["plugin SDK compatibility warning"],
tests: ["src/plugins/contracts/plugin-sdk-subpaths.test.ts"],
},
] as const satisfies readonly PluginCompatRecord[];
export type PluginCompatCode = (typeof PLUGIN_COMPAT_RECORDS)[number]["code"];

View File

@@ -11,6 +11,7 @@ type ResolveProviderPluginChoice =
type RunProviderModelSelectedHook =
typeof import("../../plugins/provider-auth-choice.runtime.js").runProviderModelSelectedHook;
const resolvePluginProvidersMock = vi.hoisted(() => vi.fn<ResolvePluginProviders>(() => []));
const resolvePluginSetupProviderMock = vi.hoisted(() => vi.fn(() => undefined));
const resolveProviderPluginChoiceMock = vi.hoisted(() => vi.fn<ResolveProviderPluginChoice>());
const runProviderModelSelectedHookMock = vi.hoisted(() =>
vi.fn<RunProviderModelSelectedHook>(async () => {}),
@@ -19,6 +20,7 @@ const runAuthMethodMock = vi.hoisted(() => vi.fn(async () => ({ profiles: [] }))
vi.mock("../../plugins/provider-auth-choice.runtime.js", () => ({
resolvePluginProviders: resolvePluginProvidersMock,
resolvePluginSetupProvider: resolvePluginSetupProviderMock,
resolveProviderPluginChoice: resolveProviderPluginChoiceMock,
runProviderModelSelectedHook: runProviderModelSelectedHookMock,
}));

View File

@@ -657,6 +657,7 @@ describe("plugin-sdk subpath exports", () => {
],
pattern: /openclaw\/plugin-sdk\/channel-runtime(?=["'])/u,
exclude: [
"src/plugins/compat/registry.ts",
"src/plugins/sdk-alias.test.ts",
"src/plugins/contracts/plugin-sdk-root-alias.test.ts",
],

View File

@@ -107,6 +107,20 @@ function writeStandalonePlugin(filePath: string, source = "export default functi
fs.writeFileSync(filePath, source, "utf-8");
}
function mockLinuxMountInfo(mountPoints: readonly string[]) {
const originalReadFileSync = fs.readFileSync;
return vi.spyOn(fs, "readFileSync").mockImplementation((filePath, options) => {
if (filePath === "/proc/self/mountinfo") {
return mountPoints
.map(
(mountPoint, index) => `${100 + index} 99 0:${index} / ${mountPoint} rw - tmpfs tmpfs rw`,
)
.join("\n");
}
return originalReadFileSync(filePath, options as never) as never;
});
}
function createPackagePlugin(params: {
packageDir: string;
packageName: string;
@@ -453,6 +467,95 @@ describe("discoverOpenClawPlugins", () => {
]);
});
it("discovers bind-mounted bundled source overlays before packaged dist bundles", () => {
const stateDir = makeTempDir();
const packageRoot = path.join(stateDir, "node_modules", "openclaw");
const bundledRoot = path.join(packageRoot, "dist", "extensions");
const bundledPluginDir = path.join(bundledRoot, "synology-chat");
const sourcePluginDir = path.join(packageRoot, "extensions", "synology-chat");
createPackagePluginWithEntry({
packageDir: bundledPluginDir,
packageName: "@openclaw/synology-chat",
pluginId: "synology-chat",
entryPath: "index.js",
});
createPackagePluginWithEntry({
packageDir: sourcePluginDir,
packageName: "@openclaw/synology-chat",
pluginId: "synology-chat",
});
mockLinuxMountInfo([sourcePluginDir]);
const sourceEntryPath = path.join(sourcePluginDir, "src", "index.ts");
const bundledEntryPath = path.join(bundledPluginDir, "index.js");
const { candidates, diagnostics } = discoverOpenClawPlugins({
env: {
...buildDiscoveryEnv(stateDir),
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
},
});
const synologyCandidates = candidates.filter(
(candidate) => candidate.idHint === "synology-chat",
);
expect(synologyCandidates).toEqual([
expect.objectContaining({
origin: "bundled",
rootDir: fs.realpathSync(sourcePluginDir),
source: fs.realpathSync(sourceEntryPath),
}),
expect.objectContaining({
origin: "bundled",
rootDir: fs.realpathSync(bundledPluginDir),
source: fs.realpathSync(bundledEntryPath),
}),
]);
expect(diagnostics).toEqual([
expect.objectContaining({
level: "warn",
source: sourcePluginDir,
message: expect.stringContaining("bind-mounted bundled plugin source overlay"),
}),
]);
});
it("keeps copied source plugin dirs inert when they are not mounted overlays", () => {
const stateDir = makeTempDir();
const packageRoot = path.join(stateDir, "node_modules", "openclaw");
const bundledRoot = path.join(packageRoot, "dist", "extensions");
const bundledPluginDir = path.join(bundledRoot, "synology-chat");
const sourcePluginDir = path.join(packageRoot, "extensions", "synology-chat");
createPackagePluginWithEntry({
packageDir: bundledPluginDir,
packageName: "@openclaw/synology-chat",
pluginId: "synology-chat",
entryPath: "index.js",
});
createPackagePluginWithEntry({
packageDir: sourcePluginDir,
packageName: "@openclaw/synology-chat",
pluginId: "synology-chat",
});
mockLinuxMountInfo([]);
const bundledEntryPath = path.join(bundledPluginDir, "index.js");
const { candidates, diagnostics } = discoverOpenClawPlugins({
env: {
...buildDiscoveryEnv(stateDir),
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
},
});
expect(candidates.filter((candidate) => candidate.idHint === "synology-chat")).toEqual([
expect.objectContaining({
origin: "bundled",
rootDir: fs.realpathSync(bundledPluginDir),
source: fs.realpathSync(bundledEntryPath),
}),
]);
expect(diagnostics).toEqual([]);
});
it("loads package extension packs", async () => {
const stateDir = makeTempDir();
const globalExt = path.join(stateDir, "extensions", "pack");
@@ -499,6 +602,35 @@ describe("discoverOpenClawPlugins", () => {
);
});
it("rejects package runtimeExtensions that do not match extension entries", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "runtime-mismatch-pack");
mkdirSafe(path.join(pluginDir, "src"));
mkdirSafe(path.join(pluginDir, "dist"));
writePluginPackageManifest({
packageDir: pluginDir,
packageName: "@openclaw/runtime-mismatch-pack",
extensions: ["./src/one.ts", "./src/two.ts"],
runtimeExtensions: ["./dist/one.js"],
});
writePluginEntry(path.join(pluginDir, "src", "one.ts"));
writePluginEntry(path.join(pluginDir, "src", "two.ts"));
writePluginEntry(path.join(pluginDir, "dist", "one.js"));
const result = await discoverWithStateDir(stateDir, {});
expectCandidatePresence(result, { absent: ["runtime-mismatch-pack"] });
expect(
result.diagnostics.some(
(entry) =>
entry.level === "error" &&
entry.message.includes("runtimeExtensions length (1)") &&
entry.message.includes("extensions length (2)"),
),
).toBe(true);
});
it("infers built dist entries for installed TypeScript package plugins", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "built-peer-pack");

View File

@@ -1,7 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { resolveBoundaryPathSync } from "../infra/boundary-path.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -9,6 +8,7 @@ import {
import { resolveUserPath } from "../utils.js";
import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js";
import { resolvePackagedBundledLoadPathAlias } from "./bundled-load-path-aliases.js";
import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js";
import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js";
import {
DEFAULT_PLUGIN_ENTRY_CANDIDATES,
@@ -19,6 +19,10 @@ import {
type OpenClawPackageManifest,
type PackageManifest,
} from "./manifest.js";
import {
resolvePackageRuntimeExtensionSources,
resolvePackageSetupSource,
} from "./package-entry-resolution.js";
import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js";
@@ -554,271 +558,6 @@ function discoverBundleInRoot(params: {
return "added";
}
function resolvePackageEntrySource(params: {
packageDir: string;
entryPath: string;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const source = path.resolve(params.packageDir, params.entryPath);
const rejectHardlinks = params.rejectHardlinks ?? true;
const candidates = [source];
const openCandidate = (absolutePath: string): string | null => {
const opened = openBoundaryFileSync({
absolutePath,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
rejectHardlinks,
});
if (!opened.ok) {
return matchBoundaryFileOpenFailure(opened, {
path: () => null,
io: () => {
params.diagnostics.push({
level: "warn",
message: `extension entry unreadable (I/O error): ${params.entryPath}`,
source: params.sourceLabel,
});
return null;
},
fallback: () => {
params.diagnostics.push({
level: "error",
message: `extension entry escapes package directory: ${params.entryPath}`,
source: params.sourceLabel,
});
return null;
},
});
}
const safeSource = opened.path;
fs.closeSync(opened.fd);
return safeSource;
};
if (!rejectHardlinks) {
const builtCandidate = source.replace(/\.[^.]+$/u, ".js");
if (builtCandidate !== source) {
candidates.push(builtCandidate);
}
}
for (const candidate of new Set(candidates)) {
if (!fs.existsSync(candidate)) {
continue;
}
return openCandidate(candidate);
}
return openCandidate(source);
}
function isTypeScriptPackageEntry(entryPath: string): boolean {
return [".ts", ".mts", ".cts"].includes(normalizeLowercaseStringOrEmpty(path.extname(entryPath)));
}
function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean {
return origin === "config" || origin === "global";
}
function resolveSafePackageEntry(params: {
packageDir: string;
entryPath: string;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): { relativePath: string; existingSource?: string } | null {
const absolutePath = path.resolve(params.packageDir, params.entryPath);
if (fs.existsSync(absolutePath)) {
const existingSource = resolvePackageEntrySource({
packageDir: params.packageDir,
entryPath: params.entryPath,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
if (!existingSource) {
return null;
}
return {
relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/"),
existingSource,
};
}
try {
resolveBoundaryPathSync({
absolutePath,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
});
} catch {
params.diagnostics.push({
level: "error",
message: `extension entry escapes package directory: ${params.entryPath}`,
source: params.sourceLabel,
});
return null;
}
return { relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/") };
}
function listBuiltRuntimeEntryCandidates(entryPath: string): string[] {
if (!isTypeScriptPackageEntry(entryPath)) {
return [];
}
const normalized = entryPath.replace(/\\/g, "/");
const withoutExtension = normalized.replace(/\.[^.]+$/u, "");
const normalizedRelative = normalized.replace(/^\.\//u, "");
const distWithoutExtension = normalizedRelative.startsWith("src/")
? `./dist/${normalizedRelative.slice("src/".length).replace(/\.[^.]+$/u, "")}`
: `./dist/${withoutExtension.replace(/^\.\//u, "")}`;
const withJavaScriptExtensions = (basePath: string) => [
`${basePath}.js`,
`${basePath}.mjs`,
`${basePath}.cjs`,
];
const candidates = [
...withJavaScriptExtensions(distWithoutExtension),
...withJavaScriptExtensions(withoutExtension),
];
return [...new Set(candidates)].filter((candidate) => candidate !== normalized);
}
function resolveExistingPackageEntrySource(params: {
packageDir: string;
entryPath: string;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const source = path.resolve(params.packageDir, params.entryPath);
if (!fs.existsSync(source)) {
return null;
}
return resolvePackageEntrySource(params);
}
function normalizePackageManifestStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean);
}
function resolvePackageRuntimeEntrySource(params: {
packageDir: string;
entryPath: string;
runtimeEntryPath?: string;
origin: PluginOrigin;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const safeEntry = resolveSafePackageEntry({
packageDir: params.packageDir,
entryPath: params.entryPath,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
if (!safeEntry) {
return null;
}
if (params.runtimeEntryPath) {
const runtimeSource = resolvePackageEntrySource({
packageDir: params.packageDir,
entryPath: params.runtimeEntryPath,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
if (runtimeSource) {
return runtimeSource;
}
}
if (shouldInferBuiltRuntimeEntry(params.origin)) {
for (const candidate of listBuiltRuntimeEntryCandidates(safeEntry.relativePath)) {
const runtimeSource = resolveExistingPackageEntrySource({
packageDir: params.packageDir,
entryPath: candidate,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
if (runtimeSource) {
return runtimeSource;
}
}
}
if (safeEntry.existingSource) {
return safeEntry.existingSource;
}
return resolvePackageEntrySource({
packageDir: params.packageDir,
entryPath: params.entryPath,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
}
function resolvePackageSetupSource(params: {
packageDir: string;
manifest: PackageManifest | null;
origin: PluginOrigin;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
const setupEntryPath = normalizeOptionalString(packageManifest?.setupEntry);
if (!setupEntryPath) {
return null;
}
return resolvePackageRuntimeEntrySource({
packageDir: params.packageDir,
entryPath: setupEntryPath,
runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry),
origin: params.origin,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
}
function resolvePackageRuntimeExtensionEntries(params: {
packageDir: string;
manifest: PackageManifest | null;
extensions: readonly string[];
origin: PluginOrigin;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string[] {
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions);
return params.extensions.flatMap((entryPath, index) => {
const source = resolvePackageRuntimeEntrySource({
packageDir: params.packageDir,
entryPath,
runtimeEntryPath:
runtimeExtensions.length === params.extensions.length
? runtimeExtensions[index]
: undefined,
origin: params.origin,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
return source ? [source] : [];
});
}
function discoverInDirectory(params: {
dir: string;
origin: PluginOrigin;
@@ -896,7 +635,7 @@ function discoverInDirectory(params: {
});
if (extensions.length > 0) {
const resolvedRuntimeSources = resolvePackageRuntimeExtensionEntries({
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
packageDir: fullPath,
manifest,
extensions,
@@ -1032,7 +771,7 @@ function discoverFromPath(params: {
});
if (extensions.length > 0) {
const resolvedRuntimeSources = resolvePackageRuntimeExtensionEntries({
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
packageDir: resolved,
manifest,
extensions,
@@ -1197,6 +936,27 @@ export function discoverOpenClawPlugins(params: {
load: () => {
const result = createDiscoveryResult();
const seen = new Set<string>();
for (const sourceOverlayDir of listBundledSourceOverlayDirs({
bundledRoot: roots.stock,
env,
})) {
discoverFromPath({
rawPath: sourceOverlayDir,
origin: "bundled",
ownershipUid: params.ownershipUid,
workspaceDir,
env,
candidates: result.candidates,
diagnostics: result.diagnostics,
seen,
});
result.diagnostics.push({
level: "warn",
source: sourceOverlayDir,
message:
"using bind-mounted bundled plugin source overlay; this source overrides the packaged dist bundle for the same plugin id",
});
}
if (roots.stock) {
discoverInDirectory({
dir: roots.stock,

View File

@@ -65,21 +65,36 @@ function resolveGatewayStartupDreamingPluginIds(config: OpenClawConfig): Set<str
return new Set([DEFAULT_MEMORY_DREAMING_PLUGIN_ID, resolveMemoryDreamingPluginId(config)]);
}
function resolveExplicitMemorySlotStartupPluginId(
config: OpenClawConfig,
normalizePluginId: (pluginId: string) => string,
): string | undefined {
const configuredSlot = config.plugins?.slots?.memory?.trim();
if (!configuredSlot || configuredSlot.toLowerCase() === "none") {
function resolveMemorySlotStartupPluginId(params: {
activationSourceConfig: OpenClawConfig;
activationSourcePlugins: ReturnType<typeof normalizePluginsConfigWithRegistry>;
normalizePluginId: (pluginId: string) => string;
}): string | undefined {
const { activationSourceConfig, activationSourcePlugins, normalizePluginId } = params;
const configuredSlot = activationSourceConfig.plugins?.slots?.memory?.trim();
if (configuredSlot?.toLowerCase() === "none") {
return undefined;
}
if (!configuredSlot) {
const defaultSlot = activationSourcePlugins.slots.memory;
if (typeof defaultSlot !== "string") {
return undefined;
}
if (
activationSourcePlugins.allow.length > 0 &&
!activationSourcePlugins.allow.includes(defaultSlot)
) {
return undefined;
}
return defaultSlot;
}
return normalizePluginId(configuredSlot);
}
function shouldConsiderForGatewayStartup(params: {
plugin: InstalledPluginIndexRecord;
startupDreamingPluginIds: ReadonlySet<string>;
explicitMemorySlotStartupPluginId?: string;
memorySlotStartupPluginId?: string;
}): boolean {
if (isGatewayStartupSidecar(params.plugin)) {
return true;
@@ -90,7 +105,7 @@ function shouldConsiderForGatewayStartup(params: {
if (params.startupDreamingPluginIds.has(params.plugin.pluginId)) {
return true;
}
return params.explicitMemorySlotStartupPluginId === params.plugin.pluginId;
return params.memorySlotStartupPluginId === params.plugin.pluginId;
}
function hasConfiguredStartupChannel(params: {
@@ -246,18 +261,23 @@ export function resolveGatewayStartupPluginIds(params: {
// not the auto-enabled effective snapshot, or configured-only channels can be
// misclassified as explicit enablement.
const activationSourceConfig = params.activationSourceConfig ?? params.config;
const activationSourcePlugins = normalizePluginsConfigWithRegistry(
activationSourceConfig.plugins,
index,
);
const activationSource = {
plugins: normalizePluginsConfigWithRegistry(activationSourceConfig.plugins, index),
plugins: activationSourcePlugins,
rootConfig: activationSourceConfig,
};
const requiredAgentHarnessRuntimes = new Set(
collectConfiguredAgentHarnessRuntimes(activationSourceConfig, params.env),
);
const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config);
const explicitMemorySlotStartupPluginId = resolveExplicitMemorySlotStartupPluginId(
const memorySlotStartupPluginId = resolveMemorySlotStartupPluginId({
activationSourceConfig,
createPluginRegistryIdNormalizer(index),
);
activationSourcePlugins,
normalizePluginId: createPluginRegistryIdNormalizer(index),
});
return index.plugins
.filter((plugin) => {
if (hasConfiguredStartupChannel({ plugin, manifestRegistry, configuredChannelIds })) {
@@ -286,7 +306,7 @@ export function resolveGatewayStartupPluginIds(params: {
!shouldConsiderForGatewayStartup({
plugin,
startupDreamingPluginIds,
explicitMemorySlotStartupPluginId,
memorySlotStartupPluginId,
})
) {
return false;

View File

@@ -0,0 +1,94 @@
import path from "node:path";
import {
resolveSafeInstallDir,
safeDirName,
safePathSegmentHashed,
unscopedPackageName,
} from "../infra/install-safe-path.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
export function safePluginInstallFileName(input: string): string {
return safeDirName(input);
}
export function encodePluginInstallDirName(pluginId: string): string {
const trimmed = pluginId.trim();
if (!trimmed.includes("/")) {
return safeDirName(trimmed);
}
// Scoped plugin ids need a reserved on-disk namespace so they cannot collide
// with valid unscoped ids that happen to match the hashed slug.
return `@${safePathSegmentHashed(trimmed)}`;
}
export function validatePluginId(pluginId: string): string | null {
const trimmed = pluginId.trim();
if (!trimmed) {
return "invalid plugin name: missing";
}
if (trimmed.includes("\\")) {
return "invalid plugin name: path separators not allowed";
}
const segments = trimmed.split("/");
if (segments.some((segment) => !segment)) {
return "invalid plugin name: malformed scope";
}
if (segments.some((segment) => segment === "." || segment === "..")) {
return "invalid plugin name: reserved path segment";
}
if (segments.length === 1) {
if (trimmed.startsWith("@")) {
return "invalid plugin name: scoped ids must use @scope/name format";
}
return null;
}
if (segments.length !== 2) {
return "invalid plugin name: path separators not allowed";
}
if (!segments[0]?.startsWith("@") || segments[0].length < 2) {
return "invalid plugin name: scoped ids must use @scope/name format";
}
return null;
}
export function matchesExpectedPluginId(params: {
expectedPluginId?: string;
pluginId: string;
manifestPluginId?: string;
npmPluginId: string;
}): boolean {
if (!params.expectedPluginId) {
return true;
}
if (params.expectedPluginId === params.pluginId) {
return true;
}
// Backward compatibility: older install records keyed scoped npm packages by
// their unscoped package name. Preserve update-in-place for those records
// unless the package declares an explicit manifest id override.
return (
!params.manifestPluginId &&
params.pluginId === params.npmPluginId &&
params.expectedPluginId === unscopedPackageName(params.npmPluginId)
);
}
export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string {
const extensionsBase = extensionsDir
? resolveUserPath(extensionsDir)
: path.join(CONFIG_DIR, "extensions");
const pluginIdError = validatePluginId(pluginId);
if (pluginIdError) {
throw new Error(pluginIdError);
}
const targetDirResult = resolveSafeInstallDir({
baseDir: extensionsBase,
id: pluginId,
invalidNameMessage: "invalid plugin name: path traversal detected",
nameEncoder: encodePluginInstallDirName,
});
if (!targetDirResult.ok) {
throw new Error(targetDirResult.error);
}
return targetDirResult.path;
}

View File

@@ -790,6 +790,182 @@ describe("installPluginFromArchive", () => {
expect.unreachable("expected install to fail without openclaw.extensions");
});
it("rejects package installs when openclaw.extensions entries escape the package", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "escaping-entry-plugin",
version: "1.0.0",
openclaw: {
extensions: ["../src/index.ts"],
runtimeExtensions: ["./dist/index.js"],
},
}),
);
fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n");
const result = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS);
expect(result.error).toContain("extension entry escapes plugin directory");
}
});
it("rejects package installs when no extension runtime entry exists", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "missing-entry-plugin",
version: "1.0.0",
openclaw: { extensions: ["./dist/index.js"] },
}),
);
const result = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS);
expect(result.error).toContain("extension entry not found");
}
});
it("allows missing TypeScript source entries when an inferred built runtime entry exists", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "inferred-runtime-plugin",
version: "1.0.0",
openclaw: { extensions: ["./src/index.ts"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n");
const result = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.pluginId).toBe("inferred-runtime-plugin");
}
});
it("rejects package installs when runtimeExtensions length does not match extensions", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "runtime-mismatch-plugin",
version: "1.0.0",
openclaw: {
extensions: ["./src/one.ts", "./src/two.ts"],
runtimeExtensions: ["./dist/one.js"],
},
}),
);
fs.writeFileSync(path.join(pluginDir, "dist", "one.js"), "export {};\n");
const result = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS);
expect(result.error).toContain("runtimeExtensions length (1)");
expect(result.error).toContain("extensions length (2)");
}
});
it("rejects package installs when an extension entry is a symlink escape", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
const outsideDir = path.join(path.dirname(pluginDir), "outside-symlink");
const outsideEntry = path.join(outsideDir, "escape.js");
const linkedDir = path.join(pluginDir, "linked");
fs.mkdirSync(outsideDir, { recursive: true });
fs.writeFileSync(outsideEntry, "export {};\n");
try {
fs.symlinkSync(outsideDir, linkedDir, process.platform === "win32" ? "junction" : "dir");
} catch {
return;
}
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "symlink-entry-plugin",
version: "1.0.0",
openclaw: { extensions: ["./linked/escape.js"] },
}),
);
const result = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS);
expect(result.error).toContain("extension entry");
}
});
it("rejects package installs when an extension entry is a hardlinked alias", async () => {
if (process.platform === "win32") {
return;
}
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
const outsideDir = path.join(path.dirname(pluginDir), "outside-hardlink");
const outsideEntry = path.join(outsideDir, "escape.js");
const linkedEntry = path.join(pluginDir, "escape.js");
fs.mkdirSync(outsideDir, { recursive: true });
fs.writeFileSync(outsideEntry, "export {};\n");
try {
fs.linkSync(outsideEntry, linkedEntry);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
return;
}
throw err;
}
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "hardlink-entry-plugin",
version: "1.0.0",
openclaw: { extensions: ["./escape.js"] },
}),
);
const result = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS);
expect(result.error).toContain("boundary checks");
}
});
it("blocks package installs when plugin contains dangerous code patterns", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();

View File

@@ -1,22 +1,25 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
packageNameMatchesId,
resolveSafeInstallDir,
safeDirName,
safePathSegmentHashed,
unscopedPackageName,
} from "../infra/install-safe-path.js";
import { packageNameMatchesId } from "../infra/install-safe-path.js";
import { type NpmIntegrityDrift, type NpmSpecResolution } from "../infra/install-source-utils.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import {
encodePluginInstallDirName,
matchesExpectedPluginId,
safePluginInstallFileName,
validatePluginId,
} from "./install-paths.js";
import type { InstallSecurityScanResult } from "./install-security-scan.js";
import type { InstallSafetyOverrides } from "./install-security-scan.js";
import {
resolvePackageExtensionEntries,
type PackageManifest as PluginPackageManifest,
} from "./manifest.js";
import { validatePackageExtensionEntriesForInstall } from "./package-entry-resolution.js";
export { resolvePluginInstallDir } from "./install-paths.js";
let pluginInstallRuntimePromise: Promise<typeof import("./install.runtime.js")> | undefined;
@@ -54,6 +57,7 @@ export const PLUGIN_INSTALL_ERROR_CODE = {
MISSING_OPENCLAW_EXTENSIONS: "missing_openclaw_extensions",
MISSING_PLUGIN_MANIFEST: "missing_plugin_manifest",
EMPTY_OPENCLAW_EXTENSIONS: "empty_openclaw_extensions",
INVALID_OPENCLAW_EXTENSIONS: "invalid_openclaw_extensions",
NPM_PACKAGE_NOT_FOUND: "npm_package_not_found",
PLUGIN_ID_MISMATCH: "plugin_id_mismatch",
SECURITY_SCAN_BLOCKED: "security_scan_blocked",
@@ -89,71 +93,6 @@ type PluginInstallPolicyRequest = {
};
const defaultLogger: PluginInstallLogger = {};
function safeFileName(input: string): string {
return safeDirName(input);
}
function encodePluginInstallDirName(pluginId: string): string {
const trimmed = pluginId.trim();
if (!trimmed.includes("/")) {
return safeDirName(trimmed);
}
// Scoped plugin ids need a reserved on-disk namespace so they cannot collide
// with valid unscoped ids that happen to match the hashed slug.
return `@${safePathSegmentHashed(trimmed)}`;
}
function validatePluginId(pluginId: string): string | null {
const trimmed = pluginId.trim();
if (!trimmed) {
return "invalid plugin name: missing";
}
if (trimmed.includes("\\")) {
return "invalid plugin name: path separators not allowed";
}
const segments = trimmed.split("/");
if (segments.some((segment) => !segment)) {
return "invalid plugin name: malformed scope";
}
if (segments.some((segment) => segment === "." || segment === "..")) {
return "invalid plugin name: reserved path segment";
}
if (segments.length === 1) {
if (trimmed.startsWith("@")) {
return "invalid plugin name: scoped ids must use @scope/name format";
}
return null;
}
if (segments.length !== 2) {
return "invalid plugin name: path separators not allowed";
}
if (!segments[0]?.startsWith("@") || segments[0].length < 2) {
return "invalid plugin name: scoped ids must use @scope/name format";
}
return null;
}
function matchesExpectedPluginId(params: {
expectedPluginId?: string;
pluginId: string;
manifestPluginId?: string;
npmPluginId: string;
}): boolean {
if (!params.expectedPluginId) {
return true;
}
if (params.expectedPluginId === params.pluginId) {
return true;
}
// Backward compatibility: older install records keyed scoped npm packages by
// their unscoped package name. Preserve update-in-place for those records
// unless the package declares an explicit manifest id override.
return (
!params.manifestPluginId &&
params.pluginId === params.npmPluginId &&
params.expectedPluginId === unscopedPackageName(params.npmPluginId)
);
}
function ensureOpenClawExtensions(params: { manifest: PackageManifest }):
| {
@@ -442,26 +381,6 @@ async function installPluginDirectoryIntoExtensions(params: {
});
}
export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string {
const extensionsBase = extensionsDir
? resolveUserPath(extensionsDir)
: path.join(CONFIG_DIR, "extensions");
const pluginIdError = validatePluginId(pluginId);
if (pluginIdError) {
throw new Error(pluginIdError);
}
const targetDirResult = resolveSafeInstallDir({
baseDir: extensionsBase,
id: pluginId,
invalidNameMessage: "invalid plugin name: path traversal detected",
nameEncoder: encodePluginInstallDirName,
});
if (!targetDirResult.ok) {
throw new Error(targetDirResult.error);
}
return targetDirResult.path;
}
async function resolvePluginInstallTarget(params: {
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
pluginId: string;
@@ -766,6 +685,19 @@ async function installPluginFromPackageDir(
};
}
const extensionValidation = await validatePackageExtensionEntriesForInstall({
packageDir: params.packageDir,
extensions,
manifest,
});
if (!extensionValidation.ok) {
return {
ok: false,
error: extensionValidation.error,
code: PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS,
};
}
const targetResult = await resolvePreparedDirectoryInstallTarget({
runtime,
pluginId,
@@ -819,18 +751,6 @@ async function installPluginFromPackageDir(
hasDeps: Object.keys(deps).length > 0,
depsLogMessage: "Installing plugin dependencies…",
nameEncoder: encodePluginInstallDirName,
afterCopy: async (installedDir) => {
for (const entry of extensions) {
const resolvedEntry = path.resolve(installedDir, entry);
if (!runtime.isPathInside(installedDir, resolvedEntry)) {
logger.warn?.(`extension entry escapes plugin directory: ${entry}`);
continue;
}
if (!(await runtime.fileExists(resolvedEntry))) {
logger.warn?.(`extension entry not found: ${entry}`);
}
}
},
afterInstall: async (installedDir) => {
// Run the dependency-tree security scan BEFORE linking peer deps.
// The scan rejects any node_modules/ symlink whose target resolves
@@ -963,7 +883,10 @@ export async function installPluginFromFile(params: {
if (pluginIdError) {
return { ok: false, error: pluginIdError };
}
const targetFile = path.join(extensionsDir, `${safeFileName(pluginId)}${path.extname(filePath)}`);
const targetFile = path.join(
extensionsDir,
`${safePluginInstallFileName(pluginId)}${path.extname(filePath)}`,
);
const preparedTarget: PreparedInstallTarget = {
targetPath: targetFile,
effectiveMode: await resolveEffectiveInstallMode({

View File

@@ -127,6 +127,7 @@ function createRichPluginFixture(params: { packageVersion?: string } = {}) {
"demo-chat": ["DEMO_CHAT_TOKEN"],
},
activation: {
onAgentHarnesses: ["codex"],
onProviders: ["demo"],
onChannels: ["demo-chat"],
},
@@ -205,6 +206,7 @@ describe("installed plugin index", () => {
},
},
compat: [
"activation-agent-harness-hint",
"activation-channel-hint",
"activation-provider-hint",
"channel-env-vars",

View File

@@ -223,6 +223,9 @@ function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompat
if (record.activation?.onProviders?.length) {
codes.push("activation-provider-hint");
}
if (record.activation?.onAgentHarnesses?.length) {
codes.push("activation-agent-harness-hint");
}
if (record.activation?.onChannels?.length) {
codes.push("activation-channel-hint");
}

View File

@@ -202,4 +202,116 @@ describe("getCachedPluginJitiLoader", () => {
expect(firstAlias?.beta).toBe("/repo/alpha/sub");
expect((firstAlias as Record<symbol, unknown>)[marker]).toBe(true);
});
it("serves compiled .js targets from native require without invoking the jiti loader", async () => {
const jitiLoader = vi.fn();
const createJiti = vi.fn(() => jitiLoader);
vi.doMock("jiti", () => ({ createJiti }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: (p: string) =>
p.endsWith(".js") || p.endsWith(".mjs") || p.endsWith(".cjs"),
tryNativeRequireJavaScriptModule: (target: string) => ({
ok: true,
moduleExport: { loadedFrom: target },
}),
}));
const { getCachedPluginJitiLoader } = await importFreshModule<
typeof import("./jiti-loader-cache.js")
>(import.meta.url, "./jiti-loader-cache.js?scope=native-require-fastpath");
const cache = new Map();
const loader = getCachedPluginJitiLoader({
cache,
modulePath: "/repo/dist/extensions/demo/api.js",
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts",
});
const result = loader("/repo/dist/extensions/demo/api.js") as { loadedFrom: string };
expect(result.loadedFrom).toBe("/repo/dist/extensions/demo/api.js");
// jiti is created eagerly, but its loader must NOT be invoked for .js
// targets that `tryNativeRequireJavaScriptModule` resolves.
expect(jitiLoader).not.toHaveBeenCalled();
});
it("falls back to jiti when the native-require helper declines", async () => {
const jitiLoader = vi.fn(() => ({ fromJiti: true }));
const createJiti = vi.fn(() => jitiLoader);
vi.doMock("jiti", () => ({ createJiti }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
tryNativeRequireJavaScriptModule: () => ({ ok: false }),
}));
const { getCachedPluginJitiLoader } = await importFreshModule<
typeof import("./jiti-loader-cache.js")
>(import.meta.url, "./jiti-loader-cache.js?scope=native-require-fallback");
const cache = new Map();
const loader = getCachedPluginJitiLoader({
cache,
modulePath: "/repo/dist/extensions/demo/api.js",
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts",
});
const result = loader("/repo/dist/extensions/demo/api.js") as { fromJiti: boolean };
expect(result.fromJiti).toBe(true);
expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js");
});
it("skips the native-require fast path when tryNative is explicitly false", async () => {
const jitiLoader = vi.fn(() => ({ fromJiti: true }));
const createJiti = vi.fn(() => jitiLoader);
vi.doMock("jiti", () => ({ createJiti }));
const nativeStub = vi.fn(() => ({ ok: true, moduleExport: { fromNative: true } }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
tryNativeRequireJavaScriptModule: nativeStub,
}));
const { getCachedPluginJitiLoader } = await importFreshModule<
typeof import("./jiti-loader-cache.js")
>(import.meta.url, "./jiti-loader-cache.js?scope=native-require-opt-out");
const cache = new Map();
const loader = getCachedPluginJitiLoader({
cache,
modulePath: "/repo/dist/extensions/demo/api.js",
importerUrl: "file:///repo/src/plugins/bundled-capability-runtime.ts",
jitiFilename: "file:///repo/src/plugins/bundled-capability-runtime.ts",
aliasMap: { "openclaw/plugin-sdk": "/repo/shim.js" },
tryNative: false,
});
const result = loader("/repo/dist/extensions/demo/api.js") as { fromJiti: boolean };
expect(result.fromJiti).toBe(true);
// With tryNative: false the wrapper must route every target through jiti
// so its alias rewrites still apply; native require must not be consulted.
expect(nativeStub).not.toHaveBeenCalled();
expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js");
});
it("forwards extra loader arguments through to the jiti fallback", async () => {
const jitiLoader = vi.fn(() => ({ fromJiti: true }));
const createJiti = vi.fn(() => jitiLoader);
vi.doMock("jiti", () => ({ createJiti }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
tryNativeRequireJavaScriptModule: () => ({ ok: false }),
}));
const { getCachedPluginJitiLoader } = await importFreshModule<
typeof import("./jiti-loader-cache.js")
>(import.meta.url, "./jiti-loader-cache.js?scope=native-require-rest-args");
const cache = new Map();
const loader = getCachedPluginJitiLoader({
cache,
modulePath: "/repo/dist/extensions/demo/api.js",
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts",
});
const loose = loader as unknown as (t: string, ...a: unknown[]) => unknown;
loose("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42);
expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42);
});
});

View File

@@ -1,4 +1,5 @@
import { createJiti } from "jiti";
import { tryNativeRequireJavaScriptModule } from "./native-module-require.js";
import {
buildPluginLoaderJitiOptions,
createPluginLoaderJitiCacheKey,
@@ -74,10 +75,32 @@ export function getCachedPluginJitiLoader(params: {
if (cached) {
return cached;
}
const loader = (params.createLoader ?? createJiti)(jitiFilename, {
const jitiLoader = (params.createLoader ?? createJiti)(jitiFilename, {
...buildPluginLoaderJitiOptions(aliasMap),
tryNative,
});
// When the caller has explicitly opted out of native loading (for example
// `bundled-capability-runtime` in Vitest+dist mode, which depends on
// jiti's alias rewriting to surface a narrow SDK slice), route every
// target through jiti so those alias rewrites still apply.
if (!tryNative) {
params.cache.set(scopedCacheKey, jitiLoader);
return jitiLoader;
}
// Otherwise prefer native require() for already-compiled JS artifacts
// (the bundled plugin public surfaces shipped in dist/). jiti's transform
// pipeline provides no value for output that is already plain JS and adds
// several seconds of per-load overhead on slower hosts. jiti still runs
// for TS / TSX sources and for the small set of require(esm) /
// async-module fallbacks `tryNativeRequireJavaScriptModule` declines to
// handle.
const loader = ((target: string, ...rest: unknown[]) => {
const native = tryNativeRequireJavaScriptModule(target);
if (native.ok) {
return native.moduleExport;
}
return (jitiLoader as (t: string, ...a: unknown[]) => unknown)(target, ...rest);
}) as PluginJitiLoader;
params.cache.set(scopedCacheKey, loader);
return loader;
}

View File

@@ -38,6 +38,7 @@ import {
resolveBundledRuntimeDependencyInstallRoot,
resolveBundledRuntimeDependencyPackageRoot,
registerBundledRuntimeDependencyNodePath,
withBundledRuntimeDepsFilesystemLock,
type BundledRuntimeDepsInstallParams,
} from "./bundled-runtime-deps.js";
import {
@@ -269,6 +270,7 @@ export function clearPluginLoaderCache(): void {
}
const defaultLogger = () => createSubsystemLogger("plugins");
const BUNDLED_RUNTIME_MIRROR_LOCK_DIR = ".openclaw-runtime-mirror.lock";
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return (
@@ -706,34 +708,40 @@ function mirrorBundledPluginRuntimeRoot(params: {
pluginRoot: string;
installRoot: string;
}): string {
const mirrorParent = prepareBundledPluginRuntimeDistMirror({
installRoot: params.installRoot,
pluginRoot: params.pluginRoot,
});
const mirrorRoot = path.join(mirrorParent, params.pluginId);
fs.mkdirSync(params.installRoot, { recursive: true });
try {
fs.chmodSync(params.installRoot, 0o755);
} catch {
// Best-effort only: staged roots may live on filesystems that reject chmod.
}
fs.mkdirSync(mirrorParent, { recursive: true });
try {
fs.chmodSync(mirrorParent, 0o755);
} catch {
// Best-effort only: the access check below will surface non-writable dirs.
}
fs.accessSync(mirrorParent, fs.constants.W_OK);
const tempDir = fs.mkdtempSync(path.join(mirrorParent, `.plugin-${params.pluginId}-`));
const stagedRoot = path.join(tempDir, "plugin");
try {
copyBundledPluginRuntimeRoot(params.pluginRoot, stagedRoot);
fs.rmSync(mirrorRoot, { recursive: true, force: true });
fs.renameSync(stagedRoot, mirrorRoot);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
return mirrorRoot;
return withBundledRuntimeDepsFilesystemLock(
params.installRoot,
BUNDLED_RUNTIME_MIRROR_LOCK_DIR,
() => {
const mirrorParent = prepareBundledPluginRuntimeDistMirror({
installRoot: params.installRoot,
pluginRoot: params.pluginRoot,
});
const mirrorRoot = path.join(mirrorParent, params.pluginId);
fs.mkdirSync(params.installRoot, { recursive: true });
try {
fs.chmodSync(params.installRoot, 0o755);
} catch {
// Best-effort only: staged roots may live on filesystems that reject chmod.
}
fs.mkdirSync(mirrorParent, { recursive: true });
try {
fs.chmodSync(mirrorParent, 0o755);
} catch {
// Best-effort only: the access check below will surface non-writable dirs.
}
fs.accessSync(mirrorParent, fs.constants.W_OK);
const tempDir = fs.mkdtempSync(path.join(mirrorParent, `.plugin-${params.pluginId}-`));
const stagedRoot = path.join(tempDir, "plugin");
try {
copyBundledPluginRuntimeRoot(params.pluginRoot, stagedRoot);
fs.rmSync(mirrorRoot, { recursive: true, force: true });
fs.renameSync(stagedRoot, mirrorRoot);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
return mirrorRoot;
},
);
}
function prepareBundledPluginRuntimeDistMirror(params: {
@@ -759,6 +767,9 @@ function prepareBundledPluginRuntimeDistMirror(params: {
try {
fs.symlinkSync(sourcePath, targetPath, entry.isDirectory() ? "junction" : "file");
} catch {
if (fs.existsSync(targetPath)) {
continue;
}
if (entry.isDirectory()) {
copyBundledPluginRuntimeRoot(sourcePath, targetPath);
} else if (entry.isFile()) {
@@ -853,17 +864,21 @@ function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void
` defaultExport = defaultExport.default;`,
`}`,
];
const content = [
`export * from ${JSON.stringify(normalizedSpecifier)};`,
...defaultForwarder,
"export { defaultExport as default };",
"",
].join("\n");
try {
if (fs.readFileSync(targetPath, "utf8") === content) {
return;
}
} catch {
// Missing or unreadable wrapper; rewrite below.
}
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(
targetPath,
[
`export * from ${JSON.stringify(normalizedSpecifier)};`,
...defaultForwarder,
"export { defaultExport as default };",
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(targetPath, content, "utf8");
}
function ensureOpenClawPluginSdkAlias(distRoot: string): void {

View File

@@ -1156,6 +1156,7 @@ export async function installPluginFromMarketplace(
logger: params.logger,
mode: params.mode,
extensionsDir: params.extensionsDir,
timeoutMs: params.timeoutMs,
dryRun: params.dryRun,
expectedPluginId: params.expectedPluginId,
});

View File

@@ -0,0 +1,412 @@
import fs from "node:fs";
import path from "node:path";
import {
matchBoundaryFileOpenFailure,
openBoundaryFile,
openBoundaryFileSync,
} from "../infra/boundary-file-read.js";
import { resolveBoundaryPath, resolveBoundaryPathSync } from "../infra/boundary-path.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import type { PluginDiagnostic } from "./manifest-types.js";
import { getPackageManifestMetadata, type PackageManifest } from "./manifest.js";
import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
type ExtensionEntryValidation = { ok: true; exists: boolean } | { ok: false; error: string };
type RuntimeExtensionsResolution =
| { ok: true; runtimeExtensions: string[] }
| { ok: false; error: string };
function runtimeExtensionsLengthMismatchMessage(params: {
runtimeExtensionsLength: number;
extensionsLength: number;
}): string {
return (
`package.json openclaw.runtimeExtensions length (${params.runtimeExtensionsLength}) ` +
`must match openclaw.extensions length (${params.extensionsLength})`
);
}
export function normalizePackageManifestStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean);
}
export function resolvePackageRuntimeExtensionEntries(params: {
manifest: PackageManifest | null | undefined;
extensions: readonly string[];
}): RuntimeExtensionsResolution {
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions);
if (runtimeExtensions.length === 0) {
return { ok: true, runtimeExtensions: [] };
}
if (runtimeExtensions.length !== params.extensions.length) {
return {
ok: false,
error: runtimeExtensionsLengthMismatchMessage({
runtimeExtensionsLength: runtimeExtensions.length,
extensionsLength: params.extensions.length,
}),
};
}
return { ok: true, runtimeExtensions };
}
async function validatePackageExtensionEntry(params: {
packageDir: string;
entry: string;
label: string;
requireExisting: boolean;
}): Promise<ExtensionEntryValidation> {
const absolutePath = path.resolve(params.packageDir, params.entry);
try {
const resolved = await resolveBoundaryPath({
absolutePath,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
});
if (!resolved.exists) {
return params.requireExisting
? { ok: false, error: `${params.label} not found: ${params.entry}` }
: { ok: true, exists: false };
}
} catch {
return {
ok: false,
error: `${params.label} escapes plugin directory: ${params.entry}`,
};
}
const opened = await openBoundaryFile({
absolutePath,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
});
if (!opened.ok) {
return matchBoundaryFileOpenFailure(opened, {
path: () => ({ ok: false, error: `${params.label} not found: ${params.entry}` }),
io: () => ({ ok: false, error: `${params.label} unreadable: ${params.entry}` }),
validation: () => ({
ok: false,
error: `${params.label} failed plugin directory boundary checks: ${params.entry}`,
}),
fallback: () => ({
ok: false,
error: `${params.label} failed plugin directory boundary checks: ${params.entry}`,
}),
});
}
fs.closeSync(opened.fd);
return { ok: true, exists: true };
}
export async function validatePackageExtensionEntriesForInstall(params: {
packageDir: string;
extensions: string[];
manifest: PackageManifest;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const runtimeResolution = resolvePackageRuntimeExtensionEntries({
manifest: params.manifest,
extensions: params.extensions,
});
if (!runtimeResolution.ok) {
return runtimeResolution;
}
for (const [index, entry] of params.extensions.entries()) {
const sourceEntry = await validatePackageExtensionEntry({
packageDir: params.packageDir,
entry,
label: "extension entry",
requireExisting: false,
});
if (!sourceEntry.ok) {
return sourceEntry;
}
const runtimeEntry = runtimeResolution.runtimeExtensions[index];
if (runtimeEntry) {
const runtimeResult = await validatePackageExtensionEntry({
packageDir: params.packageDir,
entry: runtimeEntry,
label: "runtime extension entry",
requireExisting: true,
});
if (!runtimeResult.ok) {
return runtimeResult;
}
continue;
}
if (sourceEntry.exists) {
continue;
}
let foundBuiltEntry = false;
for (const builtEntry of listBuiltRuntimeEntryCandidates(entry)) {
const builtResult = await validatePackageExtensionEntry({
packageDir: params.packageDir,
entry: builtEntry,
label: "inferred runtime extension entry",
requireExisting: false,
});
if (!builtResult.ok) {
return builtResult;
}
if (builtResult.exists) {
foundBuiltEntry = true;
break;
}
}
if (!foundBuiltEntry) {
return { ok: false, error: `extension entry not found: ${entry}` };
}
}
return { ok: true };
}
function resolvePackageEntrySource(params: {
packageDir: string;
entryPath: string;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const source = path.resolve(params.packageDir, params.entryPath);
const rejectHardlinks = params.rejectHardlinks ?? true;
const candidates = [source];
const openCandidate = (absolutePath: string): string | null => {
const opened = openBoundaryFileSync({
absolutePath,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
rejectHardlinks,
});
if (!opened.ok) {
return matchBoundaryFileOpenFailure(opened, {
path: () => null,
io: () => {
params.diagnostics.push({
level: "warn",
message: `extension entry unreadable (I/O error): ${params.entryPath}`,
source: params.sourceLabel,
});
return null;
},
fallback: () => {
params.diagnostics.push({
level: "error",
message: `extension entry escapes package directory: ${params.entryPath}`,
source: params.sourceLabel,
});
return null;
},
});
}
const safeSource = opened.path;
fs.closeSync(opened.fd);
return safeSource;
};
if (!rejectHardlinks) {
const builtCandidate = source.replace(/\.[^.]+$/u, ".js");
if (builtCandidate !== source) {
candidates.push(builtCandidate);
}
}
for (const candidate of new Set(candidates)) {
if (!fs.existsSync(candidate)) {
continue;
}
return openCandidate(candidate);
}
return openCandidate(source);
}
function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean {
return origin === "config" || origin === "global";
}
function resolveSafePackageEntry(params: {
packageDir: string;
entryPath: string;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): { relativePath: string; existingSource?: string } | null {
const absolutePath = path.resolve(params.packageDir, params.entryPath);
if (fs.existsSync(absolutePath)) {
const existingSource = resolvePackageEntrySource({
packageDir: params.packageDir,
entryPath: params.entryPath,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
if (!existingSource) {
return null;
}
return {
relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/"),
existingSource,
};
}
try {
resolveBoundaryPathSync({
absolutePath,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
});
} catch {
params.diagnostics.push({
level: "error",
message: `extension entry escapes package directory: ${params.entryPath}`,
source: params.sourceLabel,
});
return null;
}
return { relativePath: path.relative(params.packageDir, absolutePath).replace(/\\/g, "/") };
}
function resolveExistingPackageEntrySource(params: {
packageDir: string;
entryPath: string;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const source = path.resolve(params.packageDir, params.entryPath);
if (!fs.existsSync(source)) {
return null;
}
return resolvePackageEntrySource(params);
}
function resolvePackageRuntimeEntrySource(params: {
packageDir: string;
entryPath: string;
runtimeEntryPath?: string;
origin: PluginOrigin;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const safeEntry = resolveSafePackageEntry({
packageDir: params.packageDir,
entryPath: params.entryPath,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
if (!safeEntry) {
return null;
}
if (params.runtimeEntryPath) {
const runtimeSource = resolvePackageEntrySource({
packageDir: params.packageDir,
entryPath: params.runtimeEntryPath,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
if (runtimeSource) {
return runtimeSource;
}
}
if (shouldInferBuiltRuntimeEntry(params.origin)) {
for (const candidate of listBuiltRuntimeEntryCandidates(safeEntry.relativePath)) {
const runtimeSource = resolveExistingPackageEntrySource({
packageDir: params.packageDir,
entryPath: candidate,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
if (runtimeSource) {
return runtimeSource;
}
}
}
if (safeEntry.existingSource) {
return safeEntry.existingSource;
}
return resolvePackageEntrySource({
packageDir: params.packageDir,
entryPath: params.entryPath,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
}
export function resolvePackageSetupSource(params: {
packageDir: string;
manifest: PackageManifest | null;
origin: PluginOrigin;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
const setupEntryPath = normalizeOptionalString(packageManifest?.setupEntry);
if (!setupEntryPath) {
return null;
}
return resolvePackageRuntimeEntrySource({
packageDir: params.packageDir,
entryPath: setupEntryPath,
runtimeEntryPath: normalizeOptionalString(packageManifest?.runtimeSetupEntry),
origin: params.origin,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
}
export function resolvePackageRuntimeExtensionSources(params: {
packageDir: string;
manifest: PackageManifest | null;
extensions: readonly string[];
origin: PluginOrigin;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string[] {
const runtimeResolution = resolvePackageRuntimeExtensionEntries({
manifest: params.manifest,
extensions: params.extensions,
});
if (!runtimeResolution.ok) {
params.diagnostics.push({
level: "error",
message: runtimeResolution.error,
source: params.sourceLabel,
});
return [];
}
return params.extensions.flatMap((entryPath, index) => {
const source = resolvePackageRuntimeEntrySource({
packageDir: params.packageDir,
entryPath,
runtimeEntryPath: runtimeResolution.runtimeExtensions[index],
origin: params.origin,
sourceLabel: params.sourceLabel,
diagnostics: params.diagnostics,
rejectHardlinks: params.rejectHardlinks,
});
return source ? [source] : [];
});
}

View File

@@ -0,0 +1,27 @@
import path from "node:path";
export function isTypeScriptPackageEntry(entryPath: string): boolean {
return [".ts", ".mts", ".cts"].includes(path.extname(entryPath).toLowerCase());
}
export function listBuiltRuntimeEntryCandidates(entryPath: string): string[] {
if (!isTypeScriptPackageEntry(entryPath)) {
return [];
}
const normalized = entryPath.replace(/\\/g, "/");
const withoutExtension = normalized.replace(/\.[^.]+$/u, "");
const normalizedRelative = normalized.replace(/^\.\//u, "");
const distWithoutExtension = normalizedRelative.startsWith("src/")
? `./dist/${normalizedRelative.slice("src/".length).replace(/\.[^.]+$/u, "")}`
: `./dist/${withoutExtension.replace(/^\.\//u, "")}`;
const withJavaScriptExtensions = (basePath: string) => [
`${basePath}.js`,
`${basePath}.mjs`,
`${basePath}.cjs`,
];
const candidates = [
...withJavaScriptExtensions(distWithoutExtension),
...withJavaScriptExtensions(withoutExtension),
];
return [...new Set(candidates)].filter((candidate) => candidate !== normalized);
}

View File

@@ -3,12 +3,14 @@ import {
runProviderModelSelectedHook as runProviderModelSelectedHookImpl,
} from "./provider-wizard.js";
import { resolvePluginProviders as resolvePluginProvidersImpl } from "./providers.runtime.js";
import { resolvePluginSetupProvider as resolvePluginSetupProviderImpl } from "./setup-registry.js";
type ResolveProviderPluginChoice =
typeof import("./provider-wizard.js").resolveProviderPluginChoice;
type RunProviderModelSelectedHook =
typeof import("./provider-wizard.js").runProviderModelSelectedHook;
type ResolvePluginProviders = typeof import("./providers.runtime.js").resolvePluginProviders;
type ResolvePluginSetupProvider = typeof import("./setup-registry.js").resolvePluginSetupProvider;
export function resolveProviderPluginChoice(
...args: Parameters<ResolveProviderPluginChoice>
@@ -27,3 +29,9 @@ export function resolvePluginProviders(
): ReturnType<ResolvePluginProviders> {
return resolvePluginProvidersImpl(...args);
}
export function resolvePluginSetupProvider(
...args: Parameters<ResolvePluginSetupProvider>
): ReturnType<ResolvePluginSetupProvider> {
return resolvePluginSetupProviderImpl(...args);
}

View File

@@ -17,11 +17,15 @@ import {
pickAuthMethod,
resolveProviderMatch,
} from "./provider-auth-choice-helpers.js";
import {
resolveManifestProviderAuthChoice,
type ProviderAuthChoiceMetadata,
} from "./provider-auth-choices.js";
import { applyAuthProfileConfig } from "./provider-auth-helpers.js";
import { resolveProviderInstallCatalogEntry } from "./provider-install-catalog.js";
import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js";
import { isRemoteEnvironment, openUrl } from "./setup-browser.js";
import type { ProviderAuthMethod, ProviderAuthOptionBag } from "./types.js";
import type { ProviderAuthMethod, ProviderAuthOptionBag, ProviderPlugin } from "./types.js";
export type ApplyProviderAuthChoiceParams = {
authChoice: string;
@@ -154,6 +158,24 @@ async function loadPluginProviderRuntime() {
return await providerAuthChoiceDeps.loadPluginProviderRuntime();
}
function resolveManifestAuthChoiceScope(params: {
authChoice: string;
config: OpenClawConfig;
workspaceDir: string;
env?: NodeJS.ProcessEnv;
}): ProviderAuthChoiceMetadata | undefined {
return resolveManifestProviderAuthChoice(params.authChoice, {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
includeUntrustedWorkspacePlugins: false,
});
}
function withProviderPluginId(provider: ProviderPlugin, pluginId: string): ProviderPlugin {
return provider.pluginId === pluginId ? provider : { ...provider, pluginId };
}
export const __testing = {
resetDepsForTest(): void {
providerAuthChoiceDeps = defaultProviderAuthChoiceDeps;
@@ -256,8 +278,18 @@ export async function applyAuthChoiceLoadedPluginProvider(
resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir();
let nextConfig = params.config;
let enabledConfig = params.config;
const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } =
await loadPluginProviderRuntime();
const {
resolvePluginProviders,
resolvePluginSetupProvider,
resolveProviderPluginChoice,
runProviderModelSelectedHook,
} = await loadPluginProviderRuntime();
const manifestAuthChoice = resolveManifestAuthChoiceScope({
authChoice: params.authChoice,
config: nextConfig,
workspaceDir,
env: params.env,
});
const installCatalogEntry = resolveProviderInstallCatalogEntry(params.authChoice, {
config: nextConfig,
workspaceDir,
@@ -277,16 +309,43 @@ export async function applyAuthChoiceLoadedPluginProvider(
enabledConfig = enableResult.config;
}
let providers = resolvePluginProviders({
config: enabledConfig,
workspaceDir,
env: params.env,
mode: "setup",
});
const resolveScopedRuntimeProviders = (config: OpenClawConfig): ProviderPlugin[] =>
resolvePluginProviders({
config,
workspaceDir,
env: params.env,
mode: "setup",
...(manifestAuthChoice
? {
onlyPluginIds: [manifestAuthChoice.pluginId],
providerRefs: [manifestAuthChoice.providerId],
}
: {}),
});
const setupProvider = manifestAuthChoice
? resolvePluginSetupProvider({
provider: manifestAuthChoice.providerId,
config: enabledConfig,
workspaceDir,
env: params.env,
pluginIds: [manifestAuthChoice.pluginId],
})
: undefined;
let providers = setupProvider
? [withProviderPluginId(setupProvider, manifestAuthChoice!.pluginId)]
: resolveScopedRuntimeProviders(enabledConfig);
let resolved = resolveProviderPluginChoice({
providers,
choice: params.authChoice,
});
if (!resolved && setupProvider) {
providers = resolveScopedRuntimeProviders(enabledConfig);
resolved = resolveProviderPluginChoice({
providers,
choice: params.authChoice,
});
}
if (!resolved && installCatalogEntry) {
const [{ ensureOnboardingPluginInstalled }, { clearPluginDiscoveryCache }] = await Promise.all([
import("../commands/onboarding-plugin-install.js"),
@@ -308,12 +367,7 @@ export async function applyAuthChoiceLoadedPluginProvider(
}
nextConfig = installResult.cfg;
clearPluginDiscoveryCache();
providers = resolvePluginProviders({
config: nextConfig,
workspaceDir,
env: params.env,
mode: "setup",
});
providers = resolveScopedRuntimeProviders(nextConfig);
resolved = resolveProviderPluginChoice({
providers,
choice: params.authChoice,

View File

@@ -102,6 +102,10 @@ export function resolveProviderPluginsForHooks(params: {
env?: NodeJS.ProcessEnv;
onlyPluginIds?: string[];
providerRefs?: string[];
applyAutoEnable?: boolean;
bundledProviderAllowlistCompat?: boolean;
bundledProviderVitestCompat?: boolean;
installBundledRuntimeDeps?: boolean;
}): ProviderPlugin[] {
const env = params.env ?? process.env;
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
@@ -127,8 +131,10 @@ export function resolveProviderPluginsForHooks(params: {
env,
activate: false,
cache: false,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
applyAutoEnable: params.applyAutoEnable,
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true,
bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true,
installBundledRuntimeDeps: params.installBundledRuntimeDeps,
})
) {
return [];
@@ -139,8 +145,10 @@ export function resolveProviderPluginsForHooks(params: {
env,
activate: false,
cache: false,
bundledProviderAllowlistCompat: true,
bundledProviderVitestCompat: true,
applyAutoEnable: params.applyAutoEnable,
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat ?? true,
bundledProviderVitestCompat: params.bundledProviderVitestCompat ?? true,
installBundledRuntimeDeps: params.installBundledRuntimeDeps,
});
cacheBucket.set(cacheKey, resolved);
return resolved;
@@ -151,12 +159,20 @@ export function resolveProviderRuntimePlugin(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
applyAutoEnable?: boolean;
bundledProviderAllowlistCompat?: boolean;
bundledProviderVitestCompat?: boolean;
installBundledRuntimeDeps?: boolean;
}): ProviderPlugin | undefined {
return resolveProviderPluginsForHooks({
config: params.config,
workspaceDir: params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(),
env: params.env,
providerRefs: [params.provider],
applyAutoEnable: params.applyAutoEnable,
bundledProviderAllowlistCompat: params.bundledProviderAllowlistCompat,
bundledProviderVitestCompat: params.bundledProviderVitestCompat,
installBundledRuntimeDeps: params.installBundledRuntimeDeps,
}).find((plugin) => matchesProviderId(plugin, params.provider));
}

View File

@@ -35,6 +35,13 @@ vi.mock("./provider-discovery.runtime.js", () => ({
resolvePluginDiscoveryProvidersRuntime,
}));
vi.mock("./providers.js", () => ({
resolveCatalogHookProviderPluginIds: vi.fn(() => []),
resolveExternalAuthProfileCompatFallbackPluginIds: vi.fn(() => []),
resolveExternalAuthProfileProviderPluginIds: vi.fn(() => []),
resolveOwningPluginIdsForProvider: vi.fn(() => ["anthropic-vertex"]),
}));
import { resolveProviderSyntheticAuthWithPlugin } from "./provider-runtime.js";
describe("resolveProviderSyntheticAuthWithPlugin", () => {
@@ -53,7 +60,7 @@ describe("resolveProviderSyntheticAuthWithPlugin", () => {
source: "gcp-vertex-credentials (ADC)",
mode: "api-key",
});
expect(resolveProviderRuntimePlugin).toHaveBeenCalled();
expect(resolveProviderRuntimePlugin).not.toHaveBeenCalled();
expect(resolvePluginDiscoveryProvidersRuntime).toHaveBeenCalled();
});
});

View File

@@ -26,6 +26,10 @@ type ResolveExternalAuthProfileCompatFallbackPluginIds =
typeof import("./providers.js").resolveExternalAuthProfileCompatFallbackPluginIds;
type ResolveExternalAuthProfileProviderPluginIds =
typeof import("./providers.js").resolveExternalAuthProfileProviderPluginIds;
type ResolveOwningPluginIdsForProvider =
typeof import("./providers.js").resolveOwningPluginIdsForProvider;
type ResolveBundledProviderPolicySurface =
typeof import("./provider-public-artifacts.js").resolveBundledProviderPolicySurface;
const resolvePluginProvidersMock = vi.fn<ResolvePluginProviders>((_) => [] as ProviderPlugin[]);
const isPluginProvidersLoadInFlightMock = vi.fn<IsPluginProvidersLoadInFlight>((_) => false);
@@ -36,6 +40,12 @@ const resolveExternalAuthProfileCompatFallbackPluginIdsMock =
vi.fn<ResolveExternalAuthProfileCompatFallbackPluginIds>((_) => [] as string[]);
const resolveExternalAuthProfileProviderPluginIdsMock =
vi.fn<ResolveExternalAuthProfileProviderPluginIds>((_) => [] as string[]);
const resolveOwningPluginIdsForProviderMock = vi.fn<ResolveOwningPluginIdsForProvider>(
(_) => undefined,
);
const resolveBundledProviderPolicySurfaceMock = vi.fn<ResolveBundledProviderPolicySurface>(
(_) => null,
);
const providerRuntimeWarnMock = vi.fn();
let augmentModelCatalogWithProviderPlugins: typeof import("./provider-runtime.js").augmentModelCatalogWithProviderPlugins;
@@ -244,7 +254,8 @@ describe("provider-runtime", () => {
beforeAll(async () => {
vi.resetModules();
vi.doMock("./provider-public-artifacts.js", () => ({
resolveBundledProviderPolicySurface: () => null,
resolveBundledProviderPolicySurface: (provider: string) =>
resolveBundledProviderPolicySurfaceMock(provider),
}));
vi.doMock("./providers.js", () => ({
resolveCatalogHookProviderPluginIds: (params: unknown) =>
@@ -253,6 +264,8 @@ describe("provider-runtime", () => {
resolveExternalAuthProfileCompatFallbackPluginIdsMock(params as never),
resolveExternalAuthProfileProviderPluginIds: (params: unknown) =>
resolveExternalAuthProfileProviderPluginIdsMock(params as never),
resolveOwningPluginIdsForProvider: (params: unknown) =>
resolveOwningPluginIdsForProviderMock(params as never),
}));
vi.doMock("./providers.runtime.js", () => ({
resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never),
@@ -322,6 +335,7 @@ describe("provider-runtime", () => {
beforeEach(() => {
resetProviderRuntimeHookCacheForTest();
providerRuntimeTesting.resetExternalAuthFallbackWarningCacheForTest();
providerRuntimeTesting.resetCatalogHookProvidersCacheForTest();
resolvePluginProvidersMock.mockReset();
resolvePluginProvidersMock.mockReturnValue([]);
isPluginProvidersLoadInFlightMock.mockReset();
@@ -332,6 +346,10 @@ describe("provider-runtime", () => {
resolveExternalAuthProfileCompatFallbackPluginIdsMock.mockReturnValue([]);
resolveExternalAuthProfileProviderPluginIdsMock.mockReset();
resolveExternalAuthProfileProviderPluginIdsMock.mockReturnValue([]);
resolveOwningPluginIdsForProviderMock.mockReset();
resolveOwningPluginIdsForProviderMock.mockReturnValue(undefined);
resolveBundledProviderPolicySurfaceMock.mockReset();
resolveBundledProviderPolicySurfaceMock.mockReturnValue(null);
providerRuntimeWarnMock.mockReset();
});
@@ -822,6 +840,31 @@ describe("provider-runtime", () => {
});
});
it("does not scan provider plugins after bundled policy surface handles config", () => {
const providerConfig: ModelProviderConfig = {
baseUrl: "https://api.openai.com/v1",
api: "openai-completions",
models: [],
};
const normalizeConfig = vi.fn(() => providerConfig);
resolveBundledProviderPolicySurfaceMock.mockReturnValue({
normalizeConfig,
});
expect(
normalizeProviderConfigWithPlugin({
provider: "openai",
context: {
provider: "openai",
providerConfig,
},
}),
).toBeUndefined();
expect(normalizeConfig).toHaveBeenCalledTimes(1);
expect(resolvePluginProvidersMock).not.toHaveBeenCalled();
});
it("resolves provider config defaults through owner plugins", () => {
resolvePluginProvidersMock.mockReturnValue([
{
@@ -1723,6 +1766,8 @@ describe("provider-runtime", () => {
cache: false,
}),
);
expect(resolveCatalogHookProviderPluginIdsMock).toHaveBeenCalledTimes(1);
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1);
});
it("does not stack-overflow when provider hook resolution reenters the same plugin load", () => {
@@ -1758,7 +1803,7 @@ describe("provider-runtime", () => {
});
expect(result).toBeUndefined();
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2);
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1);
});
it("keeps cached provider hook results available during a nested provider load", () => {
@@ -1825,6 +1870,6 @@ describe("provider-runtime", () => {
}),
).toBeUndefined();
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(3);
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -4,6 +4,7 @@ import {
applyPluginTextReplacements,
mergePluginTextTransforms,
} from "../agents/plugin-text-transforms.js";
import { normalizeProviderId } from "../agents/provider-id.js";
import type { ProviderSystemPromptContribution } from "../agents/system-prompt-contribution.js";
import type { ModelProviderConfig } from "../config/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -13,9 +14,8 @@ import { sanitizeForLog } from "../terminal/ansi.js";
import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js";
import {
__testing as providerHookRuntimeTesting,
clearProviderRuntimeHookCache,
clearProviderRuntimeHookCache as clearProviderHookRuntimeCache,
prepareProviderExtraParams,
resetProviderRuntimeHookCacheForTest,
resolveProviderAuthProfileId,
resolveProviderExtraParamsForTransport,
resolveProviderFollowupFallbackRoute,
@@ -31,7 +31,9 @@ import {
resolveCatalogHookProviderPluginIds,
resolveExternalAuthProfileCompatFallbackPluginIds,
resolveExternalAuthProfileProviderPluginIds,
resolveOwningPluginIdsForProvider,
} from "./providers.js";
import { resolvePluginCacheInputs } from "./roots.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js";
import type {
@@ -83,18 +85,150 @@ import type {
const log = createSubsystemLogger("plugins/provider-runtime");
const warnedExternalAuthFallbackPluginIds = new Set<string>();
let catalogHookProvidersCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
let catalogHookProviderIdCacheWithoutConfig = new WeakMap<
NodeJS.ProcessEnv,
Map<string, string[]>
>();
let catalogHookProviderIdCacheByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>
>();
function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean {
const normalized = normalizeProviderId(providerId);
if (!normalized) {
return false;
}
if (normalizeProviderId(provider.id) === normalized) {
return true;
}
return [...(provider.aliases ?? []), ...(provider.hookAliases ?? [])].some(
(alias) => normalizeProviderId(alias) === normalized,
);
}
function hasExplicitProviderRuntimePluginActivation(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): boolean {
if (!params.config) {
return true;
}
const ownerPluginIds =
resolveOwningPluginIdsForProvider({
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}) ?? [];
if (ownerPluginIds.length === 0) {
return false;
}
const allow = new Set(params.config.plugins?.allow ?? []);
const entries = params.config.plugins?.entries ?? {};
return ownerPluginIds.some((pluginId) => allow.has(pluginId) || entries[pluginId] !== undefined);
}
function resetExternalAuthFallbackWarningCacheForTest(): void {
warnedExternalAuthFallbackPluginIds.clear();
}
function resetCatalogHookProvidersCacheForTest(): void {
catalogHookProvidersCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
}
function clearCatalogHookProviderIdCache(): void {
catalogHookProviderIdCacheWithoutConfig = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
catalogHookProviderIdCacheByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>
>();
}
function resolveCatalogHookProviderIdCacheBucket(params: {
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Map<string, string[]> {
if (!params.config) {
let bucket = catalogHookProviderIdCacheWithoutConfig.get(params.env);
if (!bucket) {
bucket = new Map<string, string[]>();
catalogHookProviderIdCacheWithoutConfig.set(params.env, bucket);
}
return bucket;
}
let envBuckets = catalogHookProviderIdCacheByConfig.get(params.config);
if (!envBuckets) {
envBuckets = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
catalogHookProviderIdCacheByConfig.set(params.config, envBuckets);
}
let bucket = envBuckets.get(params.env);
if (!bucket) {
bucket = new Map<string, string[]>();
envBuckets.set(params.env, bucket);
}
return bucket;
}
function buildCatalogHookProviderIdCacheKey(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): string {
const { roots } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
env: params.env,
});
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}`;
}
function resolveCachedCatalogHookProviderPluginIds(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): string[] {
const env = params.env ?? process.env;
const bucket = resolveCatalogHookProviderIdCacheBucket({
config: params.config,
env,
});
const key = buildCatalogHookProviderIdCacheKey({
config: params.config,
workspaceDir: params.workspaceDir,
env,
});
const cached = bucket.get(key);
if (cached) {
return cached;
}
const resolved = resolveCatalogHookProviderPluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
env,
});
bucket.set(key, resolved);
return resolved;
}
export function clearProviderRuntimeHookCache(): void {
resetCatalogHookProvidersCacheForTest();
clearCatalogHookProviderIdCache();
clearProviderHookRuntimeCache();
}
export function resetProviderRuntimeHookCacheForTest(): void {
clearProviderRuntimeHookCache();
}
export {
clearProviderRuntimeHookCache,
prepareProviderExtraParams,
resolveProviderAuthProfileId,
resolveProviderExtraParamsForTransport,
resolveProviderFollowupFallbackRoute,
resetProviderRuntimeHookCacheForTest,
resolveProviderRuntimePlugin,
wrapProviderStreamFn,
};
@@ -102,6 +236,8 @@ export {
export const __testing = {
...providerHookRuntimeTesting,
resetExternalAuthFallbackWarningCacheForTest,
resetCatalogHookProvidersCacheForTest,
resetProviderRuntimeHookCacheForTest,
} as const;
function resolveProviderPluginsForCatalogHooks(params: {
@@ -110,19 +246,37 @@ function resolveProviderPluginsForCatalogHooks(params: {
env?: NodeJS.ProcessEnv;
}): ProviderPlugin[] {
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
const onlyPluginIds = resolveCatalogHookProviderPluginIds({
const env = params.env ?? process.env;
let envCache = catalogHookProvidersCache.get(env);
if (!envCache) {
envCache = new Map<string, ProviderPlugin[]>();
catalogHookProvidersCache.set(env, envCache);
}
const cacheKey = JSON.stringify({
workspaceDir: workspaceDir ?? "",
plugins: params.config?.plugins ?? null,
});
const cached = envCache.get(cacheKey);
if (cached) {
return cached;
}
const onlyPluginIds = resolveCachedCatalogHookProviderPluginIds({
config: params.config,
workspaceDir,
env: params.env,
env,
});
if (onlyPluginIds.length === 0) {
envCache.set(cacheKey, []);
return [];
}
return resolveProviderPluginsForHooks({
const providers = resolveProviderPluginsForHooks({
...params,
workspaceDir,
env,
onlyPluginIds,
});
envCache.set(cacheKey, providers);
return providers;
}
export function runProviderDynamicModel(params: {
@@ -410,6 +564,7 @@ export function normalizeProviderConfigWithPlugin(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderNormalizeConfigContext;
allowRuntimePluginLoad?: boolean;
}): ModelProviderConfig | undefined {
const hasConfigChange = (normalized: ModelProviderConfig) =>
normalized !== params.context.providerConfig;
@@ -418,23 +573,15 @@ export function normalizeProviderConfigWithPlugin(params: {
const normalized = bundledSurface.normalizeConfig(params.context);
return normalized && hasConfigChange(normalized) ? normalized : undefined;
}
const matchedPlugin = resolveProviderHookPlugin(params);
if (!hasExplicitProviderRuntimePluginActivation(params)) {
return undefined;
}
if (params.allowRuntimePluginLoad === false) {
return undefined;
}
const matchedPlugin = resolveProviderRuntimePlugin(params);
const normalizedMatched = matchedPlugin?.normalizeConfig?.(params.context);
if (normalizedMatched && hasConfigChange(normalizedMatched)) {
return normalizedMatched;
}
for (const candidate of resolveProviderPluginsForHooks(params)) {
if (!candidate.normalizeConfig || candidate === matchedPlugin) {
continue;
}
const normalized = candidate.normalizeConfig(params.context);
if (normalized && hasConfigChange(normalized)) {
return normalized;
}
}
return undefined;
return normalizedMatched && hasConfigChange(normalizedMatched) ? normalizedMatched : undefined;
}
export function applyProviderNativeStreamingUsageCompatWithPlugin(params: {
@@ -443,9 +590,13 @@ export function applyProviderNativeStreamingUsageCompatWithPlugin(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderNormalizeConfigContext;
allowRuntimePluginLoad?: boolean;
}): ModelProviderConfig | undefined {
if (params.allowRuntimePluginLoad === false) {
return undefined;
}
return (
resolveProviderHookPlugin(params)?.applyNativeStreamingUsageCompat?.(params.context) ??
resolveProviderRuntimePlugin(params)?.applyNativeStreamingUsageCompat?.(params.context) ??
undefined
);
}
@@ -456,13 +607,17 @@ export function resolveProviderConfigApiKeyWithPlugin(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderResolveConfigApiKeyContext;
allowRuntimePluginLoad?: boolean;
}): string | undefined {
const bundledSurface = resolveBundledProviderPolicySurface(params.provider);
if (bundledSurface?.resolveConfigApiKey) {
return normalizeOptionalString(bundledSurface.resolveConfigApiKey(params.context));
}
if (params.allowRuntimePluginLoad === false) {
return undefined;
}
return normalizeOptionalString(
resolveProviderHookPlugin(params)?.resolveConfigApiKey?.(params.context),
resolveProviderRuntimePlugin(params)?.resolveConfigApiKey?.(params.context),
);
}
@@ -775,9 +930,34 @@ export function resolveProviderSyntheticAuthWithPlugin(params: {
env?: NodeJS.ProcessEnv;
context: ProviderResolveSyntheticAuthContext;
}) {
const runtimeResolved = resolveProviderRuntimePlugin(params)?.resolveSyntheticAuth?.(
params.context,
);
const discoveryPluginIds =
resolveOwningPluginIdsForProvider({
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}) ?? [];
const discoveryProvider = (
discoveryPluginIds.length > 0
? resolvePluginDiscoveryProvidersRuntime({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
onlyPluginIds: discoveryPluginIds,
discoveryEntriesOnly: true,
})
: []
).find((provider) => matchesProviderPluginRef(provider, params.provider));
if (typeof discoveryProvider?.resolveSyntheticAuth === "function") {
return discoveryProvider.resolveSyntheticAuth(params.context) ?? undefined;
}
const runtimeResolved = resolveProviderRuntimePlugin({
...params,
applyAutoEnable: false,
bundledProviderAllowlistCompat: false,
bundledProviderVitestCompat: false,
installBundledRuntimeDeps: false,
})?.resolveSyntheticAuth?.(params.context);
if (runtimeResolved) {
return runtimeResolved;
}

View File

@@ -7,6 +7,7 @@ import {
} from "../shared/string-coerce.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { resolvePluginProviders } from "./providers.runtime.js";
import { resolvePluginSetupProvider } from "./setup-registry.js";
import type {
ProviderAuthMethod,
ProviderPlugin,
@@ -293,12 +294,19 @@ export async function runProviderModelSelectedHook(params: {
return;
}
const providers = resolveProviderWizardProviders({
const setupProvider = resolvePluginSetupProvider({
provider: selectedProviderId,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const provider = providers.find((entry) => normalizeProviderId(entry.id) === selectedProviderId);
const provider =
setupProvider ??
resolveProviderWizardProviders({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
}).find((entry) => normalizeProviderId(entry.id) === selectedProviderId);
if (!provider?.onModelSelected) {
return;
}

View File

@@ -195,7 +195,7 @@ function resolveRuntimeProviderPluginLoadState(
env: base.env,
workspaceDir: base.workspaceDir,
onlyPluginIds: runtimeRequestedPluginIds,
applyAutoEnable: true,
applyAutoEnable: params.applyAutoEnable ?? true,
compatMode: {
allowlist: params.bundledProviderAllowlistCompat,
enablement: "allowlist",
@@ -233,6 +233,7 @@ function resolveRuntimeProviderPluginLoadState(
pluginSdkResolution: params.pluginSdkResolution,
cache: params.cache ?? true,
activate: params.activate ?? false,
installBundledRuntimeDeps: params.installBundledRuntimeDeps,
},
);
return { loadOptions };
@@ -264,6 +265,8 @@ export function resolvePluginProviders(params: {
modelRefs?: readonly string[];
activate?: boolean;
cache?: boolean;
applyAutoEnable?: boolean;
installBundledRuntimeDeps?: boolean;
pluginSdkResolution?: PluginLoadOptions["pluginSdkResolution"];
mode?: "runtime" | "setup";
includeUntrustedWorkspacePlugins?: boolean;

View File

@@ -426,6 +426,30 @@ function dedupeSortedPluginIds(values: Iterable<string>): string[] {
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
}
let owningProviderPluginIdsCache = new WeakMap<
NodeJS.ProcessEnv,
Map<string, string[] | undefined>
>();
function buildOwningProviderPluginIdsCacheKey(params: {
provider: string;
config?: PluginLoadOptions["config"];
workspaceDir?: string;
}): string {
return JSON.stringify({
provider: normalizeProviderId(params.provider),
workspaceDir: params.workspaceDir ?? "",
plugins: params.config?.plugins ?? null,
});
}
export function resetProviderOwnerPluginIdsCacheForTest(): void {
owningProviderPluginIdsCache = new WeakMap<
NodeJS.ProcessEnv,
Map<string, string[] | undefined>
>();
}
function resolvePreferredManifestPluginIds(
registry: PluginManifestRegistry,
matchedPluginIds: readonly string[],
@@ -478,18 +502,33 @@ export function resolveOwningPluginIdsForProvider(params: {
return pluginIds.length > 0 ? pluginIds : undefined;
}
const env = params.env ?? process.env;
let envCache = owningProviderPluginIdsCache.get(env);
if (!envCache) {
envCache = new Map<string, string[] | undefined>();
owningProviderPluginIdsCache.set(env, envCache);
}
const cacheKey = buildOwningProviderPluginIdsCacheKey({
provider: normalizedProvider,
config: params.config,
workspaceDir: params.workspaceDir,
});
if (envCache.has(cacheKey)) {
return envCache.get(cacheKey);
}
const pluginIds = [
...resolveProviderOwners({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
env,
providerId: normalizedProvider,
includeDisabled: true,
}),
...resolvePluginContributionOwners({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
env,
contribution: "cliBackends",
matches: (backendId) => normalizeProviderId(backendId) === normalizedProvider,
includeDisabled: true,
@@ -497,7 +536,9 @@ export function resolveOwningPluginIdsForProvider(params: {
];
const deduped = dedupeSortedPluginIds(pluginIds);
return deduped.length > 0 ? deduped : undefined;
const resolved = deduped.length > 0 ? deduped : undefined;
envCache.set(cacheKey, resolved);
return resolved;
}
export function resolveOwningPluginIdsForModelRef(params: {

View File

@@ -180,7 +180,7 @@ describe("startPluginServices", () => {
expect(stopThrows).toHaveBeenCalledOnce();
});
it("grants internal diagnostics only to the bundled diagnostics OTEL service", async () => {
it("grants internal diagnostics only to bundled diagnostics exporter services", async () => {
const contexts: OpenClawPluginServiceContext[] = [];
const diagnosticsService = createTrackingService("diagnostics-otel", { contexts });
await startPluginServices({
@@ -191,6 +191,18 @@ describe("startPluginServices", () => {
expect(contexts[0]?.internalDiagnostics?.onEvent).toBeTypeOf("function");
expect(contexts[0]?.internalDiagnostics?.emit).toBeTypeOf("function");
const prometheusContexts: OpenClawPluginServiceContext[] = [];
const prometheusService = createTrackingService("diagnostics-prometheus", {
contexts: prometheusContexts,
});
await startPluginServices({
registry: createRegistry([prometheusService], "diagnostics-prometheus", "bundled"),
config: createServiceConfig(),
});
expect(prometheusContexts[0]?.internalDiagnostics?.onEvent).toBeTypeOf("function");
expect(prometheusContexts[0]?.internalDiagnostics?.emit).toBeTypeOf("function");
const untrustedContexts: OpenClawPluginServiceContext[] = [];
const untrustedService = createTrackingService("diagnostics-otel", {
contexts: untrustedContexts,

View File

@@ -24,14 +24,18 @@ function createServiceContext(params: {
workspaceDir?: string;
service?: PluginServiceRegistration;
}): OpenClawPluginServiceContext {
const grantsInternalDiagnostics =
params.service?.origin === "bundled" &&
params.service.pluginId === params.service.service.id &&
(params.service.service.id === "diagnostics-otel" ||
params.service.service.id === "diagnostics-prometheus");
return {
config: params.config,
workspaceDir: params.workspaceDir,
stateDir: STATE_DIR,
logger: createPluginLogger(),
...(params.service?.origin === "bundled" &&
params.service.pluginId === "diagnostics-otel" &&
params.service.service.id === "diagnostics-otel"
...(grantsInternalDiagnostics
? {
internalDiagnostics: {
emit: emitTrustedDiagnosticEvent,

View File

@@ -8,6 +8,15 @@ import {
resetRegistryJitiMocks,
} from "./test-helpers/registry-jiti-mocks.js";
// jiti-loader-cache prefers native require() for compiled .js before falling
// back to jiti. These tests scripts plugin-loading behaviour through the
// jiti mock — disable the native-require fast path so the mocked jiti loader
// stays authoritative for the test fixture files on disk.
vi.mock("./native-module-require.js", () => ({
isJavaScriptModulePath: (_modulePath: string) => false,
tryNativeRequireJavaScriptModule: (_modulePath: string) => ({ ok: false }),
}));
const tempDirs: string[] = [];
const mocks = getRegistryJitiMocks();

View File

@@ -153,6 +153,7 @@ function setCachedSetupValue<T>(cache: Map<string, T>, key: string, value: T): v
}
function buildSetupRegistryCacheKey(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
@@ -160,18 +161,22 @@ function buildSetupRegistryCacheKey(params: {
const { roots, loadPaths } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
env: params.env,
loadPaths: params.config?.plugins?.load?.paths,
});
return JSON.stringify({
roots,
loadPaths,
hasConfig: Boolean(params.config),
pluginIds: params.pluginIds ? [...new Set(params.pluginIds)].toSorted() : null,
});
}
function buildSetupProviderCacheKey(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}): string {
return JSON.stringify({
provider: normalizeProviderId(params.provider),
@@ -181,6 +186,7 @@ function buildSetupProviderCacheKey(params: {
function buildSetupCliBackendCacheKey(params: {
backend: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): string {
@@ -493,12 +499,14 @@ function pushSetupDescriptorDriftDiagnostics(params: {
}
export function resolvePluginSetupRegistry(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}): PluginSetupRegistry {
const env = params?.env ?? process.env;
const cacheKey = buildSetupRegistryCacheKey({
config: params?.config,
workspaceDir: params?.workspaceDir,
env,
pluginIds: params?.pluginIds,
@@ -532,6 +540,7 @@ export function resolvePluginSetupRegistry(params?: {
const cliBackendKeys = new Set<string>();
const manifestRegistry = loadSetupManifestRegistry({
config: params?.config,
workspaceDir: params?.workspaceDir,
env,
pluginIds: params?.pluginIds,
@@ -628,8 +637,10 @@ export function resolvePluginSetupRegistry(params?: {
export function resolvePluginSetupProvider(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}): ProviderPlugin | undefined {
const cacheKey = buildSetupProviderCacheKey(params);
const cached = getCachedSetupValue(setupProviderCache, cacheKey);
@@ -640,8 +651,10 @@ export function resolvePluginSetupProvider(params: {
const env = params.env ?? process.env;
const normalizedProvider = normalizeProviderId(params.provider);
const manifestRegistry = loadSetupManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env,
pluginIds: params.pluginIds,
});
const record = findUniqueSetupManifestOwner({
registry: manifestRegistry,
@@ -697,6 +710,7 @@ export function resolvePluginSetupProvider(params: {
export function resolvePluginSetupCliBackend(params: {
backend: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): SetupCliBackendEntry | undefined {
@@ -713,6 +727,7 @@ export function resolvePluginSetupCliBackend(params: {
// plugin setup module. This avoids booting every setup-api just to find one
// backend owner.
const manifestRegistry = loadSetupManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env,
});
@@ -786,6 +801,7 @@ export function runPluginSetupConfigMigrations(params: {
}
for (const entry of resolvePluginSetupRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
pluginIds,
@@ -812,6 +828,7 @@ export function resolvePluginSetupAutoEnableReasons(params: {
const seen = new Set<string>();
for (const entry of resolvePluginSetupRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env,
pluginIds: params.pluginIds,

View File

@@ -9,7 +9,9 @@ import {
makeTrackedTempDirAsync,
} from "./test-helpers/fs-fixtures.js";
import {
applyPluginUninstallDirectoryRemoval,
removePluginFromConfig,
planPluginUninstall,
resolveUninstallChannelConfigKeys,
resolveUninstallDirectoryTarget,
uninstallPlugin,
@@ -281,6 +283,17 @@ describe("removePluginFromConfig", () => {
expect(actions.allowlist).toBe(true);
});
it("removes plugin from denylist", () => {
const config = createPluginConfig({
deny: ["my-plugin", "other-plugin"],
});
const { config: result, actions } = removePluginFromConfig(config, "my-plugin");
expect(result.plugins?.deny).toEqual(["other-plugin"]);
expect(actions.denylist).toBe(true);
});
it.each([
{
name: "removes linked path from load.paths",
@@ -700,6 +713,31 @@ describe("uninstallPlugin", () => {
}
});
it("plans directory removal without deleting before commit", async () => {
const { pluginId, extensionsDir, pluginDir, config } = await createInstalledNpmPluginFixture({
baseDir: tempDir,
});
const plan = planPluginUninstall({
config,
pluginId,
deleteFiles: true,
extensionsDir,
});
expect(plan.ok).toBe(true);
if (!plan.ok) {
throw new Error(plan.error);
}
expect(plan.directoryRemoval).toEqual({ target: pluginDir });
expect(plan.actions.directory).toBe(false);
await expect(fs.access(pluginDir)).resolves.toBeUndefined();
const applied = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
expect(applied).toEqual({ directoryRemoved: true, warnings: [] });
await expect(fs.access(pluginDir)).rejects.toThrow();
});
it.each([
{
name: "preserves directory for linked plugins",

View File

@@ -11,6 +11,7 @@ export type UninstallActions = {
entry: boolean;
install: boolean;
allowlist: boolean;
denylist: boolean;
loadPath: boolean;
memorySlot: boolean;
contextEngineSlot: boolean;
@@ -18,6 +19,63 @@ export type UninstallActions = {
directory: boolean;
};
export const UNINSTALL_ACTION_LABELS = {
entry: "config entry",
install: "install record",
allowlist: "allowlist entry",
denylist: "denylist entry",
loadPath: "load path",
memorySlot: "memory slot",
contextEngineSlot: "context engine slot",
channelConfig: "channel config",
directory: "directory",
} satisfies Record<keyof UninstallActions, string>;
const UNINSTALL_ACTION_ORDER = [
"entry",
"install",
"allowlist",
"denylist",
"loadPath",
"memorySlot",
"contextEngineSlot",
"channelConfig",
"directory",
] as const satisfies ReadonlyArray<keyof UninstallActions>;
export function createEmptyUninstallActions(
overrides: Partial<UninstallActions> = {},
): UninstallActions {
return {
entry: false,
install: false,
allowlist: false,
denylist: false,
loadPath: false,
memorySlot: false,
contextEngineSlot: false,
channelConfig: false,
directory: false,
...overrides,
};
}
export function createEmptyConfigUninstallActions(): Omit<UninstallActions, "directory"> {
const { directory: _directory, ...actions } = createEmptyUninstallActions();
return actions;
}
export function formatUninstallActionLabels(actions: UninstallActions): string[] {
return UNINSTALL_ACTION_ORDER.flatMap((key) =>
actions[key] ? [UNINSTALL_ACTION_LABELS[key]] : [],
);
}
export function formatUninstallSlotResetPreview(slotKey: "memory" | "contextEngine"): string {
const actionKey = slotKey === "memory" ? "memorySlot" : "contextEngineSlot";
return `${UNINSTALL_ACTION_LABELS[actionKey]} (will reset to "${defaultSlotIdForKey(slotKey)}")`;
}
export type UninstallPluginResult =
| {
ok: true;
@@ -28,6 +86,20 @@ export type UninstallPluginResult =
}
| { ok: false; error: string };
export type PluginUninstallDirectoryRemoval = {
target: string;
};
export type PluginUninstallPlanResult =
| {
ok: true;
config: OpenClawConfig;
pluginId: string;
actions: UninstallActions;
directoryRemoval: PluginUninstallDirectoryRemoval | null;
}
| { ok: false; error: string };
export function resolveUninstallDirectoryTarget(params: {
pluginId: string;
hasInstall: boolean;
@@ -150,15 +222,7 @@ export function removePluginFromConfig(
pluginId: string,
opts?: { channelIds?: string[] },
): { config: OpenClawConfig; actions: Omit<UninstallActions, "directory"> } {
const actions: Omit<UninstallActions, "directory"> = {
entry: false,
install: false,
allowlist: false,
loadPath: false,
memorySlot: false,
contextEngineSlot: false,
channelConfig: false,
};
const actions = createEmptyConfigUninstallActions();
const pluginsConfig = cfg.plugins ?? {};
@@ -189,6 +253,17 @@ export function removePluginFromConfig(
actions.allowlist = true;
}
// Remove from denylist. An explicit uninstall should clear stale policy so a
// later reinstall can enable the plugin deterministically.
let deny = pluginsConfig.deny;
if (Array.isArray(deny) && deny.includes(pluginId)) {
deny = deny.filter((id) => id !== pluginId);
if (deny.length === 0) {
deny = undefined;
}
actions.denylist = true;
}
// Remove linked path from load.paths (for source === "path" plugins)
let load = pluginsConfig.load;
if (installRecord?.source === "path" && installRecord.sourcePath) {
@@ -231,6 +306,7 @@ export function removePluginFromConfig(
entries,
installs,
allow,
deny,
load,
slots,
};
@@ -246,6 +322,9 @@ export function removePluginFromConfig(
if (cleanedPlugins.allow === undefined) {
delete cleanedPlugins.allow;
}
if (cleanedPlugins.deny === undefined) {
delete cleanedPlugins.deny;
}
if (cleanedPlugins.load === undefined) {
delete cleanedPlugins.load;
}
@@ -289,12 +368,10 @@ export type UninstallPluginParams = {
};
/**
* Uninstall a plugin by removing it from config and optionally deleting installed files.
* Plan a plugin uninstall by removing it from config and resolving a safe file-removal target.
* Linked plugins (source === "path") never have their source directory deleted.
*/
export async function uninstallPlugin(
params: UninstallPluginParams,
): Promise<UninstallPluginResult> {
export function planPluginUninstall(params: UninstallPluginParams): PluginUninstallPlanResult {
const { config, pluginId, channelIds, deleteFiles = true, extensionsDir } = params;
// Validate plugin exists
@@ -317,7 +394,6 @@ export async function uninstallPlugin(
...configActions,
directory: false,
};
const warnings: string[] = [];
const deleteTarget =
deleteFiles && !isLinked
@@ -329,29 +405,56 @@ export async function uninstallPlugin(
})
: null;
// Delete installed directory if requested and safe.
if (deleteTarget) {
const existed =
(await fs
.access(deleteTarget)
.then(() => true)
.catch(() => false)) ?? false;
try {
await fs.rm(deleteTarget, { recursive: true, force: true });
actions.directory = existed;
} catch (error) {
warnings.push(
`Failed to remove plugin directory ${deleteTarget}: ${formatErrorMessage(error)}`,
);
// Directory deletion failure is not fatal; config is the source of truth.
}
}
return {
ok: true,
config: newConfig,
pluginId,
actions,
warnings,
directoryRemoval: deleteTarget ? { target: deleteTarget } : null,
};
}
export async function applyPluginUninstallDirectoryRemoval(
removal: PluginUninstallDirectoryRemoval | null,
): Promise<{ directoryRemoved: boolean; warnings: string[] }> {
if (!removal) {
return { directoryRemoved: false, warnings: [] };
}
const existed =
(await fs
.access(removal.target)
.then(() => true)
.catch(() => false)) ?? false;
try {
await fs.rm(removal.target, { recursive: true, force: true });
return { directoryRemoved: existed, warnings: [] };
} catch (error) {
return {
directoryRemoved: false,
warnings: [
`Failed to remove plugin directory ${removal.target}: ${formatErrorMessage(error)}`,
],
};
}
}
export async function uninstallPlugin(
params: UninstallPluginParams,
): Promise<UninstallPluginResult> {
const plan = planPluginUninstall(params);
if (!plan.ok) {
return plan;
}
const directory = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval);
return {
ok: true,
config: plan.config,
pluginId: plan.pluginId,
actions: {
...plan.actions,
directory: directory.directoryRemoved,
},
warnings: directory.warnings,
};
}

View File

@@ -214,12 +214,14 @@ function expectNpmUpdateCall(params: {
spec: string;
expectedIntegrity?: string;
expectedPluginId?: string;
timeoutMs?: number;
}) {
expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: params.spec,
expectedIntegrity: params.expectedIntegrity,
...(params.expectedPluginId ? { expectedPluginId: params.expectedPluginId } : {}),
...(params.timeoutMs ? { timeoutMs: params.timeoutMs } : {}),
}),
);
}
@@ -355,6 +357,48 @@ describe("updateNpmInstalledPlugins", () => {
},
);
it("passes timeout budget to npm plugin metadata checks and installs", async () => {
const installPath = createInstalledPackageDir({
name: "@martian-engineering/lossless-claw",
version: "0.9.0",
});
mockNpmViewMetadata({
name: "@martian-engineering/lossless-claw",
version: "0.10.0",
integrity: "sha512-next",
});
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "lossless-claw",
targetDir: installPath,
version: "0.10.0",
}),
);
await updateNpmInstalledPlugins({
config: createNpmInstallConfig({
pluginId: "lossless-claw",
spec: "@martian-engineering/lossless-claw",
installPath,
resolvedName: "@martian-engineering/lossless-claw",
resolvedSpec: "@martian-engineering/lossless-claw@0.9.0",
resolvedVersion: "0.9.0",
}),
pluginIds: ["lossless-claw"],
timeoutMs: 1_800_000,
});
const npmViewCall = runCommandWithTimeoutMock.mock.calls.find(
([argv]) => Array.isArray(argv) && argv[0] === "npm" && argv[1] === "view",
);
expect(npmViewCall?.[1]).toEqual(expect.objectContaining({ timeoutMs: 1_800_000 }));
expectNpmUpdateCall({
spec: "@martian-engineering/lossless-claw",
expectedPluginId: "lossless-claw",
timeoutMs: 1_800_000,
});
});
it("skips npm reinstall and config rewrite when the installed artifact is unchanged", async () => {
const installPath = createInstalledPackageDir({
name: "@martian-engineering/lossless-claw",
@@ -798,6 +842,7 @@ describe("updateNpmInstalledPlugins", () => {
clawhubChannel: "official",
}),
pluginIds: ["demo"],
timeoutMs: 1_800_000,
});
expect(installPluginFromClawHubMock).toHaveBeenCalledWith(
@@ -806,6 +851,7 @@ describe("updateNpmInstalledPlugins", () => {
baseUrl: "https://clawhub.ai",
expectedPluginId: "demo",
mode: "update",
timeoutMs: 1_800_000,
}),
);
expect(result.config.plugins?.installs?.demo).toMatchObject({
@@ -930,6 +976,7 @@ describe("updateNpmInstalledPlugins", () => {
marketplacePlugin: "claude-bundle",
}),
pluginIds: ["claude-bundle"],
timeoutMs: 1_800_000,
dryRun: true,
});
@@ -939,6 +986,7 @@ describe("updateNpmInstalledPlugins", () => {
plugin: "claude-bundle",
expectedPluginId: "claude-bundle",
dryRun: true,
timeoutMs: 1_800_000,
}),
);
expect(result.outcomes).toEqual([

View File

@@ -469,6 +469,7 @@ export async function updateNpmInstalledPlugins(params: {
logger?: PluginUpdateLogger;
pluginIds?: string[];
skipIds?: Set<string>;
timeoutMs?: number;
dryRun?: boolean;
dangerouslyForceUnsafeInstall?: boolean;
specOverrides?: Record<string, string>;
@@ -567,7 +568,10 @@ export async function updateNpmInstalledPlugins(params: {
});
if (!params.dryRun && record.source === "npm" && currentVersion) {
const metadataResult = await resolveNpmSpecMetadata({ spec: effectiveSpec! });
const metadataResult = await resolveNpmSpecMetadata({
spec: effectiveSpec!,
timeoutMs: params.timeoutMs,
});
if (metadataResult.ok) {
if (
shouldSkipUnchangedNpmInstall({
@@ -604,6 +608,7 @@ export async function updateNpmInstalledPlugins(params: {
spec: effectiveSpec!,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
@@ -622,6 +627,7 @@ export async function updateNpmInstalledPlugins(params: {
baseUrl: record.clawhubUrl,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
@@ -632,6 +638,7 @@ export async function updateNpmInstalledPlugins(params: {
plugin: record.marketplacePlugin!,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
@@ -708,6 +715,7 @@ export async function updateNpmInstalledPlugins(params: {
spec: effectiveSpec!,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
expectedIntegrity,
@@ -725,6 +733,7 @@ export async function updateNpmInstalledPlugins(params: {
baseUrl: record.clawhubUrl,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,
@@ -734,6 +743,7 @@ export async function updateNpmInstalledPlugins(params: {
plugin: record.marketplacePlugin!,
mode: "update",
extensionsDir,
timeoutMs: params.timeoutMs,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
logger,

View File

@@ -65,14 +65,14 @@ describe("resolveManifestDeclaredWebProviderCandidatePluginIds", () => {
expect(mocks.loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled();
});
it("keeps runtime fallback for scoped plugins with no declared web candidates", () => {
it("keeps scoped plugins with no declared web candidates scoped-empty", () => {
expect(
resolveManifestDeclaredWebProviderCandidatePluginIds({
contract: "webSearchProviders",
configKey: "webSearch",
onlyPluginIds: ["missing-plugin"],
}),
).toBeUndefined();
).toEqual([]);
expect(mocks.loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith(
expect.objectContaining({
pluginIds: ["missing-plugin"],
@@ -80,6 +80,29 @@ describe("resolveManifestDeclaredWebProviderCandidatePluginIds", () => {
);
});
it("keeps origin filters with no declared web candidates scoped-empty", () => {
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({
plugins: [
{
id: "workspace-tool",
origin: "workspace",
configSchema: {
properties: {},
},
},
],
diagnostics: [],
});
expect(
resolveManifestDeclaredWebProviderCandidatePluginIds({
contract: "webSearchProviders",
configKey: "webSearch",
origin: "bundled",
}),
).toEqual([]);
});
it("derives provider candidates from a single manifest-registry read", () => {
expect(
resolveManifestDeclaredWebProviderCandidatePluginIds({

View File

@@ -105,6 +105,9 @@ export function resolveManifestDeclaredWebProviderCandidatePluginIds(params: {
if (ids.length > 0) {
return ids;
}
if (params.origin || scopedPluginIds !== undefined) {
return [];
}
return undefined;
}

View File

@@ -1,21 +1,10 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({
resolvePluginWebSearchProvidersMock: vi.fn(() => [
{
id: "brave",
pluginId: "brave",
envVars: ["BRAVE_API_KEY"],
getCredentialValue: (searchConfig: Record<string, unknown> | undefined) =>
searchConfig?.apiKey,
},
]),
}));
vi.mock("./web-search-providers.runtime.js", () => ({
resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock,
}));
const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
let hasConfiguredWebSearchCredential: typeof import("./web-search-credential-presence.js").hasConfiguredWebSearchCredential;
@@ -23,11 +12,17 @@ beforeAll(async () => {
({ hasConfiguredWebSearchCredential } = await import("./web-search-credential-presence.js"));
});
beforeEach(() => {
resolvePluginWebSearchProvidersMock.mockClear();
});
describe("hasConfiguredWebSearchCredential", () => {
it("does not statically import web-search runtime providers", () => {
const source = fs.readFileSync(
path.join(repoRoot, "src/plugins/web-search-credential-presence.ts"),
"utf8",
);
expect(source).not.toMatch(/\bfrom\s+["'][^"']*web-search-providers\.runtime\.js["']/);
expect(source).not.toMatch(/\bfrom\s+["'][^"']*loader\.js["']/);
});
it("keeps empty config and env on the manifest-only path", () => {
expect(
hasConfiguredWebSearchCredential({
@@ -37,10 +32,9 @@ describe("hasConfiguredWebSearchCredential", () => {
bundledAllowlistCompat: true,
}),
).toBe(false);
expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled();
});
it("loads provider runtime only when a credential candidate exists", () => {
it("detects configured web search credential candidates without runtime loading", () => {
expect(
hasConfiguredWebSearchCredential({
config: {
@@ -51,6 +45,5 @@ describe("hasConfiguredWebSearchCredential", () => {
bundledAllowlistCompat: true,
}),
).toBe(true);
expect(resolvePluginWebSearchProvidersMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,7 +1,6 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
import { resolvePluginWebSearchProviders } from "./web-search-providers.runtime.js";
function hasConfiguredCredentialValue(value: unknown): boolean {
if (typeof value === "string") {
@@ -74,29 +73,13 @@ export function hasConfiguredWebSearchCredential(params: {
const searchConfig =
params.searchConfig ??
(params.config.tools?.web?.search as Record<string, unknown> | undefined);
if (
!hasConfiguredSearchCredentialCandidate(searchConfig) &&
!hasConfiguredPluginWebSearchCandidate(params.config) &&
!hasManifestWebSearchEnvCredentialCandidate({
return (
hasConfiguredSearchCredentialCandidate(searchConfig) ||
hasConfiguredPluginWebSearchCandidate(params.config) ||
hasManifestWebSearchEnvCredentialCandidate({
config: params.config,
env: params.env,
origin: params.origin,
})
) {
return false;
}
return resolvePluginWebSearchProviders({
config: params.config,
env: params.env,
bundledAllowlistCompat: params.bundledAllowlistCompat ?? false,
origin: params.origin,
}).some((provider) => {
const configuredCredential =
provider.getConfiguredCredentialValue?.(params.config) ??
provider.getCredentialValue(searchConfig);
if (hasConfiguredCredentialValue(configuredCredential)) {
return true;
}
return provider.envVars.some((envVar) => hasConfiguredCredentialValue(params.env?.[envVar]));
});
);
}