fix(plugins): repair bundled deps on activation

This commit is contained in:
Peter Steinberger
2026-04-22 20:21:20 +01:00
parent 4663e7394b
commit 9c733956c0
7 changed files with 369 additions and 196 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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,
[

View File

@@ -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<string, readonly string[]>();
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;

View File

@@ -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;

View File

@@ -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<string, readonly string[]>();
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.
}
}
}

View File

@@ -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",
});
});
});