mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(plugins): harden bundled install/uninstall sweep
Fix bundled plugin install/uninstall sweep coverage and avoid persisting invalid placeholder config for config-gated bundled plugins.
This commit is contained in:
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/models: move local-provider pricing opt-outs, OpenRouter/LiteLLM aliases, and proxy passthrough pricing lookup into plugin manifest metadata so core no longer carries extension-specific pricing tables. Thanks @codex.
|
||||
- Agents/Claude CLI: force live-session launches to include `--output-format stream-json` whenever OpenClaw adds `--input-format stream-json`, so new Claude CLI sessions no longer fail immediately while reusable sessions keep working. Fixes #72206. Thanks @kwangwonkoh and @Xivi08.
|
||||
- CLI/plugins: accept ClawHub plugin API wildcard ranges such as `*` without rejecting compatible plugin installs, while still requiring a valid runtime API version. Fixes #56446; supersedes #56466. Thanks @darconada and @claygeo.
|
||||
- CLI/plugins: let config-gated bundled plugins install without persisting invalid placeholder config entries, so install/uninstall sweeps can cover plugins such as memory-lancedb before the user configures credentials. Thanks @vincentkoc.
|
||||
- Agents/sessions: acquire the session write lock only after cold bootstrap, plugin, and tool setup so fallback runs are not blocked by stalled pre-model startup work. Thanks @codex.
|
||||
- Browser/plugins: auto-start the bundled browser plugin when root `browser` config is present, including restrictive plugin allowlists, and ignore stale persisted plugin registries whose package paths no longer exist. Thanks @codex.
|
||||
- Gateway/models: skip external OpenRouter and LiteLLM pricing refreshes for local/self-hosted model endpoints so startup does not wait on remote pricing catalogs for local-only Ollama, vLLM, and compatible providers. Thanks @codex.
|
||||
|
||||
@@ -1498,6 +1498,7 @@
|
||||
"test:docker:browser-cdp-snapshot": "bash scripts/e2e/browser-cdp-snapshot-docker.sh",
|
||||
"test:docker:bundled-channel-deps": "bash scripts/e2e/bundled-channel-runtime-deps-docker.sh",
|
||||
"test:docker:bundled-channel-deps:fast": "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=1 bash scripts/e2e/bundled-channel-runtime-deps-docker.sh",
|
||||
"test:docker:bundled-plugin-install-uninstall": "bash scripts/e2e/bundled-plugin-install-uninstall-docker.sh",
|
||||
"test:docker:cleanup": "bash scripts/test-cleanup-docker.sh",
|
||||
"test:docker:config-reload": "bash scripts/e2e/config-reload-source-docker.sh",
|
||||
"test:docker:crestodian-first-run": "bash scripts/e2e/crestodian-first-run-docker.sh",
|
||||
|
||||
228
scripts/e2e/bundled-plugin-install-uninstall-docker.sh
Executable file
228
scripts/e2e/bundled-plugin-install-uninstall-docker.sh
Executable file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
||||
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-bundled-plugin-install-uninstall-e2e" OPENCLAW_BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_IMAGE)"
|
||||
|
||||
docker_e2e_build_or_reuse "$IMAGE_NAME" bundled-plugin-install-uninstall
|
||||
|
||||
DOCKER_ENV_ARGS=(-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0)
|
||||
for env_name in \
|
||||
OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL \
|
||||
OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX \
|
||||
OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS; do
|
||||
env_value="${!env_name:-}"
|
||||
if [[ -n "$env_value" && "$env_value" != "undefined" && "$env_value" != "null" ]]; then
|
||||
DOCKER_ENV_ARGS+=(-e "$env_name")
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Running bundled plugin install/uninstall Docker E2E..."
|
||||
RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-bundled-plugin-install-uninstall.XXXXXX")"
|
||||
if ! docker run --rm "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" bash -s >"$RUN_LOG" 2>&1 <<'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
if [ -f dist/index.mjs ]; then
|
||||
OPENCLAW_ENTRY="dist/index.mjs"
|
||||
elif [ -f dist/index.js ]; then
|
||||
OPENCLAW_ENTRY="dist/index.js"
|
||||
else
|
||||
echo "Missing dist/index.(m)js (build output):"
|
||||
ls -la dist || true
|
||||
exit 1
|
||||
fi
|
||||
export OPENCLAW_ENTRY
|
||||
|
||||
home_dir=$(mktemp -d "/tmp/openclaw-bundled-plugin-sweep.XXXXXX")
|
||||
export HOME="$home_dir"
|
||||
|
||||
node - <<'NODE' > /tmp/bundled-plugin-sweep-ids
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const explicit = (process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS || "")
|
||||
.split(/[,\s]+/u)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
const extensionRoot = path.join(process.cwd(), "dist", "extensions");
|
||||
const manifestEntries = fs
|
||||
.readdirSync(extensionRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => {
|
||||
const manifestPath = path.join(extensionRoot, entry.name, "openclaw.plugin.json");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return null;
|
||||
}
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const id = typeof manifest.id === "string" ? manifest.id.trim() : "";
|
||||
if (!id) {
|
||||
throw new Error(`Bundled plugin manifest is missing id: ${manifestPath}`);
|
||||
}
|
||||
const required = manifest.configSchema?.required;
|
||||
return {
|
||||
id,
|
||||
dir: entry.name,
|
||||
requiresConfig:
|
||||
Array.isArray(required) && required.some((value) => typeof value === "string"),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
const allEntries =
|
||||
explicit.length > 0
|
||||
? explicit.map(
|
||||
(lookup) =>
|
||||
manifestEntries.find((entry) => entry.id === lookup || entry.dir === lookup) || {
|
||||
id: lookup,
|
||||
dir: lookup,
|
||||
requiresConfig: false,
|
||||
},
|
||||
)
|
||||
: manifestEntries;
|
||||
|
||||
const total = Number.parseInt(process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL || "1", 10);
|
||||
const index = Number.parseInt(process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX || "0", 10);
|
||||
if (!Number.isInteger(total) || total < 1) {
|
||||
throw new Error(`OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL must be >= 1, got ${process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL}`);
|
||||
}
|
||||
if (!Number.isInteger(index) || index < 0 || index >= total) {
|
||||
throw new Error(`OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX must be in [0, ${total - 1}], got ${process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX}`);
|
||||
}
|
||||
|
||||
const selected = allEntries.filter((_, candidateIndex) => candidateIndex % total === index);
|
||||
if (selected.length === 0) {
|
||||
throw new Error(`No bundled plugin ids selected for shard ${index}/${total}`);
|
||||
}
|
||||
|
||||
for (const entry of selected) {
|
||||
console.log(`${entry.id}\t${entry.dir}\t${entry.requiresConfig ? "1" : "0"}`);
|
||||
}
|
||||
NODE
|
||||
|
||||
mapfile -t plugin_entries < /tmp/bundled-plugin-sweep-ids
|
||||
selected_labels=()
|
||||
for plugin_entry in "${plugin_entries[@]}"; do
|
||||
IFS=$'\t' read -r plugin_id plugin_dir _requires_config <<<"$plugin_entry"
|
||||
selected_labels+=("${plugin_id}@${plugin_dir}")
|
||||
done
|
||||
echo "Selected ${#plugin_entries[@]} bundled plugins for shard ${OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX:-0}/${OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL:-1}: ${selected_labels[*]}"
|
||||
|
||||
assert_installed() {
|
||||
local plugin_id="$1"
|
||||
local plugin_dir="$2"
|
||||
local requires_config="$3"
|
||||
node - <<'NODE' "$plugin_id" "$plugin_dir" "$requires_config"
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const pluginId = process.argv[2];
|
||||
const pluginDir = process.argv[3];
|
||||
const requiresConfig = process.argv[4] === "1";
|
||||
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
|
||||
const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json");
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
|
||||
const records = index.installRecords ?? index.records ?? {};
|
||||
const record = records[pluginId];
|
||||
if (!record) {
|
||||
throw new Error(`missing install record for ${pluginId}`);
|
||||
}
|
||||
if (record.source !== "path") {
|
||||
throw new Error(`expected bundled install record source=path for ${pluginId}, got ${record.source}`);
|
||||
}
|
||||
if (typeof record.sourcePath !== "string" || !record.sourcePath.includes(`/dist/extensions/${pluginDir}`)) {
|
||||
throw new Error(`unexpected bundled source path for ${pluginId}: ${record.sourcePath}`);
|
||||
}
|
||||
if (record.installPath !== record.sourcePath) {
|
||||
throw new Error(`bundled install path should equal source path for ${pluginId}`);
|
||||
}
|
||||
const paths = config.plugins?.load?.paths || [];
|
||||
if (!paths.includes(record.sourcePath)) {
|
||||
throw new Error(`config load paths do not include bundled install path for ${pluginId}`);
|
||||
}
|
||||
if (requiresConfig && config.plugins?.entries?.[pluginId]?.enabled === true) {
|
||||
throw new Error(`plugin requiring config should not be enabled immediately after install for ${pluginId}`);
|
||||
}
|
||||
if (!requiresConfig && config.plugins?.entries?.[pluginId]?.enabled !== true) {
|
||||
throw new Error(`config entry is not enabled after install for ${pluginId}`);
|
||||
}
|
||||
const allow = config.plugins?.allow || [];
|
||||
if (Array.isArray(allow) && allow.length > 0 && !allow.includes(pluginId)) {
|
||||
throw new Error(`existing allowlist does not include ${pluginId} after install`);
|
||||
}
|
||||
if ((config.plugins?.deny || []).includes(pluginId)) {
|
||||
throw new Error(`denylist contains ${pluginId} after install`);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
assert_uninstalled() {
|
||||
local plugin_id="$1"
|
||||
local plugin_dir="$2"
|
||||
node - <<'NODE' "$plugin_id" "$plugin_dir"
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const pluginId = process.argv[2];
|
||||
const pluginDir = process.argv[3];
|
||||
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
|
||||
const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json");
|
||||
const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {};
|
||||
const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf8")) : {};
|
||||
const records = index.installRecords ?? index.records ?? {};
|
||||
if (records[pluginId]) {
|
||||
throw new Error(`install record still present after uninstall for ${pluginId}`);
|
||||
}
|
||||
const paths = config.plugins?.load?.paths || [];
|
||||
if (paths.some((entry) => String(entry).includes(`/dist/extensions/${pluginDir}`))) {
|
||||
throw new Error(`load path still present after uninstall for ${pluginId}`);
|
||||
}
|
||||
if (config.plugins?.entries?.[pluginId]) {
|
||||
throw new Error(`config entry still present after uninstall for ${pluginId}`);
|
||||
}
|
||||
if ((config.plugins?.allow || []).includes(pluginId)) {
|
||||
throw new Error(`allowlist still contains ${pluginId} after uninstall`);
|
||||
}
|
||||
if ((config.plugins?.deny || []).includes(pluginId)) {
|
||||
throw new Error(`denylist still contains ${pluginId} after uninstall`);
|
||||
}
|
||||
const managedPath = path.join(process.env.HOME, ".openclaw", "extensions", pluginId);
|
||||
if (fs.existsSync(managedPath)) {
|
||||
throw new Error(`managed install directory unexpectedly exists for bundled plugin ${pluginId}: ${managedPath}`);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
plugin_index=0
|
||||
for plugin_entry in "${plugin_entries[@]}"; do
|
||||
IFS=$'\t' read -r plugin_id plugin_dir requires_config <<<"$plugin_entry"
|
||||
install_log="/tmp/openclaw-install-${plugin_index}.log"
|
||||
uninstall_log="/tmp/openclaw-uninstall-${plugin_index}.log"
|
||||
echo "Installing bundled plugin: $plugin_id ($plugin_dir)"
|
||||
node "$OPENCLAW_ENTRY" plugins install "$plugin_id" >"$install_log" 2>&1 || {
|
||||
cat "$install_log"
|
||||
exit 1
|
||||
}
|
||||
assert_installed "$plugin_id" "$plugin_dir" "$requires_config"
|
||||
|
||||
echo "Uninstalling bundled plugin: $plugin_id ($plugin_dir)"
|
||||
node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force >"$uninstall_log" 2>&1 || {
|
||||
cat "$uninstall_log"
|
||||
exit 1
|
||||
}
|
||||
assert_uninstalled "$plugin_id" "$plugin_dir"
|
||||
plugin_index=$((plugin_index + 1))
|
||||
done
|
||||
|
||||
echo "bundled plugin install/uninstall sweep passed (${#plugin_entries[@]} plugin(s))"
|
||||
EOF
|
||||
then
|
||||
cat "$RUN_LOG"
|
||||
rm -f "$RUN_LOG"
|
||||
exit 1
|
||||
fi
|
||||
cat "$RUN_LOG"
|
||||
rm -f "$RUN_LOG"
|
||||
|
||||
echo "OK"
|
||||
@@ -215,6 +215,14 @@ export const mainLanes = [
|
||||
resources: ["npm", "service"],
|
||||
weight: 6,
|
||||
}),
|
||||
lane(
|
||||
"bundled-plugin-install-uninstall",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-plugin-install-uninstall",
|
||||
{
|
||||
resources: ["npm"],
|
||||
weight: 4,
|
||||
},
|
||||
),
|
||||
lane(
|
||||
"plugins-offline",
|
||||
"OPENCLAW_PLUGINS_E2E_CLAWHUB=0 OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins",
|
||||
@@ -387,6 +395,14 @@ const releasePathChunks = {
|
||||
resources: ["npm", "service"],
|
||||
weight: 6,
|
||||
}),
|
||||
lane(
|
||||
"bundled-plugin-install-uninstall",
|
||||
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-plugin-install-uninstall",
|
||||
{
|
||||
resources: ["npm"],
|
||||
weight: 4,
|
||||
},
|
||||
),
|
||||
npmLane("plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"),
|
||||
...bundledScenarioLanes,
|
||||
serviceLane(
|
||||
|
||||
@@ -420,6 +420,42 @@ describe("plugins cli install", () => {
|
||||
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not persist incomplete config entries for config-gated bundled installs", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-lancedb": {
|
||||
config: {
|
||||
embedding: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
load: {
|
||||
paths: ["/existing/plugin"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
|
||||
await runPluginsCommand(["plugins", "install", "memory-lancedb"]);
|
||||
|
||||
const writtenConfig = writeConfigFile.mock.calls.at(-1)?.[0] as OpenClawConfig;
|
||||
expect(writtenConfig.plugins?.entries?.["memory-lancedb"]).toBeUndefined();
|
||||
expect(writtenConfig.plugins?.load?.paths).toEqual(
|
||||
expect.arrayContaining(["/existing/plugin", expect.stringContaining("memory-lancedb")]),
|
||||
);
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
|
||||
"memory-lancedb": expect.objectContaining({
|
||||
source: "path",
|
||||
sourcePath: expect.stringContaining("memory-lancedb"),
|
||||
installPath: expect.stringContaining("memory-lancedb"),
|
||||
}),
|
||||
});
|
||||
expect(enablePluginInConfig).not.toHaveBeenCalled();
|
||||
expect(applyExclusiveSlotSelection).not.toHaveBeenCalled();
|
||||
expect(runtimeLogs.some((line) => line.includes("requires configuration first"))).toBe(true);
|
||||
});
|
||||
|
||||
it("passes force through as overwrite mode for ClawHub installs", async () => {
|
||||
const cfg = {
|
||||
plugins: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import { collectChannelDoctorStaleConfigMutations } from "../commands/doctor/shared/channel-doctor.js";
|
||||
import { readConfigFileSnapshot } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { installHooksFromNpmSpec, installHooksFromPath } from "../hooks/install.js";
|
||||
import { resolveArchiveKind } from "../infra/archive.js";
|
||||
import { parseClawHubPluginSpec } from "../infra/clawhub.js";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
installPluginFromMarketplace,
|
||||
resolveMarketplaceInstallShortcut,
|
||||
} from "../plugins/marketplace.js";
|
||||
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
@@ -52,6 +54,54 @@ function resolveInstallSafetyOverrides(overrides: InstallSafetyOverrides): Insta
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isEmptyRecord(value: Record<string, unknown>): boolean {
|
||||
return Object.keys(value).length === 0;
|
||||
}
|
||||
|
||||
function hasValidBundledPluginConfig(params: {
|
||||
bundledSource: BundledPluginSource;
|
||||
existingEntry: unknown;
|
||||
}): boolean {
|
||||
if (!params.bundledSource.requiresConfig) {
|
||||
return true;
|
||||
}
|
||||
if (!isRecord(params.existingEntry)) {
|
||||
return false;
|
||||
}
|
||||
const config = params.existingEntry.config;
|
||||
if (!isRecord(config)) {
|
||||
return false;
|
||||
}
|
||||
if (!params.bundledSource.configSchema) {
|
||||
return !isEmptyRecord(config);
|
||||
}
|
||||
return validateJsonSchemaValue({
|
||||
schema: params.bundledSource.configSchema,
|
||||
cacheKey: `bundled-install:${params.bundledSource.pluginId}`,
|
||||
value: config,
|
||||
applyDefaults: true,
|
||||
}).ok;
|
||||
}
|
||||
|
||||
function prepareConfigForDisabledBundledInstall(
|
||||
config: OpenClawConfig,
|
||||
pluginId: string,
|
||||
): OpenClawConfig {
|
||||
const entries = config.plugins?.entries ?? {};
|
||||
const { [pluginId]: _removedEntry, ...nextEntries } = entries;
|
||||
return {
|
||||
...config,
|
||||
plugins: {
|
||||
...config.plugins,
|
||||
entries: nextEntries,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function installBundledPluginSource(params: {
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
rawSpec: string;
|
||||
@@ -60,14 +110,25 @@ async function installBundledPluginSource(params: {
|
||||
}) {
|
||||
const existing = params.snapshot.config.plugins?.load?.paths ?? [];
|
||||
const mergedPaths = Array.from(new Set([...existing, params.bundledSource.localPath]));
|
||||
const existingEntry = params.snapshot.config.plugins?.entries?.[params.bundledSource.pluginId];
|
||||
const shouldEnable = hasValidBundledPluginConfig({
|
||||
bundledSource: params.bundledSource,
|
||||
existingEntry,
|
||||
});
|
||||
const configBase = shouldEnable
|
||||
? params.snapshot.config
|
||||
: prepareConfigForDisabledBundledInstall(params.snapshot.config, params.bundledSource.pluginId);
|
||||
const configWarning = shouldEnable
|
||||
? ""
|
||||
: `Installed bundled plugin "${params.bundledSource.pluginId}" without enabling it because it requires configuration first. Configure it, then run \`openclaw plugins enable ${params.bundledSource.pluginId}\`.`;
|
||||
await persistPluginInstall({
|
||||
snapshot: {
|
||||
config: {
|
||||
...params.snapshot.config,
|
||||
...configBase,
|
||||
plugins: {
|
||||
...params.snapshot.config.plugins,
|
||||
...configBase.plugins,
|
||||
load: {
|
||||
...params.snapshot.config.plugins?.load,
|
||||
...configBase.plugins?.load,
|
||||
paths: mergedPaths,
|
||||
},
|
||||
},
|
||||
@@ -81,7 +142,8 @@ async function installBundledPluginSource(params: {
|
||||
sourcePath: params.bundledSource.localPath,
|
||||
installPath: params.bundledSource.localPath,
|
||||
},
|
||||
warningMessage: params.warning,
|
||||
enable: shouldEnable,
|
||||
warningMessage: [params.warning, configWarning].filter(Boolean).join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
applyExclusiveSlotSelection,
|
||||
enablePluginInConfig,
|
||||
refreshPluginRegistry,
|
||||
resetPluginsCliTestState,
|
||||
@@ -107,4 +108,68 @@ describe("persistPluginInstall", () => {
|
||||
|
||||
expect(next).toEqual(enabledConfig);
|
||||
});
|
||||
|
||||
it("can persist an install record without enabling a plugin that needs config first", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const next = await persistPluginInstall({
|
||||
snapshot: {
|
||||
config: baseConfig,
|
||||
baseHash: "config-1",
|
||||
},
|
||||
pluginId: "memory-lancedb",
|
||||
enable: false,
|
||||
install: {
|
||||
source: "path",
|
||||
spec: "memory-lancedb",
|
||||
sourcePath: "/app/dist/extensions/memory-lancedb",
|
||||
installPath: "/app/dist/extensions/memory-lancedb",
|
||||
},
|
||||
});
|
||||
|
||||
expect(next).toEqual(baseConfig);
|
||||
expect(enablePluginInConfig).not.toHaveBeenCalled();
|
||||
expect(applyExclusiveSlotSelection).not.toHaveBeenCalled();
|
||||
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
|
||||
"memory-lancedb": expect.objectContaining({
|
||||
source: "path",
|
||||
sourcePath: "/app/dist/extensions/memory-lancedb",
|
||||
}),
|
||||
});
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(baseConfig);
|
||||
});
|
||||
|
||||
it("does not add disabled installs to restrictive allowlists", async () => {
|
||||
const { persistPluginInstall } = await import("./plugins-install-persist.js");
|
||||
const baseConfig = {
|
||||
plugins: {
|
||||
allow: ["memory-core"],
|
||||
deny: ["memory-lancedb"],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const next = await persistPluginInstall({
|
||||
snapshot: {
|
||||
config: baseConfig,
|
||||
baseHash: "config-1",
|
||||
},
|
||||
pluginId: "memory-lancedb",
|
||||
enable: false,
|
||||
install: {
|
||||
source: "path",
|
||||
spec: "memory-lancedb",
|
||||
sourcePath: "/app/dist/extensions/memory-lancedb",
|
||||
installPath: "/app/dist/extensions/memory-lancedb",
|
||||
},
|
||||
});
|
||||
|
||||
expect(next.plugins?.allow).toEqual(["memory-core"]);
|
||||
expect(next.plugins?.deny).toEqual(["memory-lancedb"]);
|
||||
expect(next.plugins?.entries?.["memory-lancedb"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,20 +61,32 @@ export async function persistPluginInstall(params: {
|
||||
snapshot: ConfigSnapshotForInstallPersist;
|
||||
pluginId: string;
|
||||
install: Omit<PluginInstallUpdate, "pluginId">;
|
||||
enable?: boolean;
|
||||
successMessage?: string;
|
||||
warningMessage?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const installConfig = removeInstalledPluginFromDenylist(
|
||||
addInstalledPluginToAllowlist(params.snapshot.config, params.pluginId),
|
||||
params.pluginId,
|
||||
);
|
||||
let next = enablePluginInConfig(installConfig, params.pluginId).config;
|
||||
const installConfig =
|
||||
params.enable === false
|
||||
? params.snapshot.config
|
||||
: removeInstalledPluginFromDenylist(
|
||||
addInstalledPluginToAllowlist(params.snapshot.config, params.pluginId),
|
||||
params.pluginId,
|
||||
);
|
||||
let next =
|
||||
params.enable === false
|
||||
? installConfig
|
||||
: enablePluginInConfig(installConfig, params.pluginId, {
|
||||
updateChannelConfig: false,
|
||||
}).config;
|
||||
const installRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
const nextInstallRecords = recordPluginInstallInRecords(installRecords, {
|
||||
pluginId: params.pluginId,
|
||||
...params.install,
|
||||
});
|
||||
const slotResult = applySlotSelectionForPlugin(next, params.pluginId);
|
||||
const slotResult =
|
||||
params.enable === false
|
||||
? { config: next, warnings: [] }
|
||||
: applySlotSelectionForPlugin(next, params.pluginId);
|
||||
next = withoutPluginInstallRecords(slotResult.config);
|
||||
await commitPluginInstallRecordsWithConfig({
|
||||
previousInstallRecords: installRecords,
|
||||
|
||||
@@ -48,10 +48,24 @@ function setBundledDiscoveryCandidates(candidates: unknown[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function setBundledManifestIdsByRoot(manifestIds: Record<string, string>) {
|
||||
function setBundledManifestIdsByRoot(
|
||||
manifestIds: Record<string, string | { id: string; required?: string[] }>,
|
||||
) {
|
||||
loadPluginManifestMock.mockImplementation((rootDir: string) =>
|
||||
rootDir in manifestIds
|
||||
? { ok: true, manifest: { id: manifestIds[rootDir] } }
|
||||
? {
|
||||
ok: true,
|
||||
manifest:
|
||||
typeof manifestIds[rootDir] === "string"
|
||||
? { id: manifestIds[rootDir] }
|
||||
: {
|
||||
id: manifestIds[rootDir].id,
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: manifestIds[rootDir].required,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
ok: false,
|
||||
error: "invalid manifest",
|
||||
@@ -81,11 +95,15 @@ function createResolvedBundledSource(params: {
|
||||
pluginId: string;
|
||||
localPath: string;
|
||||
npmSpec?: string;
|
||||
configSchema?: Record<string, unknown>;
|
||||
requiresConfig?: boolean;
|
||||
}) {
|
||||
return {
|
||||
pluginId: params.pluginId,
|
||||
localPath: params.localPath,
|
||||
npmSpec: params.npmSpec ?? `@openclaw/${params.pluginId}`,
|
||||
...(params.configSchema ? { configSchema: params.configSchema } : {}),
|
||||
requiresConfig: params.requiresConfig ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,6 +230,33 @@ describe("bundled plugin sources", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks bundled sources that require plugin config before activation", () => {
|
||||
setBundledDiscoveryCandidates([
|
||||
createBundledCandidate({
|
||||
rootDir: appBundledPluginRoot("memory-lancedb"),
|
||||
packageName: "@openclaw/memory-lancedb",
|
||||
}),
|
||||
]);
|
||||
setBundledManifestIdsByRoot({
|
||||
[appBundledPluginRoot("memory-lancedb")]: {
|
||||
id: "memory-lancedb",
|
||||
required: ["embedding"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveBundledPluginSources({}).get("memory-lancedb")).toEqual(
|
||||
createResolvedBundledSource({
|
||||
pluginId: "memory-lancedb",
|
||||
localPath: appBundledPluginRoot("memory-lancedb"),
|
||||
configSchema: {
|
||||
type: "object",
|
||||
required: ["embedding"],
|
||||
},
|
||||
requiresConfig: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses a pre-resolved bundled map for repeated lookups", () => {
|
||||
const bundled = new Map([
|
||||
[
|
||||
|
||||
@@ -6,6 +6,8 @@ export type BundledPluginSource = {
|
||||
pluginId: string;
|
||||
localPath: string;
|
||||
npmSpec?: string;
|
||||
configSchema?: Record<string, unknown>;
|
||||
requiresConfig?: boolean;
|
||||
};
|
||||
|
||||
export type BundledPluginLookup =
|
||||
@@ -64,12 +66,28 @@ export function resolveBundledPluginSources(params: {
|
||||
pluginId,
|
||||
localPath: candidate.rootDir,
|
||||
npmSpec,
|
||||
...(isRecord(manifest.manifest.configSchema)
|
||||
? { configSchema: manifest.manifest.configSchema }
|
||||
: {}),
|
||||
requiresConfig: pluginConfigSchemaHasRequiredFields(manifest.manifest.configSchema),
|
||||
});
|
||||
}
|
||||
|
||||
return bundled;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function pluginConfigSchemaHasRequiredFields(schema: unknown): boolean {
|
||||
if (!isRecord(schema)) {
|
||||
return false;
|
||||
}
|
||||
const required = schema.required;
|
||||
return Array.isArray(required) && required.some((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
export function findBundledPluginSource(params: {
|
||||
lookup: BundledPluginLookup;
|
||||
workspaceDir?: string;
|
||||
|
||||
@@ -150,4 +150,14 @@ describe("enablePluginInConfig", () => {
|
||||
assert,
|
||||
});
|
||||
});
|
||||
|
||||
it("can enable a built-in channel plugin entry without mutating channel config", () => {
|
||||
const result = enablePluginInConfig({} as OpenClawConfig, "twitch", {
|
||||
updateChannelConfig: false,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.twitch?.enabled).toBe(true);
|
||||
expect(result.config.channels?.twitch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,11 @@ export type PluginEnableResult = {
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export function enablePluginInConfig(cfg: OpenClawConfig, pluginId: string): PluginEnableResult {
|
||||
export function enablePluginInConfig(
|
||||
cfg: OpenClawConfig,
|
||||
pluginId: string,
|
||||
options: { updateChannelConfig?: boolean } = {},
|
||||
): PluginEnableResult {
|
||||
const builtInChannelId = normalizeChatChannelId(pluginId);
|
||||
const resolvedId = builtInChannelId ?? pluginId;
|
||||
if (cfg.plugins?.enabled === false) {
|
||||
@@ -26,5 +30,8 @@ export function enablePluginInConfig(cfg: OpenClawConfig, pluginId: string): Plu
|
||||
) {
|
||||
return { config: cfg, enabled: false, reason: "blocked by allowlist" };
|
||||
}
|
||||
return { config: setPluginEnabledInConfig(cfg, resolvedId, true), enabled: true };
|
||||
return {
|
||||
config: setPluginEnabledInConfig(cfg, resolvedId, true, options),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export function setPluginEnabledInConfig(
|
||||
config: OpenClawConfig,
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
options: { updateChannelConfig?: boolean } = {},
|
||||
): OpenClawConfig {
|
||||
const builtInChannelId = normalizeChatChannelId(pluginId);
|
||||
const resolvedId = builtInChannelId ?? pluginId;
|
||||
@@ -23,7 +24,7 @@ export function setPluginEnabledInConfig(
|
||||
},
|
||||
};
|
||||
|
||||
if (!builtInChannelId) {
|
||||
if (!builtInChannelId || options.updateChannelConfig === false) {
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ const INSTALL_E2E_RUNNER_PATH = "scripts/docker/install-sh-e2e/run.sh";
|
||||
const LIVE_CLI_BACKEND_DOCKER_PATH = "scripts/test-live-cli-backend-docker.sh";
|
||||
const LIVE_BUILD_DOCKER_PATH = "scripts/test-live-build-docker.sh";
|
||||
const OPENAI_WEB_SEARCH_MINIMAL_E2E_PATH = "scripts/e2e/openai-web-search-minimal-docker.sh";
|
||||
const BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH =
|
||||
"scripts/e2e/bundled-plugin-install-uninstall-docker.sh";
|
||||
const PLUGINS_DOCKER_E2E_PATH = "scripts/e2e/plugins-docker.sh";
|
||||
const PLUGIN_UPDATE_DOCKER_E2E_PATH = "scripts/e2e/plugin-update-unchanged-docker.sh";
|
||||
const DOCTOR_SWITCH_DOCKER_E2E_PATH = "scripts/e2e/doctor-install-switch-docker.sh";
|
||||
@@ -90,6 +92,7 @@ describe("docker build helper", () => {
|
||||
const scenarios = readFileSync(DOCKER_E2E_SCENARIOS_PATH, "utf8");
|
||||
|
||||
expect(scenarios).toContain('"plugins-offline"');
|
||||
expect(scenarios).toContain('"bundled-plugin-install-uninstall"');
|
||||
expect(scenarios).toContain("OPENCLAW_PLUGINS_E2E_CLAWHUB=0");
|
||||
expect(scenarios).toContain('"bundled-channel-deps-compat"');
|
||||
expect(scenarios).toContain("test:docker:bundled-channel-deps:fast");
|
||||
@@ -125,6 +128,19 @@ describe("docker build helper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps bundled plugin install/uninstall sweep chunkable", () => {
|
||||
const runner = readFileSync(BUNDLED_PLUGIN_INSTALL_UNINSTALL_E2E_PATH, "utf8");
|
||||
|
||||
expect(runner).toContain("OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL");
|
||||
expect(runner).toContain("OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX");
|
||||
expect(runner).toContain('"openclaw.plugin.json"');
|
||||
expect(runner).toContain("read -r plugin_id plugin_dir requires_config");
|
||||
expect(runner).toContain('node "$OPENCLAW_ENTRY" plugins install "$plugin_id"');
|
||||
expect(runner).toContain('node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force');
|
||||
expect(runner).toContain("assert_installed");
|
||||
expect(runner).toContain("assert_uninstalled");
|
||||
});
|
||||
|
||||
it("passes installer tag env to bash, not curl", () => {
|
||||
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
|
||||
|
||||
|
||||
@@ -111,6 +111,23 @@ describe("scripts/lib/docker-e2e-plan", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("plans bundled plugin install/uninstall as package-backed plugin coverage", () => {
|
||||
const plan = planFor({ selectedLaneNames: ["bundled-plugin-install-uninstall"] });
|
||||
|
||||
expect(plan.lanes).toEqual([
|
||||
expect.objectContaining({
|
||||
imageKind: "functional",
|
||||
live: false,
|
||||
name: "bundled-plugin-install-uninstall",
|
||||
resources: expect.arrayContaining(["docker", "npm"]),
|
||||
}),
|
||||
]);
|
||||
expect(plan.needs).toMatchObject({
|
||||
functionalImage: true,
|
||||
package: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown selected lanes with the available lane names", () => {
|
||||
expect(() => planFor({ selectedLaneNames: ["missing-lane"] })).toThrow(
|
||||
/OPENCLAW_DOCKER_ALL_LANES unknown lane\(s\): missing-lane/u,
|
||||
|
||||
Reference in New Issue
Block a user