From a2512f02430ec792d07475d110efbe5eb45c8631 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 18:20:40 +0100 Subject: [PATCH] fix: load staged dist-runtime plugins in docker --- scripts/postinstall-bundled-plugins.mjs | 3 +- scripts/stage-bundled-plugin-runtime.mjs | 24 ++++- src/acp/runtime/registry.ts | 12 ++- src/agents/bundle-mcp.test-harness.ts | 15 ++- src/agents/pi-embedded-runner/run/attempt.ts | 2 +- src/gateway/gateway-acp-bind.live.test.ts | 4 + ...gateway-codex-harness.live-helpers.test.ts | 15 +++ .../gateway-codex-harness.live-helpers.ts | 1 + src/plugins/bundled-runtime-deps.test.ts | 44 +++++++++ src/plugins/bundled-runtime-deps.ts | 3 +- src/plugins/loader.test.ts | 96 +++++++++++++++++++ src/plugins/loader.ts | 82 +++++++++++++++- .../stage-bundled-plugin-runtime.test.ts | 22 +++++ 13 files changed, 311 insertions(+), 12 deletions(-) diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index c1abb5d38a3..75fa38aed4f 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -636,7 +636,8 @@ function applyBundledPluginRuntimeHotfixes(params = {}) { export function isSourceCheckoutRoot(params) { const pathExists = params.existsSync ?? existsSync; return ( - pathExists(join(params.packageRoot, ".git")) && + (pathExists(join(params.packageRoot, ".git")) || + pathExists(join(params.packageRoot, "pnpm-workspace.yaml"))) && pathExists(join(params.packageRoot, "src")) && pathExists(join(params.packageRoot, "extensions")) ); diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index 59d7f7e7051..d9713959780 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -140,15 +140,35 @@ function shouldCopyRuntimeFile(sourcePath) { ); } +function hasDefaultExport(sourcePath) { + const text = fs.readFileSync(sourcePath, "utf8"); + return /\bexport\s+default\b/u.test(text) || /\bas\s+default\b/u.test(text); +} + function writeRuntimeModuleWrapper(sourcePath, targetPath) { const specifier = relativeSymlinkTarget(sourcePath, targetPath).replace(/\\/g, "/"); const normalizedSpecifier = specifier.startsWith(".") ? specifier : `./${specifier}`; + const defaultForwarder = hasDefaultExport(sourcePath) + ? [ + `import defaultModule from ${JSON.stringify(normalizedSpecifier)};`, + `let defaultExport = defaultModule;`, + `for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {`, + ` defaultExport = defaultExport.default;`, + `}`, + ] + : [ + `import * as module from ${JSON.stringify(normalizedSpecifier)};`, + `let defaultExport = "default" in module ? module.default : module;`, + `for (let index = 0; index < 4 && defaultExport && typeof defaultExport === "object" && "default" in defaultExport; index += 1) {`, + ` defaultExport = defaultExport.default;`, + `}`, + ]; fs.writeFileSync( targetPath, [ `export * from ${JSON.stringify(normalizedSpecifier)};`, - `import * as module from ${JSON.stringify(normalizedSpecifier)};`, - "export default module.default;", + ...defaultForwarder, + "export { defaultExport as default };", "", ].join("\n"), "utf8", diff --git a/src/acp/runtime/registry.ts b/src/acp/runtime/registry.ts index 5521aea79aa..789072d2aff 100644 --- a/src/acp/runtime/registry.ts +++ b/src/acp/runtime/registry.ts @@ -16,12 +16,22 @@ type AcpRuntimeRegistryGlobalState = { const ACP_RUNTIME_REGISTRY_STATE_KEY = Symbol.for("openclaw.acpRuntimeRegistryState"); function resolveAcpRuntimeRegistryGlobalState(): AcpRuntimeRegistryGlobalState { - return resolveGlobalSingleton( + const processStore = process as NodeJS.Process & Record; + const existing = processStore[ACP_RUNTIME_REGISTRY_STATE_KEY]; + if (existing) { + return existing as AcpRuntimeRegistryGlobalState; + } + const created = resolveGlobalSingleton( ACP_RUNTIME_REGISTRY_STATE_KEY, () => ({ backendsById: new Map(), }), ); + // ACP runtime backends are registered from bundled plugin code and read from + // core/test code. In Vitest and Jiti, those can run in different globalThis + // contexts while still sharing one Node process. + processStore[ACP_RUNTIME_REGISTRY_STATE_KEY] = created; + return created; } const ACP_BACKENDS_BY_ID = resolveAcpRuntimeRegistryGlobalState().backendsById; diff --git a/src/agents/bundle-mcp.test-harness.ts b/src/agents/bundle-mcp.test-harness.ts index 13e851d477c..379eb242d3c 100644 --- a/src/agents/bundle-mcp.test-harness.ts +++ b/src/agents/bundle-mcp.test-harness.ts @@ -17,6 +17,7 @@ export async function writeFakeClaudeCli(filePath: string): Promise { `#!/usr/bin/env node import fs from "node:fs/promises"; import { randomUUID } from "node:crypto"; +import readline from "node:readline/promises"; import { Client } from ${JSON.stringify(SDK_CLIENT_INDEX_PATH)}; import { StdioClientTransport } from ${JSON.stringify(SDK_CLIENT_STDIO_PATH)}; @@ -39,6 +40,17 @@ if (!mcpConfigPath) { throw new Error("missing --mcp-config"); } +const input = readline.createInterface({ input: process.stdin }); +try { + for await (const line of input) { + if (line.trim()) { + break; + } + } +} finally { + input.close(); +} + const raw = JSON.parse(await fs.readFile(mcpConfigPath, "utf-8")); const servers = raw?.mcpServers ?? raw?.servers ?? {}; const server = servers.bundleProbe ?? Object.values(servers)[0]; @@ -75,8 +87,9 @@ const text = Array.isArray(result.content) process.stdout.write( JSON.stringify({ + type: "result", session_id: readArg("--session-id") ?? randomUUID(), - message: "BUNDLE MCP OK " + text, + result: "BUNDLE MCP OK " + text, }) + "\\n", ); `, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index ef2c6b86a27..304598bc82e 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1126,7 +1126,7 @@ export async function runEmbeddedAttempt( ({ session } = await createEmbeddedAgentSessionWithResourceLoader({ createAgentSession: async (options) => - await createAgentSession(options as Parameters[0]), + await createAgentSession(options as unknown as Parameters[0]), options: { cwd: resolvedWorkspace, agentDir, diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts index f71a60a7c88..012dfd0cb27 100644 --- a/src/gateway/gateway-acp-bind.live.test.ts +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -8,9 +8,11 @@ import { getAcpRuntimeBackend } from "../acp/runtime/registry.js"; import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; import { clearConfigCache, clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; +import { clearPluginLoaderCache } from "../plugins/loader.js"; import { pinActivePluginChannelRegistry, releasePinnedPluginChannelRegistry, + resetPluginRuntimeStateForTest, } from "../plugins/runtime.js"; import { extractFirstTextBlock } from "../shared/chat-message-content.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; @@ -503,6 +505,8 @@ describeLive("gateway live (ACP bind)", () => { process.env.OPENCLAW_CONFIG_PATH = tempConfigPath; clearConfigCache(); clearRuntimeConfigSnapshot(); + clearPluginLoaderCache(); + resetPluginRuntimeStateForTest(); logLiveStep(`starting gateway on port ${String(port)}`); const server = await startGatewayServer(port, { diff --git a/src/gateway/gateway-codex-harness.live-helpers.test.ts b/src/gateway/gateway-codex-harness.live-helpers.test.ts index 1444fc035c8..be18fedabc0 100644 --- a/src/gateway/gateway-codex-harness.live-helpers.test.ts +++ b/src/gateway/gateway-codex-harness.live-helpers.test.ts @@ -42,6 +42,21 @@ describe("gateway codex harness live helpers", () => { expect(isExpectedCodexModelsCommandText(text)).toBe(true); }); + it("accepts missing codex CLI fallback output", () => { + const text = [ + "`codex` is not installed on the shell PATH in this environment.", + "", + "Command result:", + "```text", + "/bin/bash: line 1: codex: command not found", + "```", + ].join("\n"); + + expect( + EXPECTED_CODEX_MODELS_COMMAND_TEXT.some((expectedText) => text.includes(expectedText)), + ).toBe(true); + }); + it("accepts sandbox escalation rejection for codex models", () => { const texts = [ "I couldn’t list them because `codex models` requires running outside the sandbox here, and that approval was rejected.", diff --git a/src/gateway/gateway-codex-harness.live-helpers.ts b/src/gateway/gateway-codex-harness.live-helpers.ts index 305f87bfdcd..24612dce581 100644 --- a/src/gateway/gateway-codex-harness.live-helpers.ts +++ b/src/gateway/gateway-codex-harness.live-helpers.ts @@ -13,6 +13,7 @@ export const EXPECTED_CODEX_MODELS_COMMAND_TEXT = [ "`codex models` failed in this sandbox", "`codex models` could not be run in this sandbox.", "`codex models` is not runnable in this sandboxed session.", + "`codex` is not installed on the shell PATH in this environment.", "`codex models` didn’t return a plain list in this environment", "I couldn’t get a direct `codex models` CLI listing because the local sandbox blocked that command.", "I couldn’t list all installed/available Codex models from the local CLI because the sandboxed `codex` command failed to start in this environment.", diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 9de96295fb3..226671713e1 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -603,6 +603,50 @@ describe("ensureBundledPluginRuntimeDeps", () => { expect(resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} })).toBe(pluginRoot); }); + it("treats Docker build source trees without .git as source checkouts", () => { + const packageRoot = makeTempDir(); + fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages:\n - .\n"); + const pluginRoot = path.join(packageRoot, "extensions", "acpx"); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify({ + dependencies: { + acpx: "0.5.3", + }, + devDependencies: { + "@openclaw/plugin-sdk": "workspace:*", + }, + }), + ); + + const calls: BundledRuntimeDepsInstallParams[] = []; + const result = ensureBundledPluginRuntimeDeps({ + env: {}, + installDeps: (params) => { + calls.push(params); + }, + pluginId: "acpx", + pluginRoot, + }); + + expect(result).toEqual({ + installedSpecs: ["acpx@0.5.3"], + retainSpecs: ["acpx@0.5.3"], + }); + expect(calls).toEqual([ + { + installRoot: pluginRoot, + installExecutionRoot: expect.stringContaining( + path.join(".local", "bundled-plugin-runtime-deps"), + ), + missingSpecs: ["acpx@0.5.3"], + installSpecs: ["acpx@0.5.3"], + }, + ]); + }); + it("does not trust package-root runtime deps for source-checkout bundled plugins", () => { const packageRoot = makeTempDir(); const stageDir = makeTempDir(); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 097ad321d41..6a0a490cd38 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -172,7 +172,8 @@ function collectRuntimeDeps(packageJson: JsonObject): Record { function isSourceCheckoutRoot(packageRoot: string): boolean { return ( - fs.existsSync(path.join(packageRoot, ".git")) && + (fs.existsSync(path.join(packageRoot, ".git")) || + fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml"))) && fs.existsSync(path.join(packageRoot, "src")) && fs.existsSync(path.join(packageRoot, "extensions")) ); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 275fe84f595..edfa4ef0753 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1262,6 +1262,102 @@ module.exports = { expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded"); }); + it("loads dist-runtime wrappers from an external stage dir", () => { + const packageRoot = makeTempDir(); + const stageDir = makeTempDir(); + const bundledDir = path.join(packageRoot, "dist-runtime", "extensions"); + const pluginRoot = path.join(bundledDir, "acpx"); + const canonicalPluginRoot = path.join(packageRoot, "dist", "extensions", "acpx"); + fs.mkdirSync(pluginRoot, { recursive: true }); + fs.mkdirSync(canonicalPluginRoot, { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "index.js"), + [ + `export * from "../../../dist/extensions/acpx/index.js";`, + `import defaultModule from "../../../dist/extensions/acpx/index.js";`, + `export default defaultModule;`, + "", + ].join("\n"), + "utf-8", + ); + fs.writeFileSync( + path.join(canonicalPluginRoot, "index.js"), + [ + `import runtimeDep from "external-runtime";`, + `export default {`, + ` id: "acpx",`, + ` register(api) {`, + ` api.registerCommand({ name: "external-runtime", handler: () => runtimeDep.marker });`, + ` },`, + `};`, + "", + ].join("\n"), + "utf-8", + ); + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir; + process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir; + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify( + { + name: "@openclaw/acpx", + version: "1.0.0", + type: "module", + dependencies: { + "external-runtime": "1.0.0", + }, + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: "acpx", + enabledByDefault: true, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + enabled: true, + }, + }, + bundledRuntimeDepsInstaller: ({ installRoot }) => { + const depRoot = path.join(installRoot, "node_modules", "external-runtime"); + fs.mkdirSync(depRoot, { recursive: true }); + fs.writeFileSync( + path.join(depRoot, "package.json"), + JSON.stringify({ + name: "external-runtime", + version: "1.0.0", + type: "module", + exports: "./index.js", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(depRoot, "index.js"), + "export default { marker: 'dist-runtime-ok' };\n", + "utf-8", + ); + }, + }); + + expect(registry.plugins.find((entry) => entry.id === "acpx")?.status).toBe("loaded"); + }); + it("loads source-checkout bundled runtime deps without mirroring the repo tree", () => { const packageRoot = makeTempDir(); fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true }); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 39ca4f47f3f..5c52cdd1155 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -437,7 +437,8 @@ function toSafeImportPath(specifier: string): string { function createPluginJitiLoader(options: Pick) { const jitiLoaders: PluginJitiLoaderCache = new Map(); return (modulePath: string) => { - const tryNative = shouldPreferNativeJiti(modulePath); + const tryNative = + shouldPreferNativeJiti(modulePath) && !isBundledRuntimeDependencyMirrorPath(modulePath); return getCachedPluginJitiLoader({ cache: jitiLoaders, modulePath, @@ -453,8 +454,32 @@ function createPluginJitiLoader(options: Pick(); +function isBundledRuntimeDependencyMirrorPath(modulePath: string): boolean { + const resolvedModulePath = path.resolve(modulePath); + for (const nodeModulesDir of registeredBundledRuntimeDepNodePaths) { + const installRoot = path.dirname(nodeModulesDir); + if ( + resolvedModulePath === installRoot || + resolvedModulePath.startsWith(`${installRoot}${path.sep}`) + ) { + return true; + } + } + return false; +} + function registerBundledRuntimeDependencyNodePath(installRoot: string): void { const nodeModulesDir = path.join(installRoot, "node_modules"); if (registeredBundledRuntimeDepNodePaths.has(nodeModulesDir) || !fs.existsSync(nodeModulesDir)) { @@ -529,7 +554,8 @@ function prepareBundledPluginRuntimeDistMirror(params: { }): string { const sourceExtensionsRoot = path.dirname(params.pluginRoot); const sourceDistRoot = path.dirname(sourceExtensionsRoot); - const mirrorDistRoot = path.join(params.installRoot, "dist"); + const sourceDistRootName = path.basename(sourceDistRoot); + const mirrorDistRoot = path.join(params.installRoot, sourceDistRootName); const mirrorExtensionsRoot = path.join(mirrorDistRoot, "extensions"); fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 }); for (const entry of fs.readdirSync(sourceDistRoot, { withFileTypes: true })) { @@ -551,6 +577,24 @@ function prepareBundledPluginRuntimeDistMirror(params: { } } } + if (sourceDistRootName === "dist-runtime") { + const sourceCanonicalDistRoot = path.join(path.dirname(sourceDistRoot), "dist"); + const targetCanonicalDistRoot = path.join(params.installRoot, "dist"); + if (fs.existsSync(sourceCanonicalDistRoot)) { + const targetMatchesSource = + fs.existsSync(targetCanonicalDistRoot) && + safeRealpathOrResolve(targetCanonicalDistRoot) === + safeRealpathOrResolve(sourceCanonicalDistRoot); + if (!targetMatchesSource) { + fs.rmSync(targetCanonicalDistRoot, { recursive: true, force: true }); + try { + fs.symlinkSync(sourceCanonicalDistRoot, targetCanonicalDistRoot, "junction"); + } catch { + copyBundledPluginRuntimeRoot(sourceCanonicalDistRoot, targetCanonicalDistRoot); + } + } + } + } return mirrorExtensionsRoot; } @@ -938,7 +982,33 @@ function resolvePluginModuleExport(moduleExport: unknown): { definition?: OpenClawPluginDefinition; register?: OpenClawPluginDefinition["register"]; } { - const resolved = unwrapDefaultModuleExport(moduleExport); + const seen = new Set(); + const candidates: unknown[] = [unwrapDefaultModuleExport(moduleExport), moduleExport]; + for (let index = 0; index < candidates.length && index < 12; index += 1) { + const resolved = candidates[index]; + if (seen.has(resolved)) { + continue; + } + seen.add(resolved); + if (typeof resolved === "function") { + return { + register: resolved as OpenClawPluginDefinition["register"], + }; + } + if (resolved && typeof resolved === "object") { + const def = resolved as OpenClawPluginDefinition; + const register = def.register ?? def.activate; + if (typeof register === "function") { + return { definition: def, register }; + } + for (const key of ["default", "module"]) { + if (key in def) { + candidates.push((def as Record)[key]); + } + } + } + } + const resolved = candidates[0]; if (typeof resolved === "function") { return { register: resolved as OpenClawPluginDefinition["register"], @@ -2132,9 +2202,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi runtimeSetupSource ? runtimeSetupSource : runtimeCandidateSource; + const moduleLoadSource = resolveCanonicalDistRuntimeSource(loadSource); + const moduleRoot = resolveCanonicalDistRuntimeSource(runtimePluginRoot); const opened = openBoundaryFileSync({ - absolutePath: loadSource, - rootPath: runtimePluginRoot, + absolutePath: moduleLoadSource, + rootPath: moduleRoot, boundaryLabel: "plugin root", rejectHardlinks: candidate.origin !== "bundled", skipLexicalRootCheck: true, diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index 7af5ad29bfc..ad74c6be129 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -53,6 +53,23 @@ function expectRuntimePluginWrapperContains(params: { expect(fs.readFileSync(runtimePath, "utf8")).toContain(params.expectedImport); } +function expectRuntimePluginWrapperForwardsDefault(params: { + repoRoot: string; + pluginId: string; + expectedImport: string; +}) { + const runtimePath = path.join( + params.repoRoot, + "dist-runtime", + "extensions", + params.pluginId, + "index.js", + ); + expect(fs.readFileSync(runtimePath, "utf8")).toContain( + `import defaultModule from "${params.expectedImport}";`, + ); +} + function expectRuntimeArtifactText(params: { repoRoot: string; pluginId: string; @@ -102,6 +119,11 @@ describe("stageBundledPluginRuntime", () => { pluginId: "diffs", expectedImport: distRuntimeImportPath("diffs"), }); + expectRuntimePluginWrapperForwardsDefault({ + repoRoot, + pluginId: "diffs", + expectedImport: distRuntimeImportPath("diffs"), + }); expect(fs.lstatSync(path.join(runtimePluginDir, "node_modules")).isSymbolicLink()).toBe(true); expect(fs.realpathSync(path.join(runtimePluginDir, "node_modules"))).toBe( fs.realpathSync(path.join(distPluginDir, "node_modules")),