From 9c733956c08d1e399f510fd5d55f0eed97c0ae46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 20:21:20 +0100 Subject: [PATCH] fix(plugins): repair bundled deps on activation --- CHANGELOG.md | 1 + .../bundled-channel-runtime-deps-docker.sh | 48 +++-- scripts/release-check.ts | 152 +++++++++++++-- src/channels/plugins/bundled.ts | 178 ++---------------- src/plugin-sdk/channel-entry-contract.ts | 16 +- src/plugins/bundled-runtime-root.ts | 167 ++++++++++++++++ test/release-check.test.ts | 3 +- 7 files changed, 369 insertions(+), 196 deletions(-) create mode 100644 src/plugins/bundled-runtime-root.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 57836950455..98efdbf4f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - CLI sessions: persist CLI session clearing through the atomic session-store merge path, so expired Claude/Codex CLI bindings are actually removed before retrying without the stale session id. (#70298) Thanks @HFConsultant. - ACP/sessions_spawn: honor explicit `model` overrides for ACP child sessions instead of silently falling back to the target agent default model. (#70210) Thanks @felix-miao. - Agents/subagents: drop bare `NO_REPLY` from the parent turn when the session still has pending spawned children, so direct-conversation surfaces such as Telegram DMs no longer rewrite the sentinel into visible fallback chatter while waiting for the child completion event. (#69942) Thanks @neeravmakwana. +- Plugins/install: keep bundled plugin dependencies off npm install while repairing them when plugins activate from a packaged install, including Feishu/Lark, Browser, and direct bundled channel setup-entry loads. - CLI/Claude: hash only static extra system prompt parts when deciding whether to reuse a CLI session, so per-message inbound metadata no longer resets Claude CLI conversations on every turn. (#70122) Thanks @zijunl. - Hooks/Slack: standardize shared message hook routing fields (`threadId` / `replyToId`) and stop Slack outbound delivery from re-running `message_sending` inside the channel adapter, so plugins like thread-ownership make one outbound routing decision per reply. Thanks @vincentkoc. - Auto-reply/media: share one run-scoped reply media context between streamed block delivery and final payload filtering, so a local `MEDIA:` attachment is staged once and duplicate media sends are suppressed reliably. (#68111) Thanks @ayeshakhalid192007-dev. diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh index 0ae5c8b4928..4f4946075d8 100644 --- a/scripts/e2e/bundled-channel-runtime-deps-docker.sh +++ b/scripts/e2e/bundled-channel-runtime-deps-docker.sh @@ -64,20 +64,11 @@ package_root="$(npm root -g)/openclaw" test -d "$package_root/dist/extensions/telegram" test -d "$package_root/dist/extensions/discord" test -d "$package_root/dist/extensions/slack" +test -d "$package_root/dist/extensions/feishu" -if [ -d "$package_root/dist/extensions/telegram/node_modules" ]; then - echo "telegram runtime deps should not be preinstalled in package" >&2 - find "$package_root/dist/extensions/telegram/node_modules" -maxdepth 2 -type f | head -20 >&2 || true - exit 1 -fi -if [ -d "$package_root/dist/extensions/discord/node_modules" ]; then - echo "discord runtime deps should not be preinstalled in package" >&2 - find "$package_root/dist/extensions/discord/node_modules" -maxdepth 2 -type f | head -20 >&2 || true - exit 1 -fi -if [ -d "$package_root/dist/extensions/slack/node_modules" ]; then - echo "slack runtime deps should not be preinstalled in package" >&2 - find "$package_root/dist/extensions/slack/node_modules" -maxdepth 2 -type f | head -20 >&2 || true +if [ -d "$package_root/dist/extensions/$CHANNEL/node_modules" ]; then + echo "$CHANNEL runtime deps should not be preinstalled in package" >&2 + find "$package_root/dist/extensions/$CHANNEL/node_modules" -maxdepth 2 -type f | head -20 >&2 || true exit 1 fi @@ -156,6 +147,15 @@ if (mode === "slack") { }, }; } +if (mode === "feishu") { + config.channels = { + ...(config.channels || {}), + feishu: { + ...(config.channels?.feishu || {}), + enabled: true, + }, + }; +} fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); @@ -239,10 +239,17 @@ NODE assert_installed_once() { local log_file="$1" local channel="$2" + local dep_path="$3" local count count="$(grep -c "\\[plugins\\] $channel installed bundled runtime deps:" "$log_file" || true)" + if [ "$count" -eq 1 ]; then + return 0 + fi + if [ "$count" -eq 0 ] && [ -f "$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then + return 0 + fi if [ "$count" -ne 1 ]; then - echo "expected exactly one runtime deps install for $channel, got $count" >&2 + echo "expected exactly one runtime deps install log or installed sentinel for $channel, got $count log lines" >&2 cat "$log_file" >&2 exit 1 fi @@ -268,17 +275,27 @@ assert_dep_sentinel() { fi } +assert_no_dep_sentinel() { + local channel="$1" + local dep_path="$2" + if [ -f "$package_root/dist/extensions/$channel/node_modules/$dep_path/package.json" ]; then + echo "dependency sentinel should be absent before activation for $channel: $dep_path" >&2 + exit 1 + fi +} + echo "Starting baseline gateway with OpenAI configured..." write_config baseline start_gateway "/tmp/openclaw-$CHANNEL-baseline.log" wait_for_gateway_health stop_gateway +assert_no_dep_sentinel "$CHANNEL" "$DEP_SENTINEL" echo "Enabling $CHANNEL by config edit, then restarting gateway..." write_config "$CHANNEL" start_gateway "/tmp/openclaw-$CHANNEL-first.log" wait_for_gateway_health -assert_installed_once "/tmp/openclaw-$CHANNEL-first.log" "$CHANNEL" +assert_installed_once "/tmp/openclaw-$CHANNEL-first.log" "$CHANNEL" "$DEP_SENTINEL" assert_dep_sentinel "$CHANNEL" "$DEP_SENTINEL" assert_channel_status "$CHANNEL" stop_gateway @@ -919,6 +936,7 @@ if [ "$RUN_CHANNEL_SCENARIOS" != "0" ]; then run_channel_scenario telegram grammy run_channel_scenario discord discord-api-types run_channel_scenario slack @slack/web-api + run_channel_scenario feishu @larksuiteoapi/node-sdk fi if [ "$RUN_UPDATE_SCENARIO" != "0" ]; then run_update_scenario diff --git a/scripts/release-check.ts b/scripts/release-check.ts index d785708f317..311c68fdf8c 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -1,7 +1,15 @@ #!/usr/bin/env -S node --import tsx import { execFileSync, execSync } from "node:child_process"; -import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -211,7 +219,6 @@ export function createPackedBundledPluginPostinstallEnv( return { ...env, OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1", - OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1", }; } @@ -238,20 +245,143 @@ export function collectInstalledBundledPluginRuntimeDepErrors(packageRoot: strin .toSorted((left, right) => left.localeCompare(right)); } -function assertInstalledBundledPluginRuntimeDepsResolved(packageRoot: string): void { - const errors = collectInstalledBundledPluginRuntimeDepErrors(packageRoot); - if (errors.length === 0) { +function bundledRuntimeDependencySentinelPath( + packageRoot: string, + pluginId: string, + dependencyName: string, +): string { + return join( + packageRoot, + "dist", + "extensions", + pluginId, + "node_modules", + ...dependencyName.split("/"), + "package.json", + ); +} + +function bundledRuntimeDependencySentinelCandidates( + packageRoot: string, + pluginId: string, + dependencyName: string, +): string[] { + const dependencyParts = dependencyName.split("/"); + return [ + bundledRuntimeDependencySentinelPath(packageRoot, pluginId, dependencyName), + join(packageRoot, "dist", "extensions", "node_modules", ...dependencyParts, "package.json"), + join(packageRoot, "node_modules", ...dependencyParts, "package.json"), + ]; +} + +function assertBundledRuntimeDependencyAbsent(params: { + packageRoot: string; + pluginId: string; + dependencyName: string; +}): void { + const sentinelPath = bundledRuntimeDependencySentinelCandidates( + params.packageRoot, + params.pluginId, + params.dependencyName, + ).find((candidate) => existsSync(candidate)); + if (sentinelPath) { + throw new Error( + `release-check: ${params.pluginId} runtime dependency ${params.dependencyName} was installed before plugin activation (${sentinelPath}).`, + ); + } +} + +function assertBundledRuntimeDependencyPresent(params: { + packageRoot: string; + pluginId: string; + dependencyName: string; +}): void { + const sentinelPath = bundledRuntimeDependencySentinelCandidates( + params.packageRoot, + params.pluginId, + params.dependencyName, + ).find((candidate) => existsSync(candidate)); + if (sentinelPath) { return; } - console.error("release-check: packed install is missing bundled plugin runtime dependencies:"); - for (const error of errors) { - console.error(` - ${error}`); - } throw new Error( - "release-check: bundled plugin runtime dependencies were not installed after packed postinstall.", + `release-check: ${params.pluginId} runtime dependency ${params.dependencyName} was not installed during plugin activation.`, ); } +function writePackedBundledPluginActivationConfig(homeDir: string): void { + const configPath = join(homeDir, ".openclaw", "openclaw.json"); + mkdirSync(join(homeDir, ".openclaw"), { recursive: true }); + writeFileSync( + configPath, + `${JSON.stringify( + { + agents: { + defaults: { + model: { primary: "openai/gpt-4.1-mini" }, + }, + }, + channels: { + feishu: { + enabled: true, + }, + }, + models: { + providers: { + openai: { + apiKey: "sk-openclaw-release-check", + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + plugins: { + enabled: true, + entries: { + feishu: { + enabled: true, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); +} + +function runPackedBundledPluginActivationSmoke(packageRoot: string, tmpRoot: string): void { + const lazyDeps = [ + { pluginId: "browser", dependencyName: "playwright-core" }, + { pluginId: "feishu", dependencyName: "@larksuiteoapi/node-sdk" }, + ] as const; + + for (const dep of lazyDeps) { + assertBundledRuntimeDependencyAbsent({ packageRoot, ...dep }); + } + + const homeDir = join(tmpRoot, "activation-home"); + mkdirSync(homeDir, { recursive: true }); + writePackedBundledPluginActivationConfig(homeDir); + execFileSync(process.execPath, [join(packageRoot, "openclaw.mjs"), "plugins", "doctor"], { + cwd: packageRoot, + stdio: "inherit", + env: { + ...process.env, + HOME: homeDir, + OPENAI_API_KEY: "sk-openclaw-release-check", + OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1", + OPENCLAW_NO_ONBOARD: "1", + OPENCLAW_SUPPRESS_NOTES: "1", + }, + }); + + for (const dep of lazyDeps) { + assertBundledRuntimeDependencyPresent({ packageRoot, ...dep }); + } +} + function runPackedBundledChannelEntrySmoke(): void { const tmpRoot = mkdtempSync(join(tmpdir(), "openclaw-release-pack-smoke-")); try { @@ -265,7 +395,7 @@ function runPackedBundledChannelEntrySmoke(): void { const packageRoot = join(resolveGlobalRoot(prefixDir, tmpRoot), "openclaw"); runPackedBundledPluginPostinstall(packageRoot); - assertInstalledBundledPluginRuntimeDepsResolved(packageRoot); + runPackedBundledPluginActivationSmoke(packageRoot, tmpRoot); execFileSync( process.execPath, [ diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index ea3be4778c3..24c60fa2f21 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -1,4 +1,3 @@ -import fs from "node:fs"; import path from "node:path"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; @@ -12,9 +11,9 @@ import { type BundledChannelPluginMetadata, } from "../../plugins/bundled-channel-runtime.js"; import { - ensureBundledPluginRuntimeDeps, - resolveBundledRuntimeDependencyInstallRoot, -} from "../../plugins/bundled-runtime-deps.js"; + isBuiltBundledPluginRuntimeRoot, + prepareBundledPluginRuntimeRoot, +} from "../../plugins/bundled-runtime-root.js"; import { unwrapDefaultModuleExport } from "../../plugins/module-export.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; import { resolveBundledChannelRootScope, type BundledChannelRootScope } from "./bundled-root.js"; @@ -78,7 +77,6 @@ type BundledChannelCacheContext = { }; const log = createSubsystemLogger("channels"); -const bundledRuntimeDepsRetainSpecsByInstallRoot = new Map(); function resolveChannelPluginModuleEntry( moduleExport: unknown, @@ -193,11 +191,17 @@ function loadGeneratedBundledChannelModule(params: { metadata: params.metadata, modulePath, }); - if (isBuiltBundledChannelPluginRoot(boundaryRoot)) { - const prepared = prepareBundledChannelRuntimeRoot({ + if (isBuiltBundledPluginRuntimeRoot(boundaryRoot)) { + const prepared = prepareBundledPluginRuntimeRoot({ pluginId: params.metadata.manifest.id, pluginRoot: boundaryRoot, modulePath, + env: process.env, + logInstalled: (installedSpecs) => { + log.debug( + `[channels] ${params.metadata.manifest.id} installed bundled runtime deps: ${installedSpecs.join(", ")}`, + ); + }, }); modulePath = prepared.modulePath; boundaryRoot = prepared.pluginRoot; @@ -211,166 +215,6 @@ 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.debug( - `[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; diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index f4161b2834c..1a5c4271543 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -8,6 +8,10 @@ import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types. import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import { + isBuiltBundledPluginRuntimeRoot, + prepareBundledPluginRuntimeRoot, +} from "../plugins/bundled-runtime-root.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache, @@ -327,7 +331,17 @@ function canTryNodeRequireBuiltModule(modulePath: string): boolean { } function loadBundledEntryModuleSync(importMetaUrl: string, specifier: string): unknown { - const modulePath = resolveBundledEntryModulePath(importMetaUrl, specifier); + let modulePath = resolveBundledEntryModulePath(importMetaUrl, specifier); + const boundaryRoot = resolveEntryBoundaryRoot(importMetaUrl); + if (isBuiltBundledPluginRuntimeRoot(boundaryRoot)) { + const prepared = prepareBundledPluginRuntimeRoot({ + pluginId: path.basename(boundaryRoot), + pluginRoot: boundaryRoot, + modulePath, + env: process.env, + }); + modulePath = prepared.modulePath; + } const cached = loadedModuleExports.get(modulePath); if (cached !== undefined) { return cached; diff --git a/src/plugins/bundled-runtime-root.ts b/src/plugins/bundled-runtime-root.ts new file mode 100644 index 00000000000..3c8a1cf6d27 --- /dev/null +++ b/src/plugins/bundled-runtime-root.ts @@ -0,0 +1,167 @@ +import fs from "node:fs"; +import path from "node:path"; +import { + ensureBundledPluginRuntimeDeps, + resolveBundledRuntimeDependencyInstallRoot, +} from "./bundled-runtime-deps.js"; + +const bundledRuntimeDepsRetainSpecsByInstallRoot = new Map(); + +export function isBuiltBundledPluginRuntimeRoot(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") + ); +} + +export function prepareBundledPluginRuntimeRoot(params: { + pluginId: string; + pluginRoot: string; + modulePath: string; + env?: NodeJS.ProcessEnv; + logInstalled?: (installedSpecs: readonly string[]) => void; +}): { pluginRoot: string; modulePath: string } { + const env = params.env ?? process.env; + const installRoot = resolveBundledRuntimeDependencyInstallRoot(params.pluginRoot, { env }); + const retainSpecs = bundledRuntimeDepsRetainSpecsByInstallRoot.get(installRoot) ?? []; + const depsInstallResult = ensureBundledPluginRuntimeDeps({ + pluginId: params.pluginId, + pluginRoot: params.pluginRoot, + env, + retainSpecs, + }); + if (depsInstallResult.installedSpecs.length > 0) { + bundledRuntimeDepsRetainSpecsByInstallRoot.set( + installRoot, + [...new Set([...retainSpecs, ...depsInstallResult.retainSpecs])].toSorted((left, right) => + left.localeCompare(right), + ), + ); + params.logInstalled?.(depsInstallResult.installedSpecs); + } + if (path.resolve(installRoot) === path.resolve(params.pluginRoot)) { + return { pluginRoot: params.pluginRoot, modulePath: params.modulePath }; + } + const mirrorRoot = mirrorBundledPluginRuntimeRoot({ + pluginId: params.pluginId, + pluginRoot: params.pluginRoot, + installRoot, + }); + return { + pluginRoot: mirrorRoot, + modulePath: remapBundledPluginRuntimePath({ + source: params.modulePath, + pluginRoot: params.pluginRoot, + mirroredRoot: mirrorRoot, + }), + }; +} + +function remapBundledPluginRuntimePath(params: { + source: string; + pluginRoot: string; + mirroredRoot: string; +}): string { + const relativePath = path.relative(params.pluginRoot, params.source); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return params.source; + } + return path.join(params.mirroredRoot, relativePath); +} + +function mirrorBundledPluginRuntimeRoot(params: { + pluginId: string; + pluginRoot: string; + installRoot: string; +}): string { + const mirrorParent = prepareBundledPluginRuntimeDistMirror({ + 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, `.plugin-${params.pluginId}-`)); + const stagedRoot = path.join(tempDir, "plugin"); + try { + copyBundledPluginRuntimeRoot(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 prepareBundledPluginRuntimeDistMirror(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()) { + copyBundledPluginRuntimeRoot(sourcePath, targetPath); + } else if (entry.isFile()) { + fs.copyFileSync(sourcePath, targetPath); + } + } + } + return mirrorExtensionsRoot; +} + +function copyBundledPluginRuntimeRoot(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()) { + copyBundledPluginRuntimeRoot(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. + } + } +} diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 459efb5467c..2bd661f3635 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -467,11 +467,10 @@ describe("collectPackUnpackedSizeErrors", () => { }); describe("createPackedBundledPluginPostinstallEnv", () => { - it("enables eager bundled dependency repair for packed channel entry smoke", () => { + it("keeps packed postinstall on the lazy bundled dependency path", () => { expect(createPackedBundledPluginPostinstallEnv({ PATH: "/usr/bin" })).toEqual({ PATH: "/usr/bin", OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1", - OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS: "1", }); }); });