diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index f48c794b668..a8115f1644a 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -62,24 +62,57 @@ jobs: run: | docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version' - # This smoke only validates that the build-arg path preinstalls selected - # extension deps without breaking image build or basic CLI startup. It - # does not exercise runtime loading/registration of diagnostics-otel. + # This smoke validates that the build-arg path preinstalls selected + # extension deps and that matrix plugin discovery stays healthy in the + # final runtime image. - name: Build extension Dockerfile smoke image uses: useblacksmith/build-push-action@v2 with: context: . file: ./Dockerfile build-args: | - OPENCLAW_EXTENSIONS=diagnostics-otel + OPENCLAW_EXTENSIONS=matrix tags: openclaw-ext-smoke:local load: true push: false provenance: false - - name: Smoke test Dockerfile with extension build arg + - name: Smoke test Dockerfile with matrix extension build arg run: | - docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc 'which openclaw && openclaw --version' + docker run --rm --entrypoint sh openclaw-ext-smoke:local -lc ' + which openclaw && + openclaw --version && + node -e " + const Module = require(\"node:module\"); + const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\"); + requireFromMatrix.resolve(\"@vector-im/matrix-bot-sdk/package.json\"); + requireFromMatrix.resolve(\"@matrix-org/matrix-sdk-crypto-nodejs/package.json\"); + const { spawnSync } = require(\"node:child_process\"); + const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" }); + if (run.status !== 0) { + process.stderr.write(run.stderr || run.stdout || \"plugins list failed\\n\"); + process.exit(run.status ?? 1); + } + const parsed = JSON.parse(run.stdout); + const matrix = (parsed.plugins || []).find((entry) => entry.id === \"matrix\"); + if (!matrix) { + throw new Error(\"matrix plugin missing from bundled plugin list\"); + } + const matrixDiag = (parsed.diagnostics || []).filter( + (diag) => + typeof diag.source === \"string\" && + diag.source.includes(\"/extensions/matrix\") && + typeof diag.message === \"string\" && + diag.message.includes(\"extension entry escapes package directory\"), + ); + if (matrixDiag.length > 0) { + throw new Error( + \"unexpected matrix diagnostics: \" + + matrixDiag.map((diag) => diag.message).join(\"; \"), + ); + } + " + ' - name: Build installer smoke image uses: useblacksmith/build-push-action@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index c499097a822..75a7ee7e92f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -134,6 +134,7 @@ Docs: https://docs.openclaw.ai - Gateway/channels: serialize per-account channel startup so overlapping starts do not boot the same provider twice, preventing MS Teams `EADDRINUSE` crash loops during startup and restart. (#49583) Thanks @sudie-codes. - Tests/OpenAI Codex auth: align login expectations with the default `gpt-5.4` model so CI coverage stays consistent with the current OpenAI Codex default. (#44367) Thanks @jrrcdev. - Discord: enforce strict DM component allowlist auth (#49997) Thanks @joshavant. +- Stabilize plugin loader and Docker extension smoke (#50058) Thanks @joshavant. ### Fixes diff --git a/Dockerfile b/Dockerfile index b2af00c3b40..fa97f83323a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -146,6 +146,10 @@ COPY --from=runtime-assets --chown=node:node /app/extensions ./extensions COPY --from=runtime-assets --chown=node:node /app/skills ./skills COPY --from=runtime-assets --chown=node:node /app/docs ./docs +# In npm-installed Docker images, prefer the copied source extension tree for +# bundled discovery so package metadata that points at source entries stays valid. +ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/extensions + # Keep pnpm available in the runtime image for container-local workflows. # Use a shared Corepack home so the non-root `node` user does not need a # first-run network fetch when invoking pnpm. diff --git a/src/dockerfile.test.ts b/src/dockerfile.test.ts index bf6aeb21440..2570a8ed9dc 100644 --- a/src/dockerfile.test.ts +++ b/src/dockerfile.test.ts @@ -41,11 +41,17 @@ describe("Dockerfile", () => { const dockerfile = await readFile(dockerfilePath, "utf8"); expect(dockerfile).toContain("FROM build AS runtime-assets"); expect(dockerfile).toContain("CI=true pnpm prune --prod"); + expect(dockerfile).not.toContain('npm install --prefix "extensions/$ext" --omit=dev --silent'); expect(dockerfile).toContain( "COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules", ); }); + it("pins bundled plugin discovery to copied source extensions in runtime images", async () => { + const dockerfile = await readFile(dockerfilePath, "utf8"); + expect(dockerfile).toContain("ENV OPENCLAW_BUNDLED_PLUGINS_DIR=/app/extensions"); + }); + it("normalizes plugin and agent paths permissions in image layers", async () => { const dockerfile = await readFile(dockerfilePath, "utf8"); expect(dockerfile).toContain("for dir in /app/extensions /app/.agent /app/.agents"); diff --git a/src/plugin-sdk/channel-import-guardrails.test.ts b/src/plugin-sdk/channel-import-guardrails.test.ts index 29ca632425f..9b481097ed6 100644 --- a/src/plugin-sdk/channel-import-guardrails.test.ts +++ b/src/plugin-sdk/channel-import-guardrails.test.ts @@ -170,6 +170,10 @@ function readSource(path: string): string { return readFileSync(resolve(ROOT_DIR, "..", path), "utf8"); } +function normalizePath(path: string): string { + return path.replaceAll("\\", "/"); +} + function readSetupBarrelImportBlock(path: string): string { const lines = readSource(path).split("\n"); const targetLineIndex = lines.findIndex((line) => @@ -186,10 +190,10 @@ function readSetupBarrelImportBlock(path: string): string { } function collectExtensionSourceFiles(): string[] { - const extensionsDir = resolve(ROOT_DIR, "..", "extensions"); - const sharedExtensionsDir = resolve(extensionsDir, "shared"); + const extensionsDir = normalizePath(resolve(ROOT_DIR, "..", "extensions")); + const sharedExtensionsDir = normalizePath(resolve(extensionsDir, "shared")); const files: string[] = []; - const stack = [extensionsDir]; + const stack = [resolve(ROOT_DIR, "..", "extensions")]; while (stack.length > 0) { const current = stack.pop(); if (!current) { @@ -197,6 +201,7 @@ function collectExtensionSourceFiles(): string[] { } for (const entry of readdirSync(current, { withFileTypes: true })) { const fullPath = resolve(current, entry.name); + const normalizedFullPath = normalizePath(fullPath); if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { continue; @@ -207,18 +212,18 @@ function collectExtensionSourceFiles(): string[] { if (!entry.isFile() || !/\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(entry.name)) { continue; } - if (entry.name.endsWith(".d.ts") || fullPath.includes(sharedExtensionsDir)) { + if (entry.name.endsWith(".d.ts") || normalizedFullPath.includes(sharedExtensionsDir)) { continue; } - if (fullPath.includes(`${resolve(ROOT_DIR, "..", "extensions")}/shared/`)) { + if (normalizedFullPath.includes(`${extensionsDir}/shared/`)) { continue; } if ( - fullPath.includes(".test.") || - fullPath.includes(".test-") || - fullPath.includes(".fixture.") || - fullPath.includes(".snap") || - fullPath.includes("test-support") || + normalizedFullPath.includes(".test.") || + normalizedFullPath.includes(".test-") || + normalizedFullPath.includes(".fixture.") || + normalizedFullPath.includes(".snap") || + normalizedFullPath.includes("test-support") || entry.name === "api.ts" || entry.name === "runtime-api.ts" ) { @@ -232,6 +237,7 @@ function collectExtensionSourceFiles(): string[] { function collectCoreSourceFiles(): string[] { const srcDir = resolve(ROOT_DIR, "..", "src"); + const normalizedPluginSdkDir = normalizePath(resolve(ROOT_DIR, "plugin-sdk")); const files: string[] = []; const stack = [srcDir]; while (stack.length > 0) { @@ -241,6 +247,7 @@ function collectCoreSourceFiles(): string[] { } for (const entry of readdirSync(current, { withFileTypes: true })) { const fullPath = resolve(current, entry.name); + const normalizedFullPath = normalizePath(fullPath); if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { continue; @@ -255,14 +262,14 @@ function collectCoreSourceFiles(): string[] { continue; } if ( - fullPath.includes(".test.") || - fullPath.includes(".mock-harness.") || - fullPath.includes(".spec.") || - fullPath.includes(".fixture.") || - fullPath.includes(".snap") || + normalizedFullPath.includes(".test.") || + normalizedFullPath.includes(".mock-harness.") || + normalizedFullPath.includes(".spec.") || + normalizedFullPath.includes(".fixture.") || + normalizedFullPath.includes(".snap") || // src/plugin-sdk is the curated bridge layer; validate its contracts with dedicated // plugin-sdk guardrails instead of the generic "core should not touch extensions" rule. - fullPath.includes(`${resolve(ROOT_DIR, "plugin-sdk")}/`) + normalizedFullPath.includes(`${normalizedPluginSdkDir}/`) ) { continue; } @@ -283,6 +290,7 @@ function collectExtensionFiles(extensionId: string): string[] { } for (const entry of readdirSync(current, { withFileTypes: true })) { const fullPath = resolve(current, entry.name); + const normalizedFullPath = normalizePath(fullPath); if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "coverage") { continue; @@ -297,11 +305,11 @@ function collectExtensionFiles(extensionId: string): string[] { continue; } if ( - fullPath.includes(".test.") || - fullPath.includes(".test-") || - fullPath.includes(".spec.") || - fullPath.includes(".fixture.") || - fullPath.includes(".snap") || + normalizedFullPath.includes(".test.") || + normalizedFullPath.includes(".test-") || + normalizedFullPath.includes(".spec.") || + normalizedFullPath.includes(".fixture.") || + normalizedFullPath.includes(".snap") || entry.name === "runtime-api.ts" ) { continue; @@ -392,6 +400,16 @@ describe("channel import guardrails", () => { } }); + it("keeps bundled extension source files off legacy core send-deps src imports", () => { + const legacyCoreSendDepsImport = /["'][^"']*src\/infra\/outbound\/send-deps\.[cm]?[jt]s["']/; + for (const file of collectExtensionSourceFiles()) { + const text = readFileSync(file, "utf8"); + expect(text, `${file} should not import src/infra/outbound/send-deps.*`).not.toMatch( + legacyCoreSendDepsImport, + ); + } + }); + it("keeps core production files off extension private src imports", () => { for (const file of collectCoreSourceFiles()) { const text = readFileSync(file, "utf8"); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index edc172e03d0..fc0f6c2f208 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -101,6 +101,16 @@ function makeTempDir() { return dir; } +function withCwd(cwd: string, run: () => T): T { + const previousCwd = process.cwd(); + process.chdir(cwd); + try { + return run(); + } finally { + process.chdir(previousCwd); + } +} + function writePlugin(params: { id: string; body: string; @@ -299,17 +309,43 @@ function createPluginSdkAliasFixture(params?: { distFile?: string; srcBody?: string; distBody?: string; + packageName?: string; + packageExports?: Record; + trustedRootIndicators?: boolean; + trustedRootIndicatorMode?: "bin+marker" | "cli-entry-only" | "none"; }) { const root = makeTempDir(); const srcFile = path.join(root, "src", "plugin-sdk", params?.srcFile ?? "index.ts"); const distFile = path.join(root, "dist", "plugin-sdk", params?.distFile ?? "index.js"); mkdirSafe(path.dirname(srcFile)); mkdirSafe(path.dirname(distFile)); - fs.writeFileSync( - path.join(root, "package.json"), - JSON.stringify({ name: "openclaw", type: "module" }, null, 2), - "utf-8", - ); + const trustedRootIndicatorMode = + params?.trustedRootIndicatorMode ?? + (params?.trustedRootIndicators === false ? "none" : "bin+marker"); + const packageJson: Record = { + name: params?.packageName ?? "openclaw", + type: "module", + }; + if (trustedRootIndicatorMode === "bin+marker") { + packageJson.bin = { + openclaw: "openclaw.mjs", + }; + } + if (params?.packageExports || trustedRootIndicatorMode === "cli-entry-only") { + const trustedExports: Record = + trustedRootIndicatorMode === "cli-entry-only" + ? { "./cli-entry": { default: "./dist/cli-entry.js" } } + : {}; + packageJson.exports = { + "./plugin-sdk": { default: "./dist/plugin-sdk/index.js" }, + ...trustedExports, + ...params?.packageExports, + }; + } + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(packageJson, null, 2), "utf-8"); + if (trustedRootIndicatorMode === "bin+marker") { + fs.writeFileSync(path.join(root, "openclaw.mjs"), "export {};\n", "utf-8"); + } fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8"); fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8"); return { root, srcFile, distFile }; @@ -3326,10 +3362,126 @@ module.exports = { }); it("derives plugin-sdk subpaths from package exports", () => { - const subpaths = __testing.listPluginSdkExportedSubpaths(); - expect(subpaths).toContain("telegram"); - expect(subpaths).not.toContain("compat"); - expect(subpaths).not.toContain("root-alias"); + const fixture = createPluginSdkAliasFixture({ + packageExports: { + "./plugin-sdk/compat": { default: "./dist/plugin-sdk/compat.js" }, + "./plugin-sdk/telegram": { default: "./dist/plugin-sdk/telegram.js" }, + "./plugin-sdk/nested/value": { default: "./dist/plugin-sdk/nested/value.js" }, + }, + }); + const subpaths = __testing.listPluginSdkExportedSubpaths({ + modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"), + }); + expect(subpaths).toEqual(["compat", "telegram"]); + }); + + it("derives plugin-sdk subpaths from nearest package exports even when package name is renamed", () => { + const fixture = createPluginSdkAliasFixture({ + packageName: "moltbot", + packageExports: { + "./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" }, + "./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" }, + "./plugin-sdk/compat": { default: "./dist/plugin-sdk/compat.js" }, + }, + }); + const subpaths = __testing.listPluginSdkExportedSubpaths({ + modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"), + }); + expect(subpaths).toEqual(["channel-runtime", "compat", "core"]); + }); + + it("derives plugin-sdk subpaths via cwd fallback when module path is a transpiler cache and package is renamed", () => { + const fixture = createPluginSdkAliasFixture({ + packageName: "moltbot", + packageExports: { + "./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" }, + "./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" }, + }, + }); + const subpaths = withCwd(fixture.root, () => + __testing.listPluginSdkExportedSubpaths({ + modulePath: "/tmp/tsx-cache/openclaw-loader.js", + }), + ); + expect(subpaths).toEqual(["channel-runtime", "core"]); + }); + + it("resolves plugin-sdk alias files via cwd fallback when module path is a transpiler cache and package is renamed", () => { + const fixture = createPluginSdkAliasFixture({ + srcFile: "channel-runtime.ts", + distFile: "channel-runtime.js", + packageName: "moltbot", + packageExports: { + "./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" }, + }, + }); + const resolved = withCwd(fixture.root, () => + resolvePluginSdkAlias({ + root: fixture.root, + srcFile: "channel-runtime.ts", + distFile: "channel-runtime.js", + modulePath: "/tmp/tsx-cache/openclaw-loader.js", + env: { NODE_ENV: undefined }, + }), + ); + expect(resolved).not.toBeNull(); + expect(fs.realpathSync(resolved ?? "")).toBe(fs.realpathSync(fixture.srcFile)); + }); + + it("does not derive plugin-sdk subpaths from cwd fallback when package root is not an OpenClaw root", () => { + const fixture = createPluginSdkAliasFixture({ + packageName: "moltbot", + trustedRootIndicators: false, + packageExports: { + "./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" }, + "./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" }, + }, + }); + const subpaths = withCwd(fixture.root, () => + __testing.listPluginSdkExportedSubpaths({ + modulePath: "/tmp/tsx-cache/openclaw-loader.js", + }), + ); + expect(subpaths).toEqual([]); + }); + + it("derives plugin-sdk subpaths via cwd fallback when trusted root indicator is cli-entry export", () => { + const fixture = createPluginSdkAliasFixture({ + packageName: "moltbot", + trustedRootIndicatorMode: "cli-entry-only", + packageExports: { + "./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" }, + "./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" }, + }, + }); + const subpaths = withCwd(fixture.root, () => + __testing.listPluginSdkExportedSubpaths({ + modulePath: "/tmp/tsx-cache/openclaw-loader.js", + }), + ); + expect(subpaths).toEqual(["channel-runtime", "core"]); + }); + + 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", + distFile: "channel-runtime.js", + packageName: "moltbot", + trustedRootIndicators: false, + packageExports: { + "./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" }, + }, + }); + const resolved = withCwd(fixture.root, () => + resolvePluginSdkAlias({ + root: fixture.root, + srcFile: "channel-runtime.ts", + distFile: "channel-runtime.js", + modulePath: "/tmp/tsx-cache/openclaw-loader.js", + env: { NODE_ENV: undefined }, + }), + ); + expect(resolved).toBeNull(); }); it("configures the plugin loader jiti boundary to prefer native dist modules", () => { @@ -3361,22 +3513,152 @@ module.exports = { "src", "channel.runtime.ts", ); - const discordVoiceRuntime = path.join( - process.cwd(), - "extensions", - "discord", - "src", - "voice", - "manager.runtime.ts", - ); await expect(jiti.import(discordChannelRuntime)).resolves.toMatchObject({ discordSetupWizard: expect.any(Object), }); - await expect(jiti.import(discordVoiceRuntime)).resolves.toMatchObject({ - DiscordVoiceManager: expect.any(Function), - DiscordVoiceReadyListener: expect.any(Function), + }, 240_000); + + it("loads copied imessage runtime sources from git-style paths with plugin-sdk aliases (#49806)", async () => { + const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage"); + const copiedSourceDir = path.join(copiedExtensionRoot, "src"); + const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk"); + mkdirSafe(copiedSourceDir); + mkdirSafe(copiedPluginSdkDir); + const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs"); + fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8"); + fs.writeFileSync( + path.join(copiedSourceDir, "channel.runtime.ts"), + `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js"; + +export const copiedRuntimeMarker = { + resolveOutboundSendDep, + PAIRING_APPROVED_MESSAGE, +}; +`, + "utf-8", + ); + fs.writeFileSync( + path.join(copiedExtensionRoot, "runtime-api.ts"), + `export const PAIRING_APPROVED_MESSAGE = "paired"; +`, + "utf-8", + ); + const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "channel-runtime.ts"); + fs.writeFileSync( + copiedChannelRuntimeShim, + `export function resolveOutboundSendDep() { + return "shimmed"; +} +`, + "utf-8", + ); + const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts"); + const jitiBaseUrl = pathToFileURL(jitiBaseFile).href; + + const withoutAlias = createJiti(jitiBaseUrl, { + ...__testing.buildPluginLoaderJitiOptions({}), + tryNative: false, }); + await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow( + /plugin-sdk\/channel-runtime/, + ); + + const withAlias = createJiti(jitiBaseUrl, { + ...__testing.buildPluginLoaderJitiOptions({ + "openclaw/plugin-sdk/channel-runtime": copiedChannelRuntimeShim, + }), + tryNative: false, + }); + await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({ + copiedRuntimeMarker: { + PAIRING_APPROVED_MESSAGE: "paired", + resolveOutboundSendDep: expect.any(Function), + }, + }); + }); + + it("loads git-style package extension entries through the plugin loader when they import plugin-sdk channel-runtime (#49806)", () => { + useNoBundledPlugins(); + const pluginId = "imessage-loader-regression"; + const gitExtensionRoot = path.join( + makeTempDir(), + "git-source-checkout", + "extensions", + pluginId, + ); + const gitSourceDir = path.join(gitExtensionRoot, "src"); + mkdirSafe(gitSourceDir); + + fs.writeFileSync( + path.join(gitExtensionRoot, "package.json"), + JSON.stringify( + { + name: `@openclaw/${pluginId}`, + version: "0.0.1", + type: "module", + openclaw: { + extensions: ["./src/index.ts"], + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(gitExtensionRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: pluginId, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(gitSourceDir, "channel.runtime.ts"), + `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; + +export function runtimeProbeType() { + return typeof resolveOutboundSendDep; +} +`, + "utf-8", + ); + fs.writeFileSync( + path.join(gitSourceDir, "index.ts"), + `import { runtimeProbeType } from "./channel.runtime.ts"; + +export default { + id: ${JSON.stringify(pluginId)}, + register() { + if (runtimeProbeType() !== "function") { + throw new Error("channel-runtime import did not resolve"); + } + }, +}; +`, + "utf-8", + ); + + const registry = withEnv({ NODE_ENV: "production", VITEST: undefined }, () => + loadOpenClawPlugins({ + cache: false, + workspaceDir: gitExtensionRoot, + config: { + plugins: { + load: { paths: [gitExtensionRoot] }, + allow: [pluginId], + }, + }, + }), + ); + const record = registry.plugins.find((entry) => entry.id === pluginId); + expect(record?.status).toBe("loaded"); }); it("loads source TypeScript plugins that route through local runtime shims", () => { diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 7f172b8d3dd..df8ec526271 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -12,10 +12,83 @@ export type LoaderModuleResolveParams = { moduleUrl?: string; }; +type PluginSdkPackageJson = { + exports?: Record; + bin?: string | Record; +}; + function resolveLoaderModulePath(params: LoaderModuleResolveParams = {}): string { return params.modulePath ?? fileURLToPath(params.moduleUrl ?? import.meta.url); } +function readPluginSdkPackageJson(packageRoot: string): PluginSdkPackageJson | null { + try { + const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"); + return JSON.parse(pkgRaw) as PluginSdkPackageJson; + } catch { + return null; + } +} + +function listPluginSdkSubpathsFromPackageJson(pkg: PluginSdkPackageJson): string[] { + return Object.keys(pkg.exports ?? {}) + .filter((key) => key.startsWith("./plugin-sdk/")) + .map((key) => key.slice("./plugin-sdk/".length)) + .filter((subpath) => Boolean(subpath) && !subpath.includes("/")) + .toSorted(); +} + +function hasTrustedOpenClawRootIndicator(params: { + packageRoot: string; + packageJson: PluginSdkPackageJson; +}): boolean { + const packageExports = params.packageJson.exports ?? {}; + const hasPluginSdkRootExport = Object.prototype.hasOwnProperty.call( + packageExports, + "./plugin-sdk", + ); + if (!hasPluginSdkRootExport) { + return false; + } + const hasCliEntryExport = Object.prototype.hasOwnProperty.call(packageExports, "./cli-entry"); + const hasOpenClawBin = + (typeof params.packageJson.bin === "string" && + params.packageJson.bin.toLowerCase().includes("openclaw")) || + (typeof params.packageJson.bin === "object" && + params.packageJson.bin !== null && + typeof params.packageJson.bin.openclaw === "string"); + const hasOpenClawEntrypoint = fs.existsSync(path.join(params.packageRoot, "openclaw.mjs")); + return hasCliEntryExport || hasOpenClawBin || hasOpenClawEntrypoint; +} + +function readPluginSdkSubpathsFromPackageRoot(packageRoot: string): string[] | null { + const pkg = readPluginSdkPackageJson(packageRoot); + if (!pkg) { + return null; + } + if (!hasTrustedOpenClawRootIndicator({ packageRoot, packageJson: pkg })) { + return null; + } + const subpaths = listPluginSdkSubpathsFromPackageJson(pkg); + return subpaths.length > 0 ? subpaths : null; +} + +function findNearestPluginSdkPackageRoot(startDir: string, maxDepth = 12): string | null { + let cursor = path.resolve(startDir); + for (let i = 0; i < maxDepth; i += 1) { + const subpaths = readPluginSdkSubpathsFromPackageRoot(cursor); + if (subpaths) { + return cursor; + } + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + return null; +} + export function resolveLoaderPackageRoot( params: LoaderModuleResolveParams & { modulePath: string }, ): string | null { @@ -33,6 +106,28 @@ export function resolveLoaderPackageRoot( }); } +function resolveLoaderPluginSdkPackageRoot( + params: LoaderModuleResolveParams & { modulePath: string }, +): string | null { + const cwd = params.cwd ?? path.dirname(params.modulePath); + const fromCwd = resolveOpenClawPackageRootSync({ cwd }); + const fromExplicitHints = + params.argv1 || params.moduleUrl + ? resolveOpenClawPackageRootSync({ + cwd, + ...(params.argv1 ? { argv1: params.argv1 } : {}), + ...(params.moduleUrl ? { moduleUrl: params.moduleUrl } : {}), + }) + : null; + return ( + fromCwd ?? + fromExplicitHints ?? + findNearestPluginSdkPackageRoot(path.dirname(params.modulePath)) ?? + (params.cwd ? findNearestPluginSdkPackageRoot(params.cwd) : null) ?? + findNearestPluginSdkPackageRoot(process.cwd()) + ); +} + export function resolvePluginSdkAliasCandidateOrder(params: { modulePath: string; isProduction: boolean; @@ -54,7 +149,7 @@ export function listPluginSdkAliasCandidates(params: { modulePath: params.modulePath, isProduction: process.env.NODE_ENV === "production", }); - const packageRoot = resolveLoaderPackageRoot(params); + const packageRoot = resolveLoaderPluginSdkPackageRoot(params); if (packageRoot) { const candidateMap = { src: path.join(packageRoot, "src", "plugin-sdk", params.srcFile), @@ -113,9 +208,7 @@ const cachedPluginSdkExportedSubpaths = new Map(); export function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); - const packageRoot = resolveOpenClawPackageRootSync({ - cwd: path.dirname(modulePath), - }); + const packageRoot = resolveLoaderPluginSdkPackageRoot({ modulePath }); if (!packageRoot) { return []; } @@ -123,21 +216,9 @@ export function listPluginSdkExportedSubpaths(params: { modulePath?: string } = if (cached) { return cached; } - try { - const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8"); - const pkg = JSON.parse(pkgRaw) as { - exports?: Record; - }; - const subpaths = Object.keys(pkg.exports ?? {}) - .filter((key) => key.startsWith("./plugin-sdk/")) - .map((key) => key.slice("./plugin-sdk/".length)) - .filter((subpath) => Boolean(subpath) && !subpath.includes("/")) - .toSorted(); - cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths); - return subpaths; - } catch { - return []; - } + const subpaths = readPluginSdkSubpathsFromPackageRoot(packageRoot) ?? []; + cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths); + return subpaths; } export function resolvePluginSdkScopedAliasMap(