mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 20:10:42 +00:00
Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
109
src/plugins/bundled-source-overlays.ts
Normal file
109
src/plugins/bundled-source-overlays.ts
Normal 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));
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
94
src/plugins/install-paths.ts
Normal file
94
src/plugins/install-paths.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
412
src/plugins/package-entry-resolution.ts
Normal file
412
src/plugins/package-entry-resolution.ts
Normal 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] : [];
|
||||
});
|
||||
}
|
||||
27
src/plugins/package-entrypoints.ts
Normal file
27
src/plugins/package-entrypoints.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -105,6 +105,9 @@ export function resolveManifestDeclaredWebProviderCandidatePluginIds(params: {
|
||||
if (ids.length > 0) {
|
||||
return ids;
|
||||
}
|
||||
if (params.origin || scopedPluginIds !== undefined) {
|
||||
return [];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]));
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user