diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c1c625085a..7b59d3c4746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai - Plugins/OpenAI: enable reference-image edits for `gpt-image-1` by routing edit calls to `/images/edits` with multipart image uploads, and update image-generation capability/docs metadata accordingly. - Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19. - Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus. +- Plugins/startup: migrate legacy `tools.web.search.` config before strict startup validation, and record plugin failure phase/timestamp so degraded plugin startup is easier to diagnose from logs and `plugins list`. - Plugins/Google: separate OAuth CSRF state from PKCE code verifier during Gemini browser sign-in so state validation and token exchange use independent values. (#59116) Thanks @eleqtrizit. - Agents/subagents: honor `agents.defaults.subagents.allowAgents` for `sessions_spawn` and `agents_list`, so default cross-agent allowlists work without duplicating per-agent config. (#59944) Thanks @hclsys. - Agents/tools: normalize only truly empty MCP tool schemas to `{ type: "object", properties: {} }` so OpenAI accepts parameter-free tools without rewriting unrelated conditional schemas. (#60176) Thanks @Bartok9. diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 4aad99be57d..fca4fbdc15e 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -451,6 +451,12 @@ export function registerPluginsCli(program: Command) { } lines.push(""); lines.push(`${theme.muted("Status:")} ${inspect.plugin.status}`); + if (inspect.plugin.failurePhase) { + lines.push(`${theme.muted("Failure phase:")} ${inspect.plugin.failurePhase}`); + } + if (inspect.plugin.failedAt) { + lines.push(`${theme.muted("Failed at:")} ${inspect.plugin.failedAt.toISOString()}`); + } lines.push(`${theme.muted("Format:")} ${inspect.plugin.format ?? "openclaw"}`); if (inspect.plugin.bundleFormat) { lines.push(`${theme.muted("Bundle format:")} ${inspect.plugin.bundleFormat}`); @@ -815,7 +821,8 @@ export function registerPluginsCli(program: Command) { if (errors.length > 0) { lines.push(theme.error("Plugin errors:")); for (const entry of errors) { - lines.push(`- ${entry.id}: ${entry.error ?? "failed to load"} (${entry.source})`); + const phase = entry.failurePhase ? ` [${entry.failurePhase}]` : ""; + lines.push(`- ${entry.id}${phase}: ${entry.error ?? "failed to load"} (${entry.source})`); } } if (diags.length > 0) { diff --git a/src/config/legacy-web-search.test.ts b/src/config/legacy-web-search.test.ts index e48168bdc9c..abb1c3d5554 100644 --- a/src/config/legacy-web-search.test.ts +++ b/src/config/legacy-web-search.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "./config.js"; +import { migrateLegacyConfig } from "./legacy-migrate.js"; import { listLegacyWebSearchConfigPaths, migrateLegacyWebSearchConfig, } from "./legacy-web-search.js"; +import { findLegacyConfigIssues } from "./legacy.js"; describe("legacy web search config", () => { it("migrates legacy provider config through bundled web search ownership metadata", () => { @@ -87,4 +89,44 @@ describe("legacy web search config", () => { "tools.web.search.kimi.model", ]); }); + + it("participates in shared legacy detection and migration", () => { + const rawConfig = { + tools: { + web: { + search: { + provider: "brave", + brave: { + apiKey: "brave-key", + }, + }, + }, + }, + } satisfies OpenClawConfig; + + expect(findLegacyConfigIssues(rawConfig)).toEqual([ + { + path: "tools.web.search", + message: + "tools.web.search provider-owned config moved to plugins.entries..config.webSearch (auto-migrated on load).", + }, + ]); + + const migrated = migrateLegacyConfig(rawConfig); + expect(migrated.config).not.toBeNull(); + expect(migrated.config?.tools?.web?.search).toEqual({ + provider: "brave", + }); + expect(migrated.config?.plugins?.entries?.brave).toEqual({ + enabled: true, + config: { + webSearch: { + apiKey: "brave-key", + }, + }, + }); + expect(migrated.changes).toEqual([ + "Moved tools.web.search.brave → plugins.entries.brave.config.webSearch.", + ]); + }); }); diff --git a/src/config/legacy.migrations.ts b/src/config/legacy.migrations.ts index 6e6ee7eb3f8..1a72ea8c2aa 100644 --- a/src/config/legacy.migrations.ts +++ b/src/config/legacy.migrations.ts @@ -1,11 +1,13 @@ import { LEGACY_CONFIG_MIGRATIONS_AUDIO } from "./legacy.migrations.audio.js"; import { LEGACY_CONFIG_MIGRATIONS_CHANNELS } from "./legacy.migrations.channels.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME } from "./legacy.migrations.runtime.js"; +import { LEGACY_CONFIG_MIGRATIONS_WEB_SEARCH } from "./legacy.migrations.web-search.js"; const LEGACY_CONFIG_MIGRATION_SPECS = [ ...LEGACY_CONFIG_MIGRATIONS_CHANNELS, ...LEGACY_CONFIG_MIGRATIONS_AUDIO, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME, + ...LEGACY_CONFIG_MIGRATIONS_WEB_SEARCH, ]; export const LEGACY_CONFIG_MIGRATIONS = LEGACY_CONFIG_MIGRATION_SPECS.map( diff --git a/src/config/legacy.migrations.web-search.ts b/src/config/legacy.migrations.web-search.ts new file mode 100644 index 00000000000..bbddfdb5122 --- /dev/null +++ b/src/config/legacy.migrations.web-search.ts @@ -0,0 +1,46 @@ +import { + listLegacyWebSearchConfigPaths, + migrateLegacyWebSearchConfig, +} from "./legacy-web-search.js"; +import { + defineLegacyConfigMigration, + type LegacyConfigMigrationSpec, + type LegacyConfigRule, +} from "./legacy.shared.js"; + +const LEGACY_WEB_SEARCH_RULES: LegacyConfigRule[] = [ + { + path: ["tools", "web", "search"], + message: + "tools.web.search provider-owned config moved to plugins.entries..config.webSearch (auto-migrated on load).", + match: (_value, root) => listLegacyWebSearchConfigPaths(root).length > 0, + requireSourceLiteral: true, + }, +]; + +function replaceRootRecord( + target: Record, + replacement: Record, +): void { + for (const key of Object.keys(target)) { + delete target[key]; + } + Object.assign(target, replacement); +} + +export const LEGACY_CONFIG_MIGRATIONS_WEB_SEARCH: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "tools.web.search-provider-config->plugins.entries", + describe: + "Move legacy tools.web.search provider-owned config into plugins.entries..config.webSearch", + legacyRules: LEGACY_WEB_SEARCH_RULES, + apply: (raw, changes) => { + const migrated = migrateLegacyWebSearchConfig(raw); + if (migrated.changes.length === 0) { + return; + } + replaceRootRecord(raw, migrated.config); + changes.push(...migrated.changes); + }, + }), +]; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 8fe4dc329d5..dc2a761652d 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -619,6 +619,7 @@ function recordPluginError(params: { seenIds: Map; pluginId: string; origin: PluginRecord["origin"]; + phase: PluginRecord["failurePhase"]; error: unknown; logPrefix: string; diagnosticMessagePrefix: string; @@ -637,6 +638,8 @@ function recordPluginError(params: { params.logger.error(`${params.logPrefix}${displayError}`); params.record.status = "error"; params.record.error = displayError; + params.record.failedAt = new Date(); + params.record.failurePhase = params.phase; params.registry.plugins.push(params.record); params.seenIds.set(params.pluginId, params.origin); params.registry.diagnostics.push({ @@ -647,6 +650,20 @@ function recordPluginError(params: { }); } +function formatPluginFailureSummary(failedPlugins: PluginRecord[]): string { + const grouped = new Map, string[]>(); + for (const plugin of failedPlugins) { + const phase = plugin.failurePhase ?? "load"; + const ids = grouped.get(phase); + if (ids) { + ids.push(plugin.id); + continue; + } + grouped.set(phase, [plugin.id]); + } + return [...grouped.entries()].map(([phase, ids]) => `${phase}: ${ids.join(", ")}`).join("; "); +} + function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnostic[]) { diagnostics.push(...append); } @@ -1192,6 +1209,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const pushPluginLoadError = (message: string) => { record.status = "error"; record.error = message; + record.failedAt = new Date(); + record.failurePhase = "validation"; registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); registry.diagnostics.push({ @@ -1402,6 +1421,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi seenIds, pluginId, origin: candidate.origin, + phase: "load", error: err, logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `, diagnosticMessagePrefix: "failed to load plugin: ", @@ -1546,6 +1566,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi seenIds, pluginId, origin: candidate.origin, + phase: "register", error: err, logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `, diagnosticMessagePrefix: "plugin failed during register: ", @@ -1572,6 +1593,17 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi maybeThrowOnPluginLoadError(registry, options.throwOnLoadError); + if (shouldActivate && options.mode !== "validate") { + const failedPlugins = registry.plugins.filter((plugin) => plugin.failedAt != null); + if (failedPlugins.length > 0) { + logger.warn( + `[plugins] ${failedPlugins.length} plugin(s) failed to initialize (${formatPluginFailureSummary( + failedPlugins, + )}). Run 'openclaw plugins list' for details.`, + ); + } + } + if (cacheEnabled) { setCachedPluginRegistry(cacheKey, { registry, @@ -1745,6 +1777,8 @@ export async function loadOpenClawPluginCliRegistry( const pushPluginLoadError = (message: string) => { record.status = "error"; record.error = message; + record.failedAt = new Date(); + record.failurePhase = "validation"; registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); registry.diagnostics.push({ @@ -1812,6 +1846,7 @@ export async function loadOpenClawPluginCliRegistry( seenIds, pluginId, origin: candidate.origin, + phase: "load", error: err, logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `, diagnosticMessagePrefix: "failed to load plugin: ", @@ -1901,6 +1936,7 @@ export async function loadOpenClawPluginCliRegistry( seenIds, pluginId, origin: candidate.origin, + phase: "register", error: err, logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `, diagnosticMessagePrefix: "plugin failed during register: ", diff --git a/src/plugins/plugin-graceful-init-failure.test.ts b/src/plugins/plugin-graceful-init-failure.test.ts new file mode 100644 index 00000000000..ab48f5484de --- /dev/null +++ b/src/plugins/plugin-graceful-init-failure.test.ts @@ -0,0 +1,163 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; + +function mkdtempSafe(prefix: string) { + const dir = fs.mkdtempSync(prefix); + try { + fs.chmodSync(dir, 0o755); + } catch { + // Best-effort + } + return dir; +} + +const fixtureRoot = mkdtempSafe(path.join(os.tmpdir(), "openclaw-plugin-graceful-")); +let tempDirIndex = 0; + +afterAll(() => { + try { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +function makeTempDir() { + const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function writePlugin(params: { id: string; body: string; dir?: string }): { + id: string; + file: string; + dir: string; +} { + const dir = params.dir ?? makeTempDir(); + fs.mkdirSync(dir, { recursive: true }); + const filename = `${params.id}.cjs`; + const file = path.join(dir, filename); + fs.writeFileSync(file, params.body, "utf-8"); + fs.writeFileSync( + path.join(dir, "openclaw.plugin.json"), + JSON.stringify({ + id: params.id, + name: params.id, + version: "1.0.0", + main: filename, + configSchema: { type: "object" }, + }), + "utf-8", + ); + return { id: params.id, file, dir }; +} + +function readPluginId(pluginPath: string): string { + const manifestPath = path.join(path.dirname(pluginPath), "openclaw.plugin.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as { id: string }; + return manifest.id; +} + +async function loadPlugins(pluginPaths: string[], warnings?: string[]) { + const { loadOpenClawPlugins, clearPluginLoaderCache } = await import("./loader.js"); + clearPluginLoaderCache(); + const allow = pluginPaths.map((pluginPath) => readPluginId(pluginPath)); + return loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + enabled: true, + load: { paths: pluginPaths }, + allow, + }, + }, + logger: { + info: () => {}, + debug: () => {}, + error: () => {}, + warn: (message: string) => warnings?.push(message), + }, + }); +} + +describe("graceful plugin initialization failure", () => { + it("does not crash when register throws", async () => { + const plugin = writePlugin({ + id: "throws-on-register", + body: `module.exports = { id: "throws-on-register", register() { throw new Error("config schema mismatch"); } };`, + }); + + await expect(loadPlugins([plugin.file])).resolves.toBeDefined(); + }); + + it("keeps loading other plugins after one register failure", async () => { + const failing = writePlugin({ + id: "plugin-fail", + body: `module.exports = { id: "plugin-fail", register() { throw new Error("boom"); } };`, + }); + const working = writePlugin({ + id: "plugin-ok", + body: `module.exports = { id: "plugin-ok", register() {} };`, + }); + + const registry = await loadPlugins([failing.file, working.file]); + + expect(registry.plugins.find((plugin) => plugin.id === "plugin-ok")?.status).toBe("loaded"); + }); + + it("records failed register metadata", async () => { + const plugin = writePlugin({ + id: "register-error", + body: `module.exports = { id: "register-error", register() { throw new Error("brutal config fail"); } };`, + }); + + const before = new Date(); + const registry = await loadPlugins([plugin.file]); + const after = new Date(); + + const failed = registry.plugins.find((entry) => entry.id === "register-error"); + expect(failed).toBeDefined(); + expect(failed?.status).toBe("error"); + expect(failed?.failurePhase).toBe("register"); + expect(failed?.error).toContain("brutal config fail"); + expect(failed?.failedAt).toBeInstanceOf(Date); + expect(failed?.failedAt?.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(failed?.failedAt?.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it("records validation failures before register", async () => { + const plugin = writePlugin({ + id: "missing-register", + body: `module.exports = { id: "missing-register" };`, + }); + + const registry = await loadPlugins([plugin.file]); + const failed = registry.plugins.find((entry) => entry.id === "missing-register"); + + expect(failed?.status).toBe("error"); + expect(failed?.failurePhase).toBe("validation"); + expect(failed?.error).toBe("plugin export missing register/activate"); + }); + + it("logs a startup summary grouped by failure phase", async () => { + const registerFailure = writePlugin({ + id: "warn-register", + body: `module.exports = { id: "warn-register", register() { throw new Error("bad config"); } };`, + }); + const validationFailure = writePlugin({ + id: "warn-validation", + body: `module.exports = { id: "warn-validation" };`, + }); + + const warnings: string[] = []; + await loadPlugins([registerFailure.file, validationFailure.file], warnings); + + const summary = warnings.find((warning) => warning.includes("failed to initialize")); + expect(summary).toBeDefined(); + expect(summary).toContain("register: warn-register"); + expect(summary).toContain("validation: warn-validation"); + expect(summary).toContain("openclaw plugins list"); + }); +}); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index b0d783a55cf..fcebf4a544c 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -205,6 +205,8 @@ export type PluginRecord = { activationReason?: string; status: "loaded" | "disabled" | "error"; error?: string; + failedAt?: Date; + failurePhase?: "validation" | "load" | "register"; toolNames: string[]; hookNames: string[]; channelIds: string[];