mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:00:44 +00:00
fix(plugins): repair bundled deps on activation
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
[
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
167
src/plugins/bundled-runtime-root.ts
Normal file
167
src/plugins/bundled-runtime-root.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user