diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh index 1c7bd1d6e16..5353c7be081 100644 --- a/scripts/e2e/bundled-channel-runtime-deps-docker.sh +++ b/scripts/e2e/bundled-channel-runtime-deps-docker.sh @@ -9,6 +9,7 @@ UPDATE_BASELINE_VERSION="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION:-202 RUN_CHANNEL_SCENARIOS="${OPENCLAW_BUNDLED_CHANNEL_SCENARIOS:-1}" RUN_UPDATE_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO:-1}" RUN_ROOT_OWNED_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO:-1}" +RUN_SETUP_ENTRY_SCENARIO="${OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO:-1}" echo "Building Docker image..." run_logged bundled-channel-deps-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" @@ -490,6 +491,115 @@ EOF rm -f "$run_log" } +run_setup_entry_scenario() { + local run_log + run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-bundled-channel-setup-entry.XXXXXX")" + + echo "Running bundled channel setup-entry runtime deps Docker E2E..." + if ! docker run --rm \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF' +set -euo pipefail + +export HOME="$(mktemp -d "/tmp/openclaw-bundled-channel-setup-entry.XXXXXX")" +export NPM_CONFIG_PREFIX="$HOME/.npm-global" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +export OPENCLAW_NO_ONBOARD=1 +export OPENCLAW_PLUGIN_STAGE_DIR="$HOME/.openclaw/plugin-runtime-deps" + +CHANNEL="feishu" +DEP_SENTINEL="@larksuiteoapi/node-sdk" + +package_root() { + printf "%s/openclaw" "$(npm root -g)" +} + +echo "Packing and installing current OpenClaw build..." +pack_dir="$(mktemp -d "/tmp/openclaw-setup-entry-pack.XXXXXX")" +npm pack --ignore-scripts --pack-destination "$pack_dir" >/tmp/openclaw-setup-entry-pack.log 2>&1 +package_tgz="$(find "$pack_dir" -maxdepth 1 -name 'openclaw-*.tgz' -print -quit)" +if [ -z "$package_tgz" ]; then + cat /tmp/openclaw-setup-entry-pack.log + echo "missing packed OpenClaw tarball" >&2 + exit 1 +fi +npm install -g "$package_tgz" --no-fund --no-audit >/tmp/openclaw-setup-entry-install.log 2>&1 + +root="$(package_root)" +test -d "$root/dist/extensions/$CHANNEL" +if [ -d "$root/dist/extensions/$CHANNEL/node_modules" ]; then + echo "$CHANNEL runtime deps should not be preinstalled in package" >&2 + find "$root/dist/extensions/$CHANNEL/node_modules" -maxdepth 3 -type f | head -40 >&2 || true + exit 1 +fi +if [ -f "$root/node_modules/$DEP_SENTINEL/package.json" ]; then + echo "$DEP_SENTINEL should not be installed at package root before setup-entry load" >&2 + exit 1 +fi + +echo "Loading real Feishu bundled setup entry from installed package..." +( + cd "$root" + node --input-type=module - <<'NODE' +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = process.cwd(); +const distDir = path.join(root, "dist"); +const bundledPath = fs + .readdirSync(distDir) + .filter((entry) => /^bundled-[A-Za-z0-9_-]+\.js$/.test(entry)) + .map((entry) => path.join(distDir, entry)) + .find((entry) => fs.readFileSync(entry, "utf8").includes("src/channels/plugins/bundled.ts")); +if (!bundledPath) { + throw new Error("missing packaged bundled channel loader artifact"); +} +const bundled = await import(pathToFileURL(bundledPath)); +let plugin = null; +for (const value of Object.values(bundled)) { + if (typeof value !== "function" || value.length !== 1) { + continue; + } + try { + const candidate = value("feishu"); + if (candidate?.id === "feishu" && candidate?.setupWizard) { + plugin = candidate; + break; + } + } catch { + // Ignore unrelated one-argument helper exports from the bundled chunk. + } +} +if (!plugin) { + throw new Error("missing Feishu setup plugin"); +} +console.log("Feishu setup plugin loaded"); +NODE +) + +if [ -e "$root/dist/extensions/$CHANNEL/node_modules/$DEP_SENTINEL/package.json" ]; then + echo "expected setup-entry deps to be installed externally, not into bundled plugin tree" >&2 + exit 1 +fi +if ! find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -path "*/node_modules/$DEP_SENTINEL/package.json" -type f | grep -q .; then + echo "missing external staged setup-entry dependency sentinel for $DEP_SENTINEL" >&2 + find "$OPENCLAW_PLUGIN_STAGE_DIR" -maxdepth 12 -type f | sort | head -160 >&2 || true + exit 1 +fi + +echo "bundled channel setup-entry runtime deps Docker E2E passed" +EOF + then + cat "$run_log" + rm -f "$run_log" + exit 1 + fi + + cat "$run_log" + rm -f "$run_log" +} + run_update_scenario() { local run_log run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-bundled-channel-update.XXXXXX")" @@ -802,3 +912,6 @@ fi if [ "$RUN_ROOT_OWNED_SCENARIO" != "0" ]; then run_root_owned_global_scenario fi +if [ "$RUN_SETUP_ENTRY_SCENARIO" != "0" ]; then + run_setup_entry_scenario +fi diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index b96538e22bf..61399d18c7a 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -549,6 +549,92 @@ describe("bundled channel entry shape guards", () => { } }); + it("loads bundled setup entries from external staged runtime deps", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-setup-runtime-deps-")); + const stageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-stage-")); + const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const previousPluginStageDir = process.env.OPENCLAW_PLUGIN_STAGE_DIR; + const pluginDir = path.join(root, "dist", "extensions", "alpha"); + const testGlobal = globalThis as typeof globalThis & { + __bundledSetupRuntimeDepMarker?: string; + }; + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.21" }), + "utf8", + ); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/alpha", + version: "2026.4.21", + type: "module", + dependencies: { + "alpha-runtime-dep": "1.0.0", + }, + }), + "utf8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.js"), + [ + "import { marker } from 'alpha-runtime-dep';", + "globalThis.__bundledSetupRuntimeDepMarker = marker;", + "export default {", + " kind: 'bundled-channel-setup-entry',", + " loadSetupPlugin() {", + " return { id: 'alpha', meta: { label: marker }, config: {} };", + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + + process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageRoot; + const { resolveBundledRuntimeDependencyInstallRoot } = + await import("../../plugins/bundled-runtime-deps.js"); + const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginDir); + const depRoot = path.join(installRoot, "node_modules", "alpha-runtime-dep"); + fs.mkdirSync(depRoot, { recursive: true }); + fs.writeFileSync( + path.join(depRoot, "package.json"), + JSON.stringify({ + name: "alpha-runtime-dep", + version: "1.0.0", + type: "module", + main: "index.js", + }), + "utf8", + ); + fs.writeFileSync(path.join(depRoot, "index.js"), "export const marker = 'staged-alpha';\n"); + + mockAlphaDistExtensionRuntime(); + + try { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(root, "dist", "extensions"); + + const bundled = await importFreshModule( + import.meta.url, + "./bundled.js?scope=bundled-setup-runtime-deps", + ); + + expect(bundled.getBundledChannelSetupPlugin("alpha")?.meta.label).toBe("staged-alpha"); + expect(testGlobal.__bundledSetupRuntimeDepMarker).toBe("staged-alpha"); + } finally { + restoreBundledPluginsDir(previousBundledPluginsDir); + if (previousPluginStageDir === undefined) { + delete process.env.OPENCLAW_PLUGIN_STAGE_DIR; + } else { + process.env.OPENCLAW_PLUGIN_STAGE_DIR = previousPluginStageDir; + } + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(stageRoot, { recursive: true, force: true }); + delete testGlobal.__bundledSetupRuntimeDepMarker; + } + }); + it("keeps channel entrypoints on the dedicated entry-contract SDK surface", () => { const offenders = collectBundledChannelEntrypointOffenders( bundledPluginRoots, diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index d27f70bf6a6..d2e76dce974 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -10,6 +11,10 @@ import { resolveBundledChannelGeneratedPath, type BundledChannelPluginMetadata, } from "../../plugins/bundled-channel-runtime.js"; +import { + ensureBundledPluginRuntimeDeps, + resolveBundledRuntimeDependencyInstallRoot, +} from "../../plugins/bundled-runtime-deps.js"; import { unwrapDefaultModuleExport } from "../../plugins/module-export.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; import { resolveBundledChannelRootScope, type BundledChannelRootScope } from "./bundled-root.js"; @@ -68,6 +73,7 @@ type BundledChannelCacheContext = { }; const log = createSubsystemLogger("channels"); +const bundledRuntimeDepsRetainSpecsByInstallRoot = new Map(); function resolveChannelPluginModuleEntry( moduleExport: unknown, @@ -171,17 +177,26 @@ function loadGeneratedBundledChannelModule(params: { metadata: BundledChannelPluginMetadata; entry: BundledChannelPluginMetadata["source"] | BundledChannelPluginMetadata["setupSource"]; }): unknown { - const modulePath = resolveGeneratedBundledChannelModulePath(params); + let modulePath = resolveGeneratedBundledChannelModulePath(params); if (!modulePath) { throw new Error(`missing generated module for bundled channel ${params.metadata.manifest.id}`); } const scanDir = resolveBundledChannelScanDir(params.rootScope); - const boundaryRoot = resolveBundledChannelBoundaryRoot({ + let boundaryRoot = resolveBundledChannelBoundaryRoot({ packageRoot: params.rootScope.packageRoot, ...(scanDir ? { pluginsDir: scanDir } : {}), metadata: params.metadata, modulePath, }); + if (isBuiltBundledChannelPluginRoot(boundaryRoot)) { + const prepared = prepareBundledChannelRuntimeRoot({ + pluginId: params.metadata.manifest.id, + pluginRoot: boundaryRoot, + modulePath, + }); + modulePath = prepared.modulePath; + boundaryRoot = prepared.pluginRoot; + } return loadChannelPluginModule({ modulePath, rootDir: boundaryRoot, @@ -191,6 +206,166 @@ function loadGeneratedBundledChannelModule(params: { }); } +function isBuiltBundledChannelPluginRoot(pluginRoot: string): boolean { + const extensionsDir = path.dirname(pluginRoot); + const buildDir = path.dirname(extensionsDir); + return ( + path.basename(extensionsDir) === "extensions" && + (path.basename(buildDir) === "dist" || path.basename(buildDir) === "dist-runtime") + ); +} + +function prepareBundledChannelRuntimeRoot(params: { + pluginId: string; + pluginRoot: string; + modulePath: string; +}): { pluginRoot: string; modulePath: string } { + const installRoot = resolveBundledRuntimeDependencyInstallRoot(params.pluginRoot, { + env: process.env, + }); + const retainSpecs = bundledRuntimeDepsRetainSpecsByInstallRoot.get(installRoot) ?? []; + const depsInstallResult = ensureBundledPluginRuntimeDeps({ + pluginId: params.pluginId, + pluginRoot: params.pluginRoot, + env: process.env, + retainSpecs, + }); + if (depsInstallResult.installedSpecs.length > 0) { + bundledRuntimeDepsRetainSpecsByInstallRoot.set( + installRoot, + [...new Set([...retainSpecs, ...depsInstallResult.retainSpecs])].toSorted((left, right) => + left.localeCompare(right), + ), + ); + log.info( + `[channels] ${params.pluginId} installed bundled runtime deps: ${depsInstallResult.installedSpecs.join(", ")}`, + ); + } + if (path.resolve(installRoot) === path.resolve(params.pluginRoot)) { + return { pluginRoot: params.pluginRoot, modulePath: params.modulePath }; + } + const mirrorRoot = mirrorBundledChannelRuntimeRoot({ + pluginId: params.pluginId, + pluginRoot: params.pluginRoot, + installRoot, + }); + return { + pluginRoot: mirrorRoot, + modulePath: remapBundledChannelRuntimePath({ + source: params.modulePath, + pluginRoot: params.pluginRoot, + mirroredRoot: mirrorRoot, + }), + }; +} + +function mirrorBundledChannelRuntimeRoot(params: { + pluginId: string; + pluginRoot: string; + installRoot: string; +}): string { + const mirrorParent = prepareBundledChannelRuntimeDistMirror({ + 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, `.channel-plugin-${params.pluginId}-`)); + const stagedRoot = path.join(tempDir, "plugin"); + try { + copyBundledChannelRuntimeRoot(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 prepareBundledChannelRuntimeDistMirror(params: { + installRoot: string; + pluginRoot: string; +}): string { + const sourceExtensionsRoot = path.dirname(params.pluginRoot); + const sourceDistRoot = path.dirname(sourceExtensionsRoot); + const mirrorDistRoot = path.join(params.installRoot, "dist"); + const mirrorExtensionsRoot = path.join(mirrorDistRoot, "extensions"); + fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 }); + for (const entry of fs.readdirSync(sourceDistRoot, { withFileTypes: true })) { + if (entry.name === "extensions") { + continue; + } + const sourcePath = path.join(sourceDistRoot, entry.name); + const targetPath = path.join(mirrorDistRoot, entry.name); + if (fs.existsSync(targetPath)) { + continue; + } + try { + fs.symlinkSync(sourcePath, targetPath, entry.isDirectory() ? "junction" : "file"); + } catch { + if (entry.isDirectory()) { + copyBundledChannelRuntimeRoot(sourcePath, targetPath); + } else if (entry.isFile()) { + fs.copyFileSync(sourcePath, targetPath); + } + } + } + return mirrorExtensionsRoot; +} + +function copyBundledChannelRuntimeRoot(sourceRoot: string, targetRoot: string): void { + fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 }); + for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) { + if (entry.name === "node_modules") { + continue; + } + const sourcePath = path.join(sourceRoot, entry.name); + const targetPath = path.join(targetRoot, entry.name); + if (entry.isDirectory()) { + copyBundledChannelRuntimeRoot(sourcePath, targetPath); + continue; + } + if (entry.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(sourcePath), targetPath); + continue; + } + if (!entry.isFile()) { + continue; + } + fs.copyFileSync(sourcePath, targetPath); + try { + const sourceMode = fs.statSync(sourcePath).mode; + fs.chmodSync(targetPath, sourceMode | 0o600); + } catch { + // Readable copied files are enough for plugin loading. + } + } +} + +function remapBundledChannelRuntimePath(params: { + source: string; + pluginRoot: string; + mirroredRoot: string; +}): string { + const relative = path.relative(params.pluginRoot, params.source); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return params.source; + } + return path.join(params.mirroredRoot, relative); +} + function loadGeneratedBundledChannelEntry(params: { rootScope: BundledChannelRootScope; metadata: BundledChannelPluginMetadata;