mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
fix(doctor): repair configured missing plugins
This commit is contained in:
@@ -249,7 +249,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels/Telegram: require an observed Telegram send, edit, or fallback before treating a forum-topic final as delivered, so final replies generated in transcript no longer disappear from Telegram topics. Fixes #76554. (#76764) Thanks @bubucilo and @obviyus.
|
||||
- Plugins/update: keep externalized bundled npm bridge updates on the normal plugin security scanner path instead of granting source-linked official trust without artifact provenance. (#76765) Thanks @Lucenx9.
|
||||
- Agents/reply context: label replied-to messages as the current user message target in model-visible metadata, so short replies are grounded to their explicit reply target instead of nearby chat history. (#76817) Thanks @obviyus.
|
||||
- Config/validation: skip the `plugin not found` warning for `plugins.allow` entries that match a known channel id, so packaged installs where the gateway auto-enables a channel id (e.g. `discord`) without a corresponding plugin manifest no longer emit an unfixable doctor warning. Fixes #76872. Thanks @jack-stormentswe.
|
||||
- Doctor/plugins: install configured missing official plugins such as Discord and Brave during doctor/update repair, auto-enable repaired provider plugins, preserve config when a download fails, and stop auto-enable from inventing plugin entries when no manifest declares a configured channel. Fixes #76872. Thanks @jack-stormentswe.
|
||||
|
||||
## 2026.5.2
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ Notes:
|
||||
- State integrity checks now detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` requires an interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place.
|
||||
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
|
||||
- On Linux, doctor warns when the user's crontab still runs legacy `~/.openclaw/bin/ensure-whatsapp.sh`; that script is no longer maintained and can log false WhatsApp gateway outages when cron lacks the systemd user-bus environment.
|
||||
- Doctor cleans legacy plugin dependency staging state created by older OpenClaw versions. It also repairs missing configured downloadable plugins when the registry can resolve them, and the 2026.5.2 doctor pass automatically installs downloadable plugins that an older config already uses before marking the config touched for that release.
|
||||
- Doctor cleans legacy plugin dependency staging state created by older OpenClaw versions. It also repairs missing configured downloadable plugins when the registry can resolve them, and the 2026.5.2 doctor pass automatically installs downloadable plugins that an older config already uses before marking the config touched for that release. If the download fails, doctor reports the install error and preserves the configured plugin entry for the next repair attempt.
|
||||
- Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy.
|
||||
- Doctor quarantines invalid plugin config by disabling the affected `plugins.entries.<id>` entry and removing its invalid `config` payload. Gateway startup already skips only that bad plugin so other plugins and channels can keep running.
|
||||
- Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup.
|
||||
|
||||
@@ -416,6 +416,16 @@ function assertExternalPluginInstall(records, pluginId, packageName) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertConfiguredPluginAvailable(index, pluginId, packageName) {
|
||||
const records = index.installRecords ?? {};
|
||||
const bundled = (index.plugins ?? []).find((plugin) => plugin?.pluginId === pluginId);
|
||||
if (bundled) {
|
||||
assert(bundled.enabled !== false, `configured bundled ${pluginId} plugin is disabled`);
|
||||
return;
|
||||
}
|
||||
assertExternalPluginInstall(records, pluginId, packageName);
|
||||
}
|
||||
|
||||
function assertConfiguredPluginInstalls() {
|
||||
const coverage = getCoverage();
|
||||
const stage = process.env.OPENCLAW_UPGRADE_SURVIVOR_ASSERT_STAGE || "survival";
|
||||
@@ -432,7 +442,7 @@ function assertConfiguredPluginInstalls() {
|
||||
assert(!matrix, "internal matrix plugin should not be installed externally");
|
||||
assert(bundledMatrix, "configured bundled matrix plugin is missing from the plugin index");
|
||||
assert(bundledMatrix.enabled !== false, "configured bundled matrix plugin is disabled");
|
||||
assertExternalPluginInstall(records, "discord", "@openclaw/discord");
|
||||
assertConfiguredPluginAvailable(index, "brave", "@openclaw/brave-plugin");
|
||||
assert(!records.telegram, "internal telegram plugin should not be installed externally");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"allow": ["discord", "telegram", "matrix"],
|
||||
"allow": ["brave", "discord", "telegram", "matrix"],
|
||||
"entries": {
|
||||
"brave": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"webSearch": {
|
||||
"apiKey": {
|
||||
"source": "env",
|
||||
"provider": "default",
|
||||
"id": "BRAVE_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ export DISCORD_BOT_TOKEN="upgrade-survivor-discord-token"
|
||||
export TELEGRAM_BOT_TOKEN="123456:upgrade-survivor-telegram-token"
|
||||
export FEISHU_APP_SECRET="upgrade-survivor-feishu-secret"
|
||||
export MATRIX_ACCESS_TOKEN="upgrade-survivor-matrix-token"
|
||||
export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"
|
||||
|
||||
ARTIFACT_ROOT="$(dirname "${OPENCLAW_UPGRADE_SURVIVOR_SUMMARY_JSON:-/tmp/openclaw-upgrade-survivor-artifacts/summary.json}")"
|
||||
mkdir -p "$ARTIFACT_ROOT"
|
||||
@@ -40,6 +41,7 @@ CURRENT_PHASE="setup"
|
||||
FAILURE_PHASE=""
|
||||
FAILURE_MESSAGE=""
|
||||
gateway_pid=""
|
||||
plugin_registry_pid=""
|
||||
baseline_spec=""
|
||||
baseline_version=""
|
||||
baseline_version_expected="0"
|
||||
@@ -191,6 +193,9 @@ NODE
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [ -n "${plugin_registry_pid:-}" ]; then
|
||||
kill "$plugin_registry_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
|
||||
}
|
||||
|
||||
@@ -281,6 +286,92 @@ configured_plugin_installs_enabled() {
|
||||
[ "$SCENARIO" = "configured-plugin-installs" ]
|
||||
}
|
||||
|
||||
configure_configured_plugin_install_fixture_registry() {
|
||||
configured_plugin_installs_enabled || return 0
|
||||
|
||||
local fixture_root="$ARTIFACT_ROOT/configured-plugin-installs-npm-fixture"
|
||||
local package_dir="$fixture_root/package"
|
||||
local tarball="$fixture_root/openclaw-brave-plugin-2026.5.2.tgz"
|
||||
local port_file="$fixture_root/npm-registry-port"
|
||||
local log_file="$fixture_root/npm-registry.log"
|
||||
mkdir -p "$package_dir"
|
||||
FIXTURE_PACKAGE_DIR="$package_dir" node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const root = process.env.FIXTURE_PACKAGE_DIR;
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: "@openclaw/brave-plugin",
|
||||
version: "2026.5.2",
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "openclaw.plugin.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id: "brave",
|
||||
activation: { onStartup: false },
|
||||
providerAuthEnvVars: { brave: ["BRAVE_API_KEY"] },
|
||||
contracts: { webSearchProviders: ["brave"] },
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
webSearch: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
apiKey: { type: ["string", "object"] },
|
||||
mode: { type: "string", enum: ["web", "llm-context"] },
|
||||
baseUrl: { type: ["string", "object"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "index.js"),
|
||||
`module.exports = { id: "brave", name: "Brave Fixture", register() {} };\n`,
|
||||
);
|
||||
NODE
|
||||
tar -czf "$tarball" -C "$fixture_root" package
|
||||
node scripts/e2e/lib/plugins/npm-registry-server.mjs \
|
||||
"$port_file" \
|
||||
"@openclaw/brave-plugin" \
|
||||
"2026.5.2" \
|
||||
"$tarball" \
|
||||
>"$log_file" 2>&1 &
|
||||
plugin_registry_pid="$!"
|
||||
|
||||
for _ in $(seq 1 100); do
|
||||
if [ -s "$port_file" ]; then
|
||||
export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$port_file")"
|
||||
export npm_config_registry="$NPM_CONFIG_REGISTRY"
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$plugin_registry_pid" 2>/dev/null; then
|
||||
cat "$log_file" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
cat "$log_file" >&2 || true
|
||||
echo "Timed out waiting for configured plugin install npm fixture registry." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
legacy_plugin_dependency_probe_paths() {
|
||||
local plugin="$1"
|
||||
local plugin_dir
|
||||
@@ -699,6 +790,7 @@ phase seed-legacy-runtime-deps-symlink seed_legacy_runtime_deps_symlink
|
||||
phase resolve-candidate resolve_candidate_version
|
||||
phase update-candidate update_candidate
|
||||
phase assert-legacy-plugin-dependency-debris-before-doctor assert_legacy_plugin_dependency_debris_before_doctor
|
||||
phase configure-configured-plugin-install-fixture-registry configure_configured_plugin_install_fixture_registry
|
||||
phase doctor run_doctor
|
||||
phase assert-legacy-plugin-dependency-debris-cleaned assert_legacy_plugin_dependency_debris_cleaned
|
||||
phase assert-legacy-runtime-deps-symlink-repaired assert_legacy_runtime_deps_symlink_repaired
|
||||
|
||||
@@ -143,13 +143,104 @@ export OPENAI_API_KEY="sk-openclaw-upgrade-survivor"
|
||||
export DISCORD_BOT_TOKEN="upgrade-survivor-discord-token"
|
||||
export TELEGRAM_BOT_TOKEN="123456:upgrade-survivor-telegram-token"
|
||||
export FEISHU_APP_SECRET="upgrade-survivor-feishu-secret"
|
||||
export BRAVE_API_KEY="BSA_upgrade_survivor_brave_key"
|
||||
|
||||
gateway_pid=""
|
||||
plugin_registry_pid=""
|
||||
cleanup() {
|
||||
if [ -n "${plugin_registry_pid:-}" ]; then
|
||||
kill "$plugin_registry_pid" >/dev/null 2>&1 || true
|
||||
fi
|
||||
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
configure_configured_plugin_install_fixture_registry() {
|
||||
[ "${OPENCLAW_UPGRADE_SURVIVOR_SCENARIO:-base}" = "configured-plugin-installs" ] || return 0
|
||||
|
||||
local fixture_root="$OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_ROOT/configured-plugin-installs-npm-fixture"
|
||||
local package_dir="$fixture_root/package"
|
||||
local tarball="$fixture_root/openclaw-brave-plugin-2026.5.2.tgz"
|
||||
local port_file="$fixture_root/npm-registry-port"
|
||||
local log_file="$fixture_root/npm-registry.log"
|
||||
mkdir -p "$package_dir"
|
||||
FIXTURE_PACKAGE_DIR="$package_dir" node <<'"'"'NODE'"'"'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const root = process.env.FIXTURE_PACKAGE_DIR;
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "package.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: "@openclaw/brave-plugin",
|
||||
version: "2026.5.2",
|
||||
openclaw: { extensions: ["./index.js"] },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "openclaw.plugin.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
id: "brave",
|
||||
activation: { onStartup: false },
|
||||
providerAuthEnvVars: { brave: ["BRAVE_API_KEY"] },
|
||||
contracts: { webSearchProviders: ["brave"] },
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
webSearch: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
apiKey: { type: ["string", "object"] },
|
||||
mode: { type: "string", enum: ["web", "llm-context"] },
|
||||
baseUrl: { type: ["string", "object"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "index.js"),
|
||||
`module.exports = { id: "brave", name: "Brave Fixture", register() {} };\n`,
|
||||
);
|
||||
NODE
|
||||
tar -czf "$tarball" -C "$fixture_root" package
|
||||
node scripts/e2e/lib/plugins/npm-registry-server.mjs \
|
||||
"$port_file" \
|
||||
"@openclaw/brave-plugin" \
|
||||
"2026.5.2" \
|
||||
"$tarball" \
|
||||
>"$log_file" 2>&1 &
|
||||
plugin_registry_pid="$!"
|
||||
|
||||
for _ in $(seq 1 100); do
|
||||
if [ -s "$port_file" ]; then
|
||||
export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$port_file")"
|
||||
export npm_config_registry="$NPM_CONFIG_REGISTRY"
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$plugin_registry_pid" 2>/dev/null; then
|
||||
cat "$log_file" >&2 || true
|
||||
return 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
cat "$log_file" >&2 || true
|
||||
echo "Timed out waiting for configured plugin install npm fixture registry." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}"
|
||||
node scripts/e2e/lib/upgrade-survivor/assertions.mjs seed
|
||||
|
||||
@@ -178,6 +269,7 @@ if [ "$update_status" -ne 0 ]; then
|
||||
fi
|
||||
|
||||
echo "Running non-interactive doctor repair..."
|
||||
configure_configured_plugin_install_fixture_registry
|
||||
if ! openclaw doctor --fix --non-interactive >/tmp/openclaw-upgrade-survivor-doctor.log 2>&1; then
|
||||
echo "openclaw doctor failed" >&2
|
||||
cat /tmp/openclaw-upgrade-survivor-doctor.log >&2 || true
|
||||
@@ -220,7 +312,7 @@ node scripts/e2e/lib/upgrade-survivor/probe-gateway.mjs \
|
||||
--base-url "http://127.0.0.1:$PORT" \
|
||||
--path /readyz \
|
||||
--expect ready \
|
||||
--allow-failing discord,telegram,whatsapp,feishu \
|
||||
--allow-failing discord,telegram,whatsapp,feishu,matrix \
|
||||
--out /tmp/openclaw-upgrade-survivor-readyz.json
|
||||
|
||||
echo "Checking gateway RPC status..."
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { runDoctorRepairSequence } from "./repair-sequencing.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
applyPluginAutoEnable: vi.fn(),
|
||||
maybeRepairStalePluginConfig: vi.fn(),
|
||||
repairMissingConfiguredPluginInstalls: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
||||
applyPluginAutoEnable: mocks.applyPluginAutoEnable,
|
||||
}));
|
||||
|
||||
vi.mock("./shared/missing-configured-plugin-install.js", () => ({
|
||||
repairMissingConfiguredPluginInstalls: mocks.repairMissingConfiguredPluginInstalls,
|
||||
}));
|
||||
|
||||
vi.mock("./shared/channel-doctor.js", () => ({
|
||||
collectChannelDoctorRepairMutations: ({ cfg }: { cfg: OpenClawConfig }) => {
|
||||
const allowFrom = cfg.channels?.discord?.allowFrom as unknown[] | undefined;
|
||||
@@ -70,10 +84,7 @@ vi.mock("./shared/open-policy-allowfrom.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./shared/stale-plugin-config.js", () => ({
|
||||
maybeRepairStalePluginConfig: (cfg: OpenClawConfig) => ({
|
||||
config: cfg,
|
||||
changes: [],
|
||||
}),
|
||||
maybeRepairStalePluginConfig: mocks.maybeRepairStalePluginConfig,
|
||||
}));
|
||||
|
||||
vi.mock("./shared/invalid-plugin-config.js", () => ({
|
||||
@@ -128,6 +139,22 @@ vi.mock("./shared/exec-safe-bins.js", () => ({
|
||||
}));
|
||||
|
||||
describe("doctor repair sequencing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.applyPluginAutoEnable.mockImplementation((params: { config: OpenClawConfig }) => ({
|
||||
config: params.config,
|
||||
changes: [],
|
||||
}));
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
});
|
||||
mocks.maybeRepairStalePluginConfig.mockImplementation((cfg: OpenClawConfig) => ({
|
||||
config: cfg,
|
||||
changes: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it("applies ordered repairs and sanitizes empty-allowlist warnings", async () => {
|
||||
const result = await runDoctorRepairSequence({
|
||||
state: {
|
||||
@@ -231,4 +258,201 @@ describe("doctor repair sequencing", () => {
|
||||
expect(result.state.pendingChanges).toBe(false);
|
||||
expect(result.state.candidate.channels?.discord?.allowFrom).toEqual([106232522769186816]);
|
||||
});
|
||||
|
||||
it("auto-enables newly installed configured plugins after doctor repair", async () => {
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({
|
||||
changes: ['Installed missing configured plugin "brave" from @openclaw/brave-plugin.'],
|
||||
warnings: [],
|
||||
});
|
||||
mocks.applyPluginAutoEnable.mockImplementationOnce((params: { config: OpenClawConfig }) => ({
|
||||
config: {
|
||||
...params.config,
|
||||
plugins: {
|
||||
...params.config.plugins,
|
||||
allow: ["telegram", "brave"],
|
||||
entries: {
|
||||
...params.config.plugins?.entries,
|
||||
brave: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
changes: ["brave web search provider selected, enabled automatically."],
|
||||
}));
|
||||
|
||||
const result = await runDoctorRepairSequence({
|
||||
state: {
|
||||
cfg: {
|
||||
tools: { web: { search: { provider: "brave" } } },
|
||||
plugins: { allow: ["telegram"] },
|
||||
} as OpenClawConfig,
|
||||
candidate: {
|
||||
tools: { web: { search: { provider: "brave" } } },
|
||||
plugins: { allow: ["telegram"] },
|
||||
} as OpenClawConfig,
|
||||
pendingChanges: false,
|
||||
fixHints: [],
|
||||
},
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
});
|
||||
|
||||
expect(result.state.pendingChanges).toBe(true);
|
||||
expect(result.state.candidate.plugins?.allow).toEqual(["telegram", "brave"]);
|
||||
expect(result.state.candidate.plugins?.entries?.brave?.enabled).toBe(true);
|
||||
expect(result.changeNotes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Installed missing configured plugin "brave" from @openclaw/brave-plugin.',
|
||||
"brave web search provider selected, enabled automatically.",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not remove deferred configured plugins during the package update doctor pass", async () => {
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({
|
||||
changes: [
|
||||
'Deferred missing configured plugin "brave" install repair until post-update doctor.',
|
||||
],
|
||||
warnings: [],
|
||||
});
|
||||
mocks.maybeRepairStalePluginConfig.mockImplementationOnce((cfg: OpenClawConfig) => ({
|
||||
config: {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
allow: [],
|
||||
entries: {},
|
||||
},
|
||||
},
|
||||
changes: ["- plugins.entries: removed 1 stale plugin entry (brave)"],
|
||||
}));
|
||||
|
||||
const result = await runDoctorRepairSequence({
|
||||
state: {
|
||||
cfg: {
|
||||
plugins: {
|
||||
allow: ["brave"],
|
||||
entries: {
|
||||
brave: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BRAVE_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
candidate: {
|
||||
plugins: {
|
||||
allow: ["brave"],
|
||||
entries: {
|
||||
brave: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BRAVE_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
pendingChanges: false,
|
||||
fixHints: [],
|
||||
},
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
env: {
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.maybeRepairStalePluginConfig).not.toHaveBeenCalled();
|
||||
expect(result.state.candidate.plugins?.allow).toEqual(["brave"]);
|
||||
expect(result.state.candidate.plugins?.entries?.brave?.enabled).toBe(true);
|
||||
expect(result.changeNotes).toContain(
|
||||
'Deferred missing configured plugin "brave" install repair until post-update doctor.',
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves configured plugins when their install repair fails", async () => {
|
||||
mocks.repairMissingConfiguredPluginInstalls.mockResolvedValueOnce({
|
||||
changes: [],
|
||||
warnings: [
|
||||
'Failed to install missing configured plugin "brave" from @openclaw/brave-plugin: package install failed',
|
||||
],
|
||||
});
|
||||
mocks.maybeRepairStalePluginConfig.mockImplementationOnce((cfg: OpenClawConfig) => ({
|
||||
config: {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
allow: [],
|
||||
entries: {},
|
||||
},
|
||||
},
|
||||
changes: ["plugins.entries: removed 1 stale plugin entry (brave)"],
|
||||
}));
|
||||
|
||||
const result = await runDoctorRepairSequence({
|
||||
state: {
|
||||
cfg: {
|
||||
plugins: {
|
||||
allow: ["brave"],
|
||||
entries: {
|
||||
brave: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BRAVE_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
candidate: {
|
||||
plugins: {
|
||||
allow: ["brave"],
|
||||
entries: {
|
||||
brave: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BRAVE_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
pendingChanges: false,
|
||||
fixHints: [],
|
||||
},
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
});
|
||||
|
||||
expect(mocks.maybeRepairStalePluginConfig).not.toHaveBeenCalled();
|
||||
expect(result.state.candidate.plugins?.allow).toEqual(["brave"]);
|
||||
expect(result.state.candidate.plugins?.entries?.brave?.enabled).toBe(true);
|
||||
expect(result.state.pendingChanges).toBe(false);
|
||||
expect(result.warningNotes).toContain(
|
||||
'Failed to install missing configured plugin "brave" from @openclaw/brave-plugin: package install failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
|
||||
import { sanitizeForLog } from "../../terminal/ansi.js";
|
||||
import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js";
|
||||
import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js";
|
||||
@@ -18,6 +19,12 @@ import { maybeRepairOpenPolicyAllowFrom } from "./shared/open-policy-allowfrom.j
|
||||
import { cleanupLegacyPluginDependencyState } from "./shared/plugin-dependency-cleanup.js";
|
||||
import { maybeRepairStalePluginConfig } from "./shared/stale-plugin-config.js";
|
||||
|
||||
const UPDATE_IN_PROGRESS_ENV = "OPENCLAW_UPDATE_IN_PROGRESS";
|
||||
|
||||
function isUpdatePackageDoctorPass(env: NodeJS.ProcessEnv): boolean {
|
||||
return env[UPDATE_IN_PROGRESS_ENV] === "1";
|
||||
}
|
||||
|
||||
export async function runDoctorRepairSequence(params: {
|
||||
state: DoctorConfigMutationState;
|
||||
doctorFixCommand: string;
|
||||
@@ -66,11 +73,16 @@ export async function runDoctorRepairSequence(params: {
|
||||
});
|
||||
if (missingConfiguredPluginInstallRepair.changes.length > 0) {
|
||||
changeNotes.push(sanitizeLines(missingConfiguredPluginInstallRepair.changes));
|
||||
applyMutation(applyPluginAutoEnable({ config: state.candidate, env }));
|
||||
}
|
||||
if (missingConfiguredPluginInstallRepair.warnings.length > 0) {
|
||||
warningNotes.push(sanitizeLines(missingConfiguredPluginInstallRepair.warnings));
|
||||
}
|
||||
applyMutation(maybeRepairStalePluginConfig(state.candidate, env));
|
||||
const missingConfiguredPluginInstallFailed =
|
||||
missingConfiguredPluginInstallRepair.warnings.length > 0;
|
||||
if (!isUpdatePackageDoctorPass(env) && !missingConfiguredPluginInstallFailed) {
|
||||
applyMutation(maybeRepairStalePluginConfig(state.candidate, env));
|
||||
}
|
||||
applyMutation(maybeRepairInvalidPluginConfig(state.candidate));
|
||||
applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate));
|
||||
|
||||
|
||||
@@ -467,19 +467,30 @@ describe("config plugin validation", () => {
|
||||
expect(res.warnings).not.toContainEqual(expect.objectContaining({ path: "channels.telegarm" }));
|
||||
});
|
||||
|
||||
it("does not warn when plugins.allow contains a known channel id without a plugin manifest (#76872)", async () => {
|
||||
const res = validateInSuite({
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
channels: {
|
||||
discord: { token: "xxx" },
|
||||
it("warns when plugins.allow contains a channel id without a plugin manifest (#76872)", async () => {
|
||||
const res = validateConfigObjectWithPlugins(
|
||||
{
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
channels: {
|
||||
discord: { token: "xxx" },
|
||||
},
|
||||
plugins: {
|
||||
allow: ["discord"],
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
allow: ["discord"],
|
||||
{
|
||||
env: suiteEnv(),
|
||||
pluginMetadataSnapshot: {
|
||||
manifestRegistry: {
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.warnings ?? []).not.toContainEqual({
|
||||
expect(res.warnings ?? []).toContainEqual({
|
||||
path: "plugins.allow",
|
||||
message:
|
||||
"plugin not found: discord (stale config entry ignored; remove it from plugins config)",
|
||||
|
||||
@@ -319,7 +319,7 @@ describe("applyPluginAutoEnable channels", () => {
|
||||
expect(result.config.plugins?.entries?.qqbot?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to channel key as plugin id when no installed manifest declares the channel", () => {
|
||||
it("does not synthesize plugin entries when no installed manifest declares the channel", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { "unknown-chan": { someKey: "value" } },
|
||||
@@ -328,7 +328,9 @@ describe("applyPluginAutoEnable channels", () => {
|
||||
manifestRegistry: makeRegistry([]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["unknown-chan"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.["unknown-chan"]).toBeUndefined();
|
||||
expect(result.config.plugins?.allow).toBeUndefined();
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -73,6 +73,37 @@ describe("applyPluginAutoEnable providers", () => {
|
||||
expect(result.changes).toContain("xai web search configured, enabled automatically.");
|
||||
});
|
||||
|
||||
it("auto-enables selected web search provider plugins under restrictive allowlists", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
},
|
||||
env,
|
||||
manifestRegistry: makeRegistry([
|
||||
{
|
||||
id: "brave",
|
||||
channels: [],
|
||||
contracts: {
|
||||
webSearchProviders: ["brave"],
|
||||
},
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.brave?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.allow).toEqual(["telegram", "brave"]);
|
||||
expect(result.changes).toContain("brave web search provider selected, enabled automatically.");
|
||||
});
|
||||
|
||||
it("materializes xai setup auto-enable when the plugin-owned x_search tool is configured", () => {
|
||||
const result = materializePluginAutoEnableCandidates({
|
||||
config: {
|
||||
|
||||
@@ -235,6 +235,21 @@ function resolvePluginIdForConfiguredWebFetchProvider(
|
||||
)?.id;
|
||||
}
|
||||
|
||||
function resolvePluginIdForConfiguredWebSearchProvider(
|
||||
providerId: string | undefined,
|
||||
registry: PluginManifestRegistry,
|
||||
): string | undefined {
|
||||
const normalizedProviderId = normalizeOptionalLowercaseString(providerId);
|
||||
if (!normalizedProviderId) {
|
||||
return undefined;
|
||||
}
|
||||
return registry.plugins.find((plugin) =>
|
||||
(plugin.contracts?.webSearchProviders ?? []).some(
|
||||
(candidate) => normalizeOptionalLowercaseString(candidate) === normalizedProviderId,
|
||||
),
|
||||
)?.id;
|
||||
}
|
||||
|
||||
function normalizeManifestChannelId(channelId: string): string {
|
||||
return normalizeChatChannelId(channelId) ?? channelId;
|
||||
}
|
||||
@@ -265,7 +280,7 @@ function collectPluginIdsForConfiguredChannel(
|
||||
}
|
||||
|
||||
if (claims.length === 0) {
|
||||
return [builtInId ?? normalizedChannelId];
|
||||
return [];
|
||||
}
|
||||
|
||||
const claimIds = new Set(claims.map((claim) => claim.plugin.id));
|
||||
@@ -310,6 +325,13 @@ function hasConfiguredWebSearchPluginEntry(cfg: OpenClawConfig): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredWebSearchProviderSelection(cfg: OpenClawConfig): boolean {
|
||||
const provider = cfg.tools?.web?.search?.provider;
|
||||
return (
|
||||
cfg.tools?.web?.search?.enabled !== false && typeof provider === "string" && !!provider.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function hasConfiguredWebFetchPluginEntry(cfg: OpenClawConfig): boolean {
|
||||
const entries = cfg.plugins?.entries;
|
||||
return (
|
||||
@@ -494,6 +516,9 @@ function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig, env: NodeJS.Pr
|
||||
if (hasConfiguredProviderModelOrHarness(cfg, env)) {
|
||||
return true;
|
||||
}
|
||||
if (hasConfiguredWebSearchProviderSelection(cfg)) {
|
||||
return true;
|
||||
}
|
||||
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
|
||||
if (!configuredChannels || typeof configuredChannels !== "object") {
|
||||
return false;
|
||||
@@ -526,7 +551,11 @@ export function configMayNeedPluginAutoEnable(
|
||||
if (hasConfiguredProviderModelOrHarness(cfg, env)) {
|
||||
return true;
|
||||
}
|
||||
if (hasConfiguredWebSearchPluginEntry(cfg) || hasConfiguredWebFetchPluginEntry(cfg)) {
|
||||
if (
|
||||
hasConfiguredWebSearchProviderSelection(cfg) ||
|
||||
hasConfiguredWebSearchPluginEntry(cfg) ||
|
||||
hasConfiguredWebFetchPluginEntry(cfg)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!hasSetupAutoEnableRelevantConfig(cfg)) {
|
||||
@@ -553,6 +582,8 @@ export function resolvePluginAutoEnableCandidateReason(
|
||||
return `${candidate.modelRef} model configured`;
|
||||
case "agent-harness-runtime-configured":
|
||||
return `${candidate.runtime} agent runtime configured`;
|
||||
case "web-search-provider-selected":
|
||||
return `${candidate.providerId} web search provider selected`;
|
||||
case "web-fetch-provider-selected":
|
||||
return `${candidate.providerId} web fetch provider selected`;
|
||||
case "plugin-web-search-configured":
|
||||
@@ -614,6 +645,22 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const webSearchProvider =
|
||||
typeof params.config.tools?.web?.search?.provider === "string"
|
||||
? params.config.tools.web.search.provider
|
||||
: undefined;
|
||||
const webSearchPluginId = resolvePluginIdForConfiguredWebSearchProvider(
|
||||
webSearchProvider,
|
||||
params.registry,
|
||||
);
|
||||
if (webSearchPluginId) {
|
||||
changes.push({
|
||||
pluginId: webSearchPluginId,
|
||||
kind: "web-search-provider-selected",
|
||||
providerId: normalizeOptionalLowercaseString(webSearchProvider) ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
const webFetchProvider =
|
||||
typeof params.config.tools?.web?.fetch?.provider === "string"
|
||||
? params.config.tools.web.fetch.provider
|
||||
|
||||
@@ -21,6 +21,11 @@ export type PluginAutoEnableCandidate =
|
||||
kind: "agent-harness-runtime-configured";
|
||||
runtime: string;
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "web-search-provider-selected";
|
||||
providerId: string;
|
||||
}
|
||||
| {
|
||||
pluginId: string;
|
||||
kind: "web-fetch-provider-selected";
|
||||
|
||||
@@ -794,7 +794,6 @@ function validateConfigObjectWithPluginsBase(
|
||||
type RegistryInfo = {
|
||||
registry: PluginManifestRegistry;
|
||||
knownIds?: Set<string>;
|
||||
knownChannelIds?: Set<string>;
|
||||
overriddenPluginIds?: Set<string>;
|
||||
normalizedPlugins?: ReturnType<typeof normalizePluginsConfig>;
|
||||
channelSchemas?: Map<
|
||||
@@ -909,29 +908,6 @@ function validateConfigObjectWithPluginsBase(
|
||||
return info.knownIds;
|
||||
};
|
||||
|
||||
const ensureKnownChannelIds = (): Set<string> => {
|
||||
const info = ensureRegistry();
|
||||
if (!info.knownChannelIds) {
|
||||
const ids = new Set<string>();
|
||||
for (const channelId of CHANNEL_IDS) {
|
||||
const normalized = normalizePluginId(channelId);
|
||||
if (normalized) {
|
||||
ids.add(normalized);
|
||||
}
|
||||
}
|
||||
for (const plugin of info.registry.plugins) {
|
||||
for (const channelId of plugin.channels) {
|
||||
const normalized = normalizePluginId(channelId);
|
||||
if (normalized) {
|
||||
ids.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
info.knownChannelIds = ids;
|
||||
}
|
||||
return info.knownChannelIds;
|
||||
};
|
||||
|
||||
const ensureOverriddenPluginIds = (): Set<string> => {
|
||||
const info = ensureRegistry();
|
||||
if (!info.overriddenPluginIds) {
|
||||
@@ -1546,15 +1522,11 @@ function validateConfigObjectWithPluginsBase(
|
||||
}
|
||||
|
||||
const allow = pluginsConfig?.allow ?? [];
|
||||
const knownChannelIds = ensureKnownChannelIds();
|
||||
for (const pluginId of allow) {
|
||||
if (typeof pluginId !== "string" || !pluginId.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (!knownIds.has(pluginId)) {
|
||||
if (knownChannelIds.has(normalizePluginId(pluginId))) {
|
||||
continue;
|
||||
}
|
||||
const commandAlias = resolveManifestCommandAliasOwnerInRegistry({
|
||||
command: pluginId,
|
||||
registry,
|
||||
|
||||
Reference in New Issue
Block a user