mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix(channels): repair bundled setup runtime deps
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<typeof import("./bundled.js")>(
|
||||
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,
|
||||
|
||||
@@ -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<string, readonly string[]>();
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user