diff --git a/CHANGELOG.md b/CHANGELOG.md index 2638f8ac8b3..623865fc563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Agents/sandbox: honor `tools.sandbox.tools.alsoAllow`, let explicit sandbox re-allows remove matching built-in default-deny tools, and keep sandbox explain/error guidance aligned with the effective sandbox tool policy. (#54492) Thanks @ngutman. - Feishu: close WebSocket connections on monitor stop/abort so ghost connections no longer persist, preventing duplicate event processing and resource leaks across restart cycles. (#52844) Thanks @schumilin. - Feishu: use the original message `create_time` instead of `Date.now()` for inbound timestamps so offline-retried messages carry the correct authoring time, preventing mis-targeted agent actions on stale instructions. (#52809) Thanks @schumilin. +- Plugins/SDK: thread `moduleUrl` through plugin-sdk alias resolution so user-installed plugins outside the openclaw directory (e.g. `~/.openclaw/extensions/`) correctly resolve `openclaw/plugin-sdk/*` subpath imports, and gate `plugin-sdk:check-exports` in `release:check`. (#54283) Thanks @xieyongliang. ## 2026.3.24-beta.2 diff --git a/package.json b/package.json index 04b667fb507..7f12a77e76c 100644 --- a/package.json +++ b/package.json @@ -686,7 +686,7 @@ "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift", "protocol:gen": "node --import tsx scripts/protocol-gen.ts", "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", - "release:check": "pnpm config:docs:check && pnpm plugin-sdk:api:check && node scripts/stage-bundled-plugin-runtime-deps.mjs && pnpm ui:build && node --import tsx scripts/release-check.ts", + "release:check": "pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:api:check && node scripts/stage-bundled-plugin-runtime-deps.mjs && pnpm ui:build && node --import tsx scripts/release-check.ts", "release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts", "release:openclaw:npm:verify-published": "node --import tsx scripts/openclaw-npm-postpublish-verify.ts", "release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts", diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index a864969d6a1..1472cd2cfee 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -725,7 +725,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const jitiLoaders = new Map>(); const getJiti = (modulePath: string) => { const tryNative = shouldPreferNativeJiti(modulePath); - const aliasMap = buildPluginLoaderAliasMap(modulePath); + // Pass loader's moduleUrl so the openclaw root can always be resolved even when + // loading external plugins from outside the installation directory (e.g. ~/.openclaw/extensions/). + const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url); const cacheKey = JSON.stringify({ tryNative, aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)), diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index 8646e5842ef..b639214f755 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -498,6 +498,53 @@ describe("plugin sdk alias helpers", () => { ); }); + it("resolves plugin-sdk aliases for user-installed plugins via moduleUrl hint", () => { + const fixture = createPluginSdkAliasFixture({ + srcFile: "channel-runtime.ts", + distFile: "channel-runtime.js", + packageExports: { + "./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" }, + }, + }); + const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs"); + fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8"); + const externalPluginRoot = path.join(makeTempDir(), ".openclaw", "extensions", "demo"); + const externalPluginEntry = path.join(externalPluginRoot, "index.ts"); + mkdirSafe(externalPluginRoot); + fs.writeFileSync(externalPluginEntry, 'export const plugin = "demo";\n', "utf-8"); + + // Simulate loader.ts passing its own import.meta.url as the moduleUrl hint. + // This covers installations where argv1 does not resolve to the openclaw root + // (e.g. single-binary distributions or custom process launchers). + // Use openclaw.mjs which is created by createPluginSdkAliasFixture (bin+marker mode). + // Use fixture.root as cwd so process.cwd() fallback also resolves to fixture, not the + // real openclaw repo root in the test runner environment. + const loaderModuleUrl = pathToFileURL(path.join(fixture.root, "openclaw.mjs")).href; + + // Use externalPluginRoot as cwd so process.cwd() fallback cannot accidentally + // resolve to the fixture root — only the moduleUrl hint can bridge the gap. + // Pass "" for argv1: undefined would trigger the STARTUP_ARGV1 default (the vitest + // runner binary, inside the openclaw repo), which resolves before moduleUrl is checked. + // An empty string is falsy so resolveTrustedOpenClawRootFromArgvHint returns null, + // meaning only the moduleUrl hint can bridge the gap. + const aliases = withCwd(externalPluginRoot, () => + withEnv({ NODE_ENV: undefined }, () => + buildPluginLoaderAliasMap( + externalPluginEntry, + "", // explicitly disable argv1 (empty string bypasses STARTUP_ARGV1 default) + loaderModuleUrl, + ), + ), + ); + + expect(fs.realpathSync(aliases["openclaw/plugin-sdk"] ?? "")).toBe( + fs.realpathSync(sourceRootAlias), + ); + expect(fs.realpathSync(aliases["openclaw/plugin-sdk/channel-runtime"] ?? "")).toBe( + fs.realpathSync(path.join(fixture.root, "src", "plugin-sdk", "channel-runtime.ts")), + ); + }); + it("does not resolve plugin-sdk alias files from cwd fallback when package root is not an OpenClaw root", () => { const fixture = createPluginSdkAliasFixture({ srcFile: "channel-runtime.ts", diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 983eb94f3bf..a8df6a903e2 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -235,10 +235,14 @@ const cachedPluginSdkExportedSubpaths = new Map(); const cachedPluginSdkScopedAliasMaps = new Map>(); export function listPluginSdkExportedSubpaths( - params: { modulePath?: string; argv1?: string } = {}, + params: { modulePath?: string; argv1?: string; moduleUrl?: string } = {}, ): string[] { const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); - const packageRoot = resolveLoaderPluginSdkPackageRoot({ modulePath, argv1: params.argv1 }); + const packageRoot = resolveLoaderPluginSdkPackageRoot({ + modulePath, + argv1: params.argv1, + moduleUrl: params.moduleUrl, + }); if (!packageRoot) { return []; } @@ -252,10 +256,14 @@ export function listPluginSdkExportedSubpaths( } export function resolvePluginSdkScopedAliasMap( - params: { modulePath?: string; argv1?: string } = {}, + params: { modulePath?: string; argv1?: string; moduleUrl?: string } = {}, ): Record { const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); - const packageRoot = resolveLoaderPluginSdkPackageRoot({ modulePath, argv1: params.argv1 }); + const packageRoot = resolveLoaderPluginSdkPackageRoot({ + modulePath, + argv1: params.argv1, + moduleUrl: params.moduleUrl, + }); if (!packageRoot) { return {}; } @@ -269,7 +277,11 @@ export function resolvePluginSdkScopedAliasMap( return cached; } const aliasMap: Record = {}; - for (const subpath of listPluginSdkExportedSubpaths({ modulePath, argv1: params.argv1 })) { + for (const subpath of listPluginSdkExportedSubpaths({ + modulePath, + argv1: params.argv1, + moduleUrl: params.moduleUrl, + })) { const candidateMap = { src: path.join(packageRoot, "src", "plugin-sdk", `${subpath}.ts`), dist: path.join(packageRoot, "dist", "plugin-sdk", `${subpath}.js`), @@ -317,18 +329,20 @@ export function resolveExtensionApiAlias(params: LoaderModuleResolveParams = {}) export function buildPluginLoaderAliasMap( modulePath: string, argv1: string | undefined = STARTUP_ARGV1, + moduleUrl?: string, ): Record { const pluginSdkAlias = resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs", modulePath, argv1, + moduleUrl, }); const extensionApiAlias = resolveExtensionApiAlias({ modulePath }); return { ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), - ...resolvePluginSdkScopedAliasMap({ modulePath, argv1 }), + ...resolvePluginSdkScopedAliasMap({ modulePath, argv1, moduleUrl }), }; }