From e2dac5d5cbf6e2c395e294e7569b13afa2e758c7 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Sun, 15 Mar 2026 21:16:27 +0200 Subject: [PATCH] fix(plugins): load bundled extensions from dist (#47560) --- CHANGELOG.md | 1 + extensions/llm-task/src/llm-task-tool.test.ts | 4 +- extensions/llm-task/src/llm-task-tool.ts | 34 +---------- extensions/whatsapp/src/channel.ts | 8 +-- extensions/whatsapp/src/runtime.ts | 3 +- package.json | 5 +- scripts/copy-bundled-plugin-metadata.mjs | 57 +++++++++++++++++++ src/plugin-sdk/subpaths.test.ts | 5 ++ src/plugin-sdk/whatsapp.ts | 6 ++ src/plugins/loader.test.ts | 36 ++++++++++++ src/plugins/loader.ts | 33 +++++++++++ tsconfig.json | 1 + tsdown.config.ts | 53 +++++++++++++++++ vitest.config.ts | 4 ++ 14 files changed, 206 insertions(+), 44 deletions(-) create mode 100644 scripts/copy-bundled-plugin-metadata.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbfb3f0924..fc9aa9435ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai - CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc. - CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc. - CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc. +- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. ## 2026.3.13 diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts index 2bf0cb655aa..49feb7929ff 100644 --- a/extensions/llm-task/src/llm-task-tool.test.ts +++ b/extensions/llm-task/src/llm-task-tool.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -vi.mock("../../../src/agents/pi-embedded-runner.js", () => { +vi.mock("openclaw/extension-api", () => { return { runEmbeddedPiAgent: vi.fn(async () => ({ meta: { startedAt: Date.now() }, @@ -9,7 +9,7 @@ vi.mock("../../../src/agents/pi-embedded-runner.js", () => { }; }); -import { runEmbeddedPiAgent } from "../../../src/agents/pi-embedded-runner.js"; +import { runEmbeddedPiAgent } from "openclaw/extension-api"; import { createLlmTaskTool } from "./llm-task-tool.js"; // oxlint-disable-next-line typescript/no-explicit-any diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts index ff2037e534a..d79e0a51130 100644 --- a/extensions/llm-task/src/llm-task-tool.ts +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { Type } from "@sinclair/typebox"; import Ajv from "ajv"; +import { runEmbeddedPiAgent } from "openclaw/extension-api"; import { formatThinkingLevels, formatXHighModelHint, @@ -9,39 +10,8 @@ import { resolvePreferredOpenClawTmpDir, supportsXHighThinking, } from "openclaw/plugin-sdk/llm-task"; -// NOTE: This extension is intended to be bundled with OpenClaw. -// When running from source (tests/dev), OpenClaw internals live under src/. -// When running from a built install, internals live under dist/ (no src/ tree). -// So we resolve internal imports dynamically with src-first, dist-fallback. import type { OpenClawPluginApi } from "openclaw/plugin-sdk/llm-task"; -type RunEmbeddedPiAgentFn = (params: Record) => Promise; - -async function loadRunEmbeddedPiAgent(): Promise { - // Source checkout (tests/dev) - try { - const mod = await import("../../../src/agents/pi-embedded-runner.js"); - // oxlint-disable-next-line typescript/no-explicit-any - if (typeof (mod as any).runEmbeddedPiAgent === "function") { - // oxlint-disable-next-line typescript/no-explicit-any - return (mod as any).runEmbeddedPiAgent; - } - } catch { - // ignore - } - - // Bundled install (built) - // NOTE: there is no src/ tree in a packaged install. Prefer a stable internal entrypoint. - const distExtensionApi = "../../../dist/extensionAPI.js"; - const mod = (await import(distExtensionApi)) as { runEmbeddedPiAgent?: unknown }; - // oxlint-disable-next-line typescript/no-explicit-any - const fn = (mod as any).runEmbeddedPiAgent; - if (typeof fn !== "function") { - throw new Error("Internal error: runEmbeddedPiAgent not available"); - } - return fn as RunEmbeddedPiAgentFn; -} - function stripCodeFences(s: string): string { const trimmed = s.trim(); const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); @@ -209,8 +179,6 @@ export function createLlmTaskTool(api: OpenClawPluginApi) { const sessionId = `llm-task-${Date.now()}`; const sessionFile = path.join(tmpDir, "session.json"); - const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent(); - const result = await runEmbeddedPiAgent({ sessionId, sessionFile, diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 8a60dc44432..1745f8caa74 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,11 +1,9 @@ -import { - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, -} from "openclaw/plugin-sdk/compat"; import { applyAccountNameToChannelSection, buildChannelConfigSchema, + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderGroupPolicyWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, createActionGate, createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index 13ace8243db..bf415eb17db 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -1,5 +1,4 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp"; +import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/whatsapp"; const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = createPluginRuntimeStore("WhatsApp runtime not initialized"); diff --git a/package.json b/package.json index 053e4bea2a3..2d880e80fe7 100644 --- a/package.json +++ b/package.json @@ -212,6 +212,7 @@ "types": "./dist/plugin-sdk/keyed-async-queue.d.ts", "default": "./dist/plugin-sdk/keyed-async-queue.js" }, + "./extension-api": "./dist/extensionAPI.js", "./cli-entry": "./openclaw.mjs" }, "scripts": { @@ -224,8 +225,8 @@ "android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.app/.MainActivity", "android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest", "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", - "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", - "build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && node scripts/copy-bundled-plugin-metadata.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build:docker": "node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && node scripts/copy-bundled-plugin-metadata.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json || true", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", diff --git a/scripts/copy-bundled-plugin-metadata.mjs b/scripts/copy-bundled-plugin-metadata.mjs new file mode 100644 index 00000000000..40d8baa5299 --- /dev/null +++ b/scripts/copy-bundled-plugin-metadata.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const repoRoot = process.cwd(); +const extensionsRoot = path.join(repoRoot, "extensions"); +const distExtensionsRoot = path.join(repoRoot, "dist", "extensions"); + +function rewritePackageExtensions(entries) { + if (!Array.isArray(entries)) { + return undefined; + } + + return entries + .filter((entry) => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => { + const normalized = entry.replace(/^\.\//, ""); + const rewritten = normalized.replace(/\.[^.]+$/u, ".js"); + return `./${rewritten}`; + }); +} + +for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + + const pluginDir = path.join(extensionsRoot, dirent.name); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + if (!fs.existsSync(manifestPath)) { + continue; + } + + const distPluginDir = path.join(distExtensionsRoot, dirent.name); + fs.mkdirSync(distPluginDir, { recursive: true }); + fs.copyFileSync(manifestPath, path.join(distPluginDir, "openclaw.plugin.json")); + + const packageJsonPath = path.join(pluginDir, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + if (packageJson.openclaw && "extensions" in packageJson.openclaw) { + packageJson.openclaw = { + ...packageJson.openclaw, + extensions: rewritePackageExtensions(packageJson.openclaw.extensions), + }; + } + + fs.writeFileSync( + path.join(distPluginDir, "package.json"), + `${JSON.stringify(packageJson, null, 2)}\n`, + "utf8", + ); +} diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 2d971c82255..e0d4827b879 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,3 +1,4 @@ +import * as extensionApi from "openclaw/extension-api"; import * as compatSdk from "openclaw/plugin-sdk/compat"; import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; @@ -132,4 +133,8 @@ describe("plugin-sdk subpath exports", () => { const zalo = await import("openclaw/plugin-sdk/zalo"); expect(typeof zalo.resolveClientIp).toBe("function"); }); + + it("exports the extension api bridge", () => { + expect(typeof extensionApi.runEmbeddedPiAgent).toBe("function"); + }); }); diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index f18a953bf7a..4ea4fa8d2de 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -25,6 +25,11 @@ export { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, } from "../channels/plugins/directory-config.js"; +export { + collectAllowlistProviderGroupPolicyWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, +} from "../channels/plugins/group-policy-warnings.js"; +export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; export { resolveWhatsAppOutboundTarget } from "../whatsapp/resolve-outbound-target.js"; export { @@ -44,5 +49,6 @@ export { resolveWhatsAppHeartbeatRecipients } from "../channels/plugins/whatsapp export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js"; export { createActionGate, readStringParam } from "../agents/tools/common.js"; +export { createPluginRuntimeStore } from "./runtime-store.js"; export { normalizeE164 } from "../utils.js"; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index ac6ff410268..e0d3a3537d0 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -284,6 +284,22 @@ function createPluginSdkAliasFixture(params?: { return { root, srcFile, distFile }; } +function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?: string }) { + const root = makeTempDir(); + const srcFile = path.join(root, "src", "extensionAPI.ts"); + const distFile = path.join(root, "dist", "extensionAPI.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", + ); + fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8"); + fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8"); + return { root, srcFile, distFile }; +} + afterEach(() => { clearPluginLoaderCache(); if (prevBundledDir === undefined) { @@ -2334,4 +2350,24 @@ describe("loadOpenClawPlugins", () => { ); expect(resolved).toBe(srcFile); }); + + it("prefers dist extension-api alias when loader runs from dist", () => { + const { root, distFile } = createExtensionApiAliasFixture(); + + const resolved = __testing.resolveExtensionApiAlias({ + modulePath: path.join(root, "dist", "plugins", "loader.js"), + }); + expect(resolved).toBe(distFile); + }); + + it("prefers src extension-api alias when loader runs from src in non-production", () => { + const { root, srcFile } = createExtensionApiAliasFixture(); + + const resolved = withEnv({ NODE_ENV: undefined }, () => + __testing.resolveExtensionApiAlias({ + modulePath: path.join(root, "src", "plugins", "loader.ts"), + }), + ); + expect(resolved).toBe(srcFile); + }); }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 253ad63afc4..20d5772d3f7 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -124,6 +124,36 @@ const resolvePluginSdkAliasFile = (params: { const resolvePluginSdkAlias = (): string | null => resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" }); +const resolveExtensionApiAlias = (params: { modulePath?: string } = {}): string | null => { + try { + const modulePath = params.modulePath ?? fileURLToPath(import.meta.url); + const packageRoot = resolveOpenClawPackageRootSync({ + cwd: path.dirname(modulePath), + }); + if (!packageRoot) { + return null; + } + + const orderedKinds = resolvePluginSdkAliasCandidateOrder({ + modulePath, + isProduction: process.env.NODE_ENV === "production", + }); + const candidateMap = { + src: path.join(packageRoot, "src", "extensionAPI.ts"), + dist: path.join(packageRoot, "dist", "extensionAPI.js"), + } as const; + for (const kind of orderedKinds) { + const candidate = candidateMap[kind]; + if (fs.existsSync(candidate)) { + return candidate; + } + } + } catch { + // ignore + } + return null; +}; + const cachedPluginSdkExportedSubpaths = new Map(); function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] { @@ -172,6 +202,7 @@ const resolvePluginSdkScopedAliasMap = (): Record => { export const __testing = { listPluginSdkAliasCandidates, listPluginSdkExportedSubpaths, + resolveExtensionApiAlias, resolvePluginSdkAliasCandidateOrder, resolvePluginSdkAliasFile, maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES, @@ -701,7 +732,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi return jitiLoader; } const pluginSdkAlias = resolvePluginSdkAlias(); + const extensionApiAlias = resolveExtensionApiAlias(); const aliasMap = { + ...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}), ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), ...resolvePluginSdkScopedAliasMap(), }; diff --git a/tsconfig.json b/tsconfig.json index bc6439e921f..e2f9e4ff97e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "target": "es2023", "useDefineForClassFields": false, "paths": { + "openclaw/extension-api": ["./src/extensionAPI.ts"], "openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], "openclaw/plugin-sdk/account-id": ["./src/plugin-sdk/account-id.ts"] diff --git a/tsdown.config.ts b/tsdown.config.ts index acd4fc3e0c8..b1aa8749307 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import path from "node:path"; import { defineConfig } from "tsdown"; const env = { @@ -87,6 +89,51 @@ const pluginSdkEntrypoints = [ "keyed-async-queue", ] as const; +function listBundledPluginBuildEntries(): Record { + const extensionsRoot = path.join(process.cwd(), "extensions"); + const entries: Record = {}; + + for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + + const pluginDir = path.join(extensionsRoot, dirent.name); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + if (!fs.existsSync(manifestPath)) { + continue; + } + + const packageJsonPath = path.join(pluginDir, "package.json"); + let packageEntries: string[] = []; + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + openclaw?: { extensions?: unknown }; + }; + packageEntries = Array.isArray(packageJson.openclaw?.extensions) + ? packageJson.openclaw.extensions.filter( + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, + ) + : []; + } catch { + packageEntries = []; + } + } + + const sourceEntries = packageEntries.length > 0 ? packageEntries : ["./index.ts"]; + for (const entry of sourceEntries) { + const normalizedEntry = entry.replace(/^\.\//, ""); + const entryKey = `extensions/${dirent.name}/${normalizedEntry.replace(/\.[^.]+$/u, "")}`; + entries[entryKey] = path.join("extensions", dirent.name, normalizedEntry); + } + } + + return entries; +} + +const bundledPluginBuildEntries = listBundledPluginBuildEntries(); + export default defineConfig([ nodeBuildConfig({ entry: "src/index.ts", @@ -122,6 +169,12 @@ export default defineConfig([ entry: Object.fromEntries(pluginSdkEntrypoints.map((e) => [e, `src/plugin-sdk/${e}.ts`])), outDir: "dist/plugin-sdk", }), + nodeBuildConfig({ + // Bundle bundled plugin entrypoints so built gateway startup can load JS + // directly from dist/extensions instead of transpiling extensions/*.ts via Jiti. + entry: bundledPluginBuildEntries, + outDir: "dist", + }), nodeBuildConfig({ entry: "src/extensionAPI.ts", }), diff --git a/vitest.config.ts b/vitest.config.ts index 5e0a192d5a3..70011a6a0b8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -58,6 +58,10 @@ export default defineConfig({ resolve: { // Keep this ordered: the base `openclaw/plugin-sdk` alias is a prefix match. alias: [ + { + find: "openclaw/extension-api", + replacement: path.join(repoRoot, "src", "extensionAPI.ts"), + }, ...pluginSdkSubpaths.map((subpath) => ({ find: `openclaw/plugin-sdk/${subpath}`, replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`),