From 56633e4f3cc51659a0d289d2cebb76aa18760ced Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Tue, 26 May 2026 01:13:20 +0100 Subject: [PATCH] fix(cli): route plugin packaging recovery hints Route invalid-config recovery output for source-only installed plugin packages to plugin packaging guidance instead of openclaw doctor --fix. Validated with focused config/CLI/gateway/plugin tests, autoreview, Crabbox/Testbox E2E tbx_01ksgr80tnvvc13kv6t126yv78, and green PR CI on 3b3ce73d0ff1a25da2c5703d2373ed20aace04ef. Thanks @brokemac79. --- src/cli/config-cli.test.ts | 33 +++- src/cli/config-cli.ts | 23 ++- src/cli/config-recovery-hints.ts | 7 + .../lifecycle-core.config-guard.test.ts | 64 +++++++ src/cli/daemon-cli/lifecycle-core.ts | 22 ++- src/cli/program/config-guard.test.ts | 54 ++++-- src/cli/program/config-guard.ts | 26 ++- src/commands/config-validation.test.ts | 50 +++++ src/commands/config-validation.ts | 8 +- src/config/recovery-policy.test.ts | 68 ++++++- src/config/recovery-policy.ts | 69 +++++++ .../server-startup-config.recovery.test.ts | 172 +++++++++++++----- src/gateway/server-startup-config.ts | 15 +- src/plugins/discovery.test.ts | 6 + 14 files changed, 539 insertions(+), 78 deletions(-) diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 67cd0c574a1..2f67733e357 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -273,6 +273,7 @@ function setExternalFeishuSchema() { function makeInvalidSnapshot(params: { issues: ConfigFileSnapshot["issues"]; + warnings?: ConfigFileSnapshot["warnings"]; path?: string; }): ConfigFileSnapshot { return { @@ -286,7 +287,7 @@ function makeInvalidSnapshot(params: { runtimeConfig: {}, config: {}, issues: params.issues, - warnings: [], + warnings: params.warnings ?? [], legacyIssues: [], }; } @@ -1066,6 +1067,36 @@ describe("config cli", () => { expect(mockLog).not.toHaveBeenCalled(); }); + it("replaces doctor advice for plugin packaging compiled-output failures", async () => { + setSnapshotOnce( + makeInvalidSnapshot({ + issues: [ + { + path: "plugins.slots.memory", + message: "plugin not found: source-only-pack", + }, + ], + warnings: [ + { + path: "plugins", + message: + "plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js. This is a plugin packaging issue, not a local config problem.", + }, + ], + }), + ); + + await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1"); + + expectErrorIncludes("plugin not found: source-only-pack"); + expectErrorIncludes("This is a plugin packaging issue, not a local config problem."); + expectErrorIncludes("disable/uninstall the plugin"); + expect(mockError.mock.calls.map((call) => String(call[0])).join("\n")).not.toContain( + "openclaw doctor --fix", + ); + expect(mockLog).not.toHaveBeenCalled(); + }); + it("returns machine-readable JSON with --json for invalid config", async () => { setSnapshotOnce( makeInvalidSnapshot({ diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 7d34d10a7f2..801da4b7862 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import type { Command } from "commander"; import JSON5 from "json5"; import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js"; -import { readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; +import { + type ConfigFileSnapshot, + readConfigFileSnapshot, + replaceConfigFile, +} from "../config/config.js"; import { AUTO_MANAGED_CONFIG_META_PATHS } from "../config/io.meta.js"; import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js"; import { @@ -11,6 +15,7 @@ import { } from "../config/model-input.js"; import { CONFIG_PATH } from "../config/paths.js"; import { isBlockedObjectKey } from "../config/prototype-keys.js"; +import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../config/recovery-policy.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; import { readBestEffortRuntimeConfigSchema } from "../config/runtime-schema.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -50,6 +55,7 @@ import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import { formatPluginPackagingRuntimeOutputRecoveryHint } from "./config-recovery-hints.js"; import type { ConfigSetDryRunError, ConfigSetDryRunInputMode, @@ -412,6 +418,15 @@ function formatDoctorHint(message: string): string { return `Run \`${formatCliCommand("openclaw doctor --fix")}\` ${message}`; } +function formatInvalidConfigRepairHint( + snapshot: Pick, + doctorMessage: string, +): string { + return isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot) + ? formatPluginPackagingRuntimeOutputRecoveryHint() + : formatDoctorHint(doctorMessage); +} + function formatUnsupportedSecretRefPolicyFailureMessage(issues: string[]): string { const lines = [ "Config policy validation failed: unsupported SecretRef usage was detected.", @@ -868,7 +883,7 @@ async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) { for (const line of formatConfigIssueLines(snapshot.issues, "-", { normalizeRoot: true })) { runtime.error(line); } - runtime.error(formatDoctorHint("to repair, then retry.")); + runtime.error(formatInvalidConfigRepairHint(snapshot, "to repair, then retry.")); runtime.exit(1); return snapshot; } @@ -2343,7 +2358,9 @@ export async function runConfigValidate(opts: { json?: boolean; runtime?: Runtim runtime.error(` ${line}`); } runtime.error(""); - runtime.error(formatDoctorHint("to repair, or fix the keys above manually.")); + runtime.error( + formatInvalidConfigRepairHint(snapshot, "to repair, or fix the keys above manually."), + ); runtime.error(`Inspect with ${formatCliCommand("openclaw config validate")}.`); } runtime.exit(1); diff --git a/src/cli/config-recovery-hints.ts b/src/cli/config-recovery-hints.ts index ae67c944f59..913f745dd3f 100644 --- a/src/cli/config-recovery-hints.ts +++ b/src/cli/config-recovery-hints.ts @@ -6,3 +6,10 @@ export function formatInvalidConfigRecoveryHint(): string { "If startup is still blocked, inspect the adjacent .bak backup before restoring it manually.", ].join("\n"); } + +export function formatPluginPackagingRuntimeOutputRecoveryHint(): string { + return [ + "This is a plugin packaging issue, not a local config problem.", + "Update or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then.", + ].join("\n"); +} diff --git a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts index b9f7035f0f3..5361b7d6f1a 100644 --- a/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.config-guard.test.ts @@ -19,6 +19,14 @@ const invalidConfigRecoveryHint = [ 'Run "openclaw doctor --fix" to repair, then retry.', "If startup is still blocked, inspect the adjacent .bak backup before restoring it manually.", ].join("\n"); +const pluginPackagingRecoveryHints = [ + "This is a plugin packaging issue, not a local config problem.", + "Update or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then.", +]; +const pluginPackagingHintItems = pluginPackagingRecoveryHints.map((text) => ({ + kind: "generic", + text, +})); function expectLatestRuntimeJson(payload: unknown) { const calls = defaultRuntime.writeJson.mock.calls; @@ -47,6 +55,8 @@ function setConfigSnapshot(params: { exists: boolean; valid: boolean; issues?: Array<{ path: string; message: string }>; + warnings?: Array<{ path: string; message: string }>; + legacyIssues?: Array<{ path: string; message: string }>; lastTouchedVersion?: string; }) { const config = params.lastTouchedVersion @@ -58,6 +68,28 @@ function setConfigSnapshot(params: { config, sourceConfig: config, issues: params.issues ?? [], + warnings: params.warnings ?? [], + legacyIssues: params.legacyIssues ?? [], + }); +} + +function setPluginPackagingInvalidSnapshot() { + setConfigSnapshot({ + exists: true, + valid: false, + issues: [ + { + path: "plugins.slots.memory", + message: "plugin not found: source-only-pack", + }, + ], + warnings: [ + { + path: "plugins", + message: + "plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js. This is a plugin packaging issue, not a local config problem.", + }, + ], }); } @@ -107,6 +139,22 @@ describe("runServiceRestart config pre-flight (#35862)", () => { }); }); + it("points restart at plugin packaging recovery for packaging-only invalid config", async () => { + setPluginPackagingInvalidSnapshot(); + + await expect(runServiceRestart(createServiceRunArgs())).rejects.toThrow("__exit__:1"); + + expect(service.restart).not.toHaveBeenCalled(); + expectLatestRuntimeJson({ + action: "restart", + ok: false, + error: "Gateway restart blocked: plugins.slots.memory: plugin not found: source-only-pack", + hints: pluginPackagingRecoveryHints, + hintItems: pluginPackagingHintItems, + warnings: undefined, + }); + }); + it("blocks restart from an older binary when config was written by a newer one", async () => { setConfigSnapshot({ exists: true, valid: true, lastTouchedVersion: "9999.1.1" }); @@ -185,6 +233,22 @@ describe("runServiceStart config pre-flight (#35862)", () => { }); }); + it("points start at plugin packaging recovery for packaging-only invalid config", async () => { + setPluginPackagingInvalidSnapshot(); + + await expect(runServiceStart(createServiceRunArgs())).rejects.toThrow("__exit__:1"); + + expect(service.restart).not.toHaveBeenCalled(); + expectLatestRuntimeJson({ + action: "start", + ok: false, + error: "Gateway start blocked: plugins.slots.memory: plugin not found: source-only-pack", + hints: pluginPackagingRecoveryHints, + hintItems: pluginPackagingHintItems, + warnings: undefined, + }); + }); + it("aborts before not-loaded start recovery when config is invalid", async () => { const onNotLoaded = vi.fn(async () => ({ result: "started" as const, diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 6ea283782a3..e626f10f2ba 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -3,6 +3,7 @@ import { readBestEffortConfig, readConfigFileSnapshot } from "../../config/confi import { resolveFutureConfigActionBlock } from "../../config/future-version-guard.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; import { resolveIsNixMode } from "../../config/paths.js"; +import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../../config/recovery-policy.js"; import { checkTokenDrift } from "../../daemon/service-audit.js"; import type { GatewayServiceRestartResult } from "../../daemon/service-types.js"; import type { GatewayServiceStartRepairIssue, GatewayServiceState } from "../../daemon/service.js"; @@ -19,7 +20,10 @@ import { import { isWSL } from "../../infra/wsl.js"; import { defaultRuntime } from "../../runtime.js"; import { formatCliCommand } from "../command-format.js"; -import { formatInvalidConfigRecoveryHint } from "../config-recovery-hints.js"; +import { + formatInvalidConfigRecoveryHint, + formatPluginPackagingRuntimeOutputRecoveryHint, +} from "../config-recovery-hints.js"; import { resolveGatewayTokenForDriftCheck } from "./gateway-token-drift.js"; import { buildDaemonServiceSnapshot, @@ -139,6 +143,10 @@ type ConfigActionPreflightFailure = { hints?: string[]; }; +function formatPluginPackagingRuntimeOutputRecoveryHints(): string[] { + return formatPluginPackagingRuntimeOutputRecoveryHint().split("\n"); +} + async function getConfigActionPreflightFailure( action: string, ): Promise { @@ -146,11 +154,15 @@ async function getConfigActionPreflightFailure( try { snapshot = await readConfigFileSnapshot(); if (snapshot.exists && !snapshot.valid) { + const message = + snapshot.issues.length > 0 + ? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n") + : "Unknown validation issue."; return { - message: - snapshot.issues.length > 0 - ? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n") - : "Unknown validation issue.", + message, + ...(isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot) + ? { hints: formatPluginPackagingRuntimeOutputRecoveryHints() } + : {}), }; } } catch { diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index d3e209d85a7..931a31be083 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -3,6 +3,11 @@ import { note } from "../../terminal/note.js"; import { formatCliCommand } from "../command-format.js"; import { ensureConfigReady, testApi } from "./config-guard.js"; +const pluginPackagingRecoveryHint = [ + "This is a plugin packaging issue, not a local config problem.", + "Update or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then.", +].join("\n"); + const loadAndMaybeMigrateDoctorConfigMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const setRuntimeConfigSnapshotMock = vi.hoisted(() => vi.fn()); @@ -23,6 +28,7 @@ function makeSnapshot() { exists: false, valid: true, issues: [] as ConfigIssue[], + warnings: [] as ConfigIssue[], legacyIssues: [] as ConfigIssue[], path: "/tmp/openclaw.json", }; @@ -42,20 +48,16 @@ function plainErrorCalls(runtime: ReturnType): string[] { async function withCapturedStdout(run: () => Promise): Promise { const writes: string[] = []; - const writeSpy = vi - .spyOn(process.stdout, "write") - .mockImplementation( - (( - chunk: unknown, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void, - ) => { - writes.push(String(chunk)); - const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; - done?.(); - return true; - }) as typeof process.stdout.write, - ); + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation((( + chunk: unknown, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ) => { + writes.push(String(chunk)); + const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + done?.(); + return true; + }) as typeof process.stdout.write); try { await run(); return writes.join(""); @@ -185,6 +187,30 @@ describe("ensureConfigReady", () => { expect(runtime.exit).toHaveBeenCalledWith(1); }); + it("replaces doctor fix advice for plugin packaging-only invalid config", async () => { + setInvalidSnapshot({ + issues: [ + { + path: "plugins.slots.memory", + message: "plugin not found: source-only-pack", + }, + ], + warnings: [ + { + path: "plugins", + message: + "plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js. This is a plugin packaging issue, not a local config problem.", + }, + ], + }); + const runtime = await runEnsureConfigReady(["message"]); + const calls = plainErrorCalls(runtime); + + expect(calls).toContain(`Fix: ${pluginPackagingRecoveryHint}`); + expect(calls).not.toContain(`Fix: ${formatCliCommand("openclaw doctor --fix")}`); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + it("does not exit for invalid config on allowlisted commands", async () => { setInvalidSnapshot({ issues: [{ path: "agents.defaults", message: 'Unrecognized key: "agentRuntime"' }], diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index 23d7c12068d..abe7466079e 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -96,12 +96,19 @@ export async function ensureConfigReady(params: { return; } - const [{ colorize, isRich, theme }, { shortenHomePath }, { formatCliCommand }] = - await Promise.all([ - import("../../terminal/theme.js"), - import("../../utils.js"), - import("../command-format.js"), - ]); + const [ + { colorize, isRich, theme }, + { shortenHomePath }, + { formatCliCommand }, + { isPluginPackagingRuntimeOutputInvalidConfigSnapshot }, + { formatPluginPackagingRuntimeOutputRecoveryHint }, + ] = await Promise.all([ + import("../../terminal/theme.js"), + import("../../utils.js"), + import("../command-format.js"), + import("../../config/recovery-policy.js"), + import("../config-recovery-hints.js"), + ]); const rich = isRich(); const muted = (value: string) => colorize(rich, theme.muted, value); const error = (value: string) => colorize(rich, theme.error, value); @@ -119,9 +126,10 @@ export async function ensureConfigReady(params: { params.runtime.error(legacyIssues.map((issue) => ` ${error(issue)}`).join("\n")); } params.runtime.error(""); - params.runtime.error( - `${muted("Fix:")} ${commandText(formatCliCommand("openclaw doctor --fix"))}`, - ); + const fixHint = isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot) + ? formatPluginPackagingRuntimeOutputRecoveryHint() + : commandText(formatCliCommand("openclaw doctor --fix")); + params.runtime.error(`${muted("Fix:")} ${fixHint}`); params.runtime.error( `${muted("Inspect:")} ${commandText(formatCliCommand("openclaw config validate"))}`, ); diff --git a/src/commands/config-validation.test.ts b/src/commands/config-validation.test.ts index 0bac34dc560..e8e7cc98c41 100644 --- a/src/commands/config-validation.test.ts +++ b/src/commands/config-validation.test.ts @@ -108,4 +108,54 @@ describe("requireValidConfigSnapshot", () => { expect(runtime.exit).toHaveBeenCalledWith(1); expect(runtime.log).not.toHaveBeenCalled(); }); + + it("replaces doctor fix advice for plugin packaging compiled-output failures", async () => { + readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: false, + config: {}, + issues: [ + { + path: "plugins.slots.memory", + message: "plugin not found: source-only-pack", + }, + ], + warnings: [ + { + path: "plugins", + message: + "plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js. This is a plugin packaging issue, not a local config problem.", + }, + ], + legacyIssues: [], + }); + const runtime = createRuntime(); + + const config = await requireValidConfigSnapshot(runtime); + + expect(config).toBeNull(); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("plugin not found")); + expect(runtime.error).toHaveBeenCalledWith( + "Fix: This is a plugin packaging issue, not a local config problem.\nUpdate or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then.", + ); + expect(runtime.error).not.toHaveBeenCalledWith("Fix: openclaw doctor --fix"); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); + + it("keeps doctor fix advice for normal invalid config failures", async () => { + readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: false, + config: {}, + issues: [{ path: "gateway.mode", message: "Expected 'local' or 'remote'" }], + legacyIssues: [], + }); + const runtime = createRuntime(); + + const config = await requireValidConfigSnapshot(runtime); + + expect(config).toBeNull(); + expect(runtime.error).toHaveBeenCalledWith("Fix: openclaw doctor --fix"); + expect(runtime.exit).toHaveBeenCalledWith(1); + }); }); diff --git a/src/commands/config-validation.ts b/src/commands/config-validation.ts index b0e453d05ab..79f7c66e3ba 100644 --- a/src/commands/config-validation.ts +++ b/src/commands/config-validation.ts @@ -1,10 +1,12 @@ import { formatCliCommand } from "../cli/command-format.js"; +import { formatPluginPackagingRuntimeOutputRecoveryHint } from "../cli/config-recovery-hints.js"; import { type ConfigFileSnapshot, type OpenClawConfig, readConfigFileSnapshot, } from "../config/config.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; +import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../config/recovery-policy.js"; import { buildPluginCompatibilitySnapshotNotices, formatPluginCompatibilityNotice, @@ -22,7 +24,11 @@ export async function requireValidConfigFileSnapshot( ? formatConfigIssueLines(snapshot.issues, "-").join("\n") : "Unknown validation issue."; runtime.error(`OpenClaw config is invalid: ${snapshot.path}\n${issues}`); - runtime.error(`Fix: ${formatCliCommand("openclaw doctor --fix")}`); + runtime.error( + isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot) + ? `Fix: ${formatPluginPackagingRuntimeOutputRecoveryHint()}` + : `Fix: ${formatCliCommand("openclaw doctor --fix")}`, + ); runtime.error(`Inspect: ${formatCliCommand("openclaw config validate")}`); runtime.exit(1); return null; diff --git a/src/config/recovery-policy.test.ts b/src/config/recovery-policy.test.ts index 93afe8bbc81..2dfd7a799e5 100644 --- a/src/config/recovery-policy.test.ts +++ b/src/config/recovery-policy.test.ts @@ -1,16 +1,18 @@ import { describe, expect, it } from "vitest"; import { + isPluginPackagingRuntimeOutputInvalidConfigSnapshot, isPluginLocalInvalidConfigSnapshot, shouldAttemptLastKnownGoodRecovery, } from "./recovery-policy.js"; import type { ConfigFileSnapshot } from "./types.openclaw.js"; -type PolicySnapshot = Pick; +type PolicySnapshot = Pick; function snapshot(params: Partial): PolicySnapshot { return { valid: false, issues: [], + warnings: [], legacyIssues: [], ...params, }; @@ -84,4 +86,68 @@ describe("config recovery policy", () => { expect(isPluginLocalInvalidConfigSnapshot(current)).toBe(false); expect(shouldAttemptLastKnownGoodRecovery(current)).toBe(true); }); + + it("classifies plugin packaging compiled-output failures with plugin-not-found fallout", () => { + const current = snapshot({ + issues: [ + { + path: "plugins.slots.memory", + message: "plugin not found: source-only-pack", + }, + ], + warnings: [ + { + path: "plugins", + message: + "plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js. This is a plugin packaging issue, not a local config problem.", + }, + ], + }); + + expect(isPluginPackagingRuntimeOutputInvalidConfigSnapshot(current)).toBe(true); + }); + + it("does not classify mixed core invalidity as a plugin packaging-only failure", () => { + const current = snapshot({ + issues: [ + { + path: "plugins.slots.memory", + message: "plugin not found: source-only-pack", + }, + { + path: "gateway.mode", + message: "Expected 'local' or 'remote'", + }, + ], + warnings: [ + { + path: "plugins", + message: + "plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js.", + }, + ], + }); + + expect(isPluginPackagingRuntimeOutputInvalidConfigSnapshot(current)).toBe(false); + }); + + it("does not classify unrelated missing plugins as packaging fallout", () => { + const current = snapshot({ + issues: [ + { + path: "plugins.slots.memory", + message: "plugin not found: missing-memory", + }, + ], + warnings: [ + { + path: "plugins", + message: + "plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js.", + }, + ], + }); + + expect(isPluginPackagingRuntimeOutputInvalidConfigSnapshot(current)).toBe(false); + }); }); diff --git a/src/config/recovery-policy.ts b/src/config/recovery-policy.ts index 4f702f25d93..97b6baee260 100644 --- a/src/config/recovery-policy.ts +++ b/src/config/recovery-policy.ts @@ -2,6 +2,13 @@ import type { ConfigFileSnapshot, ConfigValidationIssue } from "./types.openclaw const PLUGIN_ENTRY_PATH_PREFIX = "plugins.entries."; const PLUGIN_POLICY_PATHS = new Set(["plugins.allow", "plugins.deny"]); +const COMPILED_RUNTIME_OUTPUT_DIAGNOSTIC = "compiled runtime output"; +const PLUGIN_DIAGNOSTIC_PREFIX_PATTERN = /^plugin\s+([^:\s]+):\s/u; +const PLUGIN_NOT_FOUND_PATTERN = /^plugin not found:\s*([^\s(]+)/u; + +function isPluginsPath(path: string): boolean { + return path === "plugins" || path.startsWith("plugins."); +} function isPluginEntryIssue(issue: ConfigValidationIssue): boolean { const path = issue.path.trim(); @@ -18,6 +25,68 @@ function isPluginPolicyIssue(issue: ConfigValidationIssue): boolean { ); } +export function isPluginPackagingRuntimeOutputIssue(issue: ConfigValidationIssue): boolean { + const path = issue.path.trim(); + const message = issue.message.trim().toLowerCase(); + return isPluginsPath(path) && message.includes(COMPILED_RUNTIME_OUTPUT_DIAGNOSTIC); +} + +function isPluginPackagingFalloutIssue(issue: ConfigValidationIssue): boolean { + const path = issue.path.trim(); + const message = issue.message.trim(); + return isPluginsPath(path) && message.startsWith("plugin not found:"); +} + +function normalizePluginIssueId(value: string | undefined): string | null { + const normalized = value?.trim().toLowerCase(); + return normalized ? normalized : null; +} + +function extractPluginPackagingRuntimeOutputPluginId(issue: ConfigValidationIssue): string | null { + if (!isPluginPackagingRuntimeOutputIssue(issue)) { + return null; + } + return normalizePluginIssueId(PLUGIN_DIAGNOSTIC_PREFIX_PATTERN.exec(issue.message.trim())?.[1]); +} + +function extractPluginNotFoundIssuePluginId(issue: ConfigValidationIssue): string | null { + if (!isPluginPackagingFalloutIssue(issue)) { + return null; + } + return normalizePluginIssueId(PLUGIN_NOT_FOUND_PATTERN.exec(issue.message.trim())?.[1]); +} + +/** + * Returns true when an invalid config snapshot is blocked by an installed plugin + * package that shipped TypeScript source without compiled JavaScript output. + */ +export function isPluginPackagingRuntimeOutputInvalidConfigSnapshot( + snapshot: Pick & + Partial>, +): boolean { + if (snapshot.valid || (snapshot.legacyIssues?.length ?? 0) > 0 || snapshot.issues.length === 0) { + return false; + } + const packagingIssues = [...snapshot.issues, ...(snapshot.warnings ?? [])].filter( + isPluginPackagingRuntimeOutputIssue, + ); + const packagingPluginIds = new Set( + packagingIssues + .map((issue) => extractPluginPackagingRuntimeOutputPluginId(issue)) + .filter((pluginId): pluginId is string => pluginId !== null), + ); + return ( + packagingIssues.length > 0 && + snapshot.issues.every((issue) => { + if (isPluginPackagingRuntimeOutputIssue(issue)) { + return true; + } + const pluginId = extractPluginNotFoundIssuePluginId(issue); + return pluginId !== null && packagingPluginIds.has(pluginId); + }) + ); +} + /** * Returns true when an invalid config snapshot is scoped entirely to stale plugin refs. */ diff --git a/src/gateway/server-startup-config.recovery.test.ts b/src/gateway/server-startup-config.recovery.test.ts index 668c6b9bfe4..2f39f1be064 100644 --- a/src/gateway/server-startup-config.recovery.test.ts +++ b/src/gateway/server-startup-config.recovery.test.ts @@ -14,50 +14,48 @@ const configMocks = vi.hoisted(() => ({ isNixMode: { value: false }, })); const pluginManifestRegistry = vi.hoisted(() => ({ plugins: [], diagnostics: [] })); -const pluginMetadataSnapshot = vi.hoisted( - (): PluginMetadataSnapshot => { - const emptyOwners = { - channels: new Map(), - channelConfigs: new Map(), - providers: new Map(), - modelCatalogProviders: new Map(), - cliBackends: new Map(), - setupProviders: new Map(), - commandAliases: new Map(), - contracts: new Map(), - }; - const zeroMetrics = { - registrySnapshotMs: 0, - manifestRegistryMs: 0, - ownerMapsMs: 0, - totalMs: 0, - indexPluginCount: 0, - manifestPluginCount: 0, - }; - return { +const pluginMetadataSnapshot = vi.hoisted((): PluginMetadataSnapshot => { + const emptyOwners = { + channels: new Map(), + channelConfigs: new Map(), + providers: new Map(), + modelCatalogProviders: new Map(), + cliBackends: new Map(), + setupProviders: new Map(), + commandAliases: new Map(), + contracts: new Map(), + }; + const zeroMetrics = { + registrySnapshotMs: 0, + manifestRegistryMs: 0, + ownerMapsMs: 0, + totalMs: 0, + indexPluginCount: 0, + manifestPluginCount: 0, + }; + return { + policyHash: "policy", + index: { + version: 1, + hostContractVersion: "test", + compatRegistryVersion: "test", + migrationVersion: 1, policyHash: "policy", - index: { - version: 1, - hostContractVersion: "test", - compatRegistryVersion: "test", - migrationVersion: 1, - policyHash: "policy", - generatedAtMs: 0, - installRecords: {}, - plugins: [], - diagnostics: [], - }, - registryDiagnostics: [], - manifestRegistry: pluginManifestRegistry, + generatedAtMs: 0, + installRecords: {}, plugins: [], diagnostics: [], - byPluginId: new Map(), - normalizePluginId: (pluginId) => pluginId, - owners: emptyOwners, - metrics: zeroMetrics, - }; - }, -); + }, + registryDiagnostics: [], + manifestRegistry: pluginManifestRegistry, + plugins: [], + diagnostics: [], + byPluginId: new Map(), + normalizePluginId: (pluginId) => pluginId, + owners: emptyOwners, + metrics: zeroMetrics, + }; +}); vi.mock("../config/io.js", () => ({ readConfigFileSnapshot: vi.fn(), readConfigFileSnapshotWithPluginMetadata: vi.fn(), @@ -461,6 +459,98 @@ describe("gateway startup config validation", () => { ); }); + it("does not suggest doctor repair for plugin packaging compiled-output failures", async () => { + const invalidSnapshot = buildTestConfigSnapshot({ + path: configPath, + exists: true, + raw: `${JSON.stringify({ + gateway: { mode: "local" }, + plugins: { slots: { memory: "source-only-pack" } }, + })}\n`, + parsed: { + gateway: { mode: "local" }, + plugins: { slots: { memory: "source-only-pack" } }, + }, + valid: false, + config: { + gateway: { mode: "local" }, + plugins: { slots: { memory: "source-only-pack" } }, + } as OpenClawConfig, + issues: [ + { + path: "plugins.slots.memory", + message: "plugin not found: source-only-pack", + }, + ], + warnings: [ + { + path: "plugins", + message: + "plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js. This is a plugin packaging issue, not a local config problem.", + }, + ], + legacyIssues: [], + }); + vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot); + + const start = loadGatewayStartupConfigSnapshot({ + minimalTestGateway: true, + log: { info: vi.fn(), warn: vi.fn() }, + }); + await expect(start).rejects.toThrow( + `Invalid config at ${configPath}.\nplugins.slots.memory: plugin not found: source-only-pack\nThis is a plugin packaging issue, not a local config problem.\nUpdate or reinstall the plugin after the publisher ships compiled JavaScript, or disable/uninstall the plugin until then.`, + ); + await start.catch((error: unknown) => { + expect(String(error)).not.toContain("openclaw doctor --fix"); + }); + }); + + it("keeps doctor repair guidance for mixed plugin packaging and core invalidity", async () => { + const invalidSnapshot = buildTestConfigSnapshot({ + path: configPath, + exists: true, + raw: `${JSON.stringify({ + gateway: { mode: "invalid" }, + plugins: { slots: { memory: "source-only-pack" } }, + })}\n`, + parsed: { + gateway: { mode: "invalid" }, + plugins: { slots: { memory: "source-only-pack" } }, + }, + valid: false, + config: { + gateway: { mode: "invalid" }, + plugins: { slots: { memory: "source-only-pack" } }, + } as unknown as OpenClawConfig, + issues: [ + { + path: "plugins.slots.memory", + message: "plugin not found: source-only-pack", + }, + { + path: "gateway.mode", + message: "Expected 'local' or 'remote'", + }, + ], + warnings: [ + { + path: "plugins", + message: + "plugin source-only-pack: installed plugin package requires compiled runtime output for TypeScript entry index.ts: expected ./dist/index.js.", + }, + ], + legacyIssues: [], + }); + vi.mocked(configIo.readConfigFileSnapshot).mockResolvedValueOnce(invalidSnapshot); + + await expect( + loadGatewayStartupConfigSnapshot({ + minimalTestGateway: true, + log: { info: vi.fn(), warn: vi.fn() }, + }), + ).rejects.toThrow('Run "openclaw doctor --fix" to repair, then retry.'); + }); + it("rejects legacy config entries in Nix mode", async () => { const legacySnapshot = buildTestConfigSnapshot({ path: configPath, diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index 93e521ab448..60496aa504c 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -1,5 +1,8 @@ import { isDeepStrictEqual } from "node:util"; -import { formatInvalidConfigRecoveryHint } from "../cli/config-recovery-hints.js"; +import { + formatInvalidConfigRecoveryHint, + formatPluginPackagingRuntimeOutputRecoveryHint, +} from "../cli/config-recovery-hints.js"; import { type ReadConfigFileSnapshotWithPluginMetadataResult, readConfigFileSnapshotWithPluginMetadata, @@ -7,6 +10,7 @@ import { import { formatConfigIssueLines } from "../config/issue-format.js"; import { isNixMode } from "../config/paths.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; +import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../config/recovery-policy.js"; import { applyConfigOverrides } from "../config/runtime-overrides.js"; import type { GatewayAuthConfig, GatewayTailscaleConfig } from "../config/types.gateway.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js"; @@ -387,8 +391,13 @@ export function assertValidGatewayStartupConfigSnapshot( snapshot.issues.length > 0 ? formatConfigIssueLines(snapshot.issues, "", { normalizeRoot: true }).join("\n") : "Unknown validation issue."; - const doctorHint = options.includeDoctorHint ? `\n${formatInvalidConfigRecoveryHint()}` : ""; - throw new Error(`Invalid config at ${snapshot.path}.\n${issues}${doctorHint}`); + const recoveryHint = + options.includeDoctorHint && isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot) + ? `\n${formatPluginPackagingRuntimeOutputRecoveryHint()}` + : options.includeDoctorHint + ? `\n${formatInvalidConfigRecoveryHint()}` + : ""; + throw new Error(`Invalid config at ${snapshot.path}.\n${issues}${recoveryHint}`); } export async function prepareGatewayStartupConfig(params: { diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 1342e558905..cca54ee713f 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -950,6 +950,12 @@ describe("discoverOpenClawPlugins", () => { entry.message.includes("disable/uninstall the plugin"), ), ).toBe(true); + expect( + result.diagnostics.some( + (entry) => + entry.pluginId === "source-only-pack" && entry.message.includes("openclaw doctor --fix"), + ), + ).toBe(false); expect(result.diagnostics).toHaveLength(1); });