mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 02:12:59 +00:00
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 3b3ce73d0f.
Thanks @brokemac79.
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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<ConfigFileSnapshot, "valid" | "issues" | "warnings" | "legacyIssues">,
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ConfigActionPreflightFailure | null> {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof makeRuntime>): string[] {
|
||||
|
||||
async function withCapturedStdout(run: () => Promise<void>): Promise<string> {
|
||||
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"' }],
|
||||
|
||||
@@ -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"))}`,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ConfigFileSnapshot, "valid" | "issues" | "legacyIssues">;
|
||||
type PolicySnapshot = Pick<ConfigFileSnapshot, "valid" | "issues" | "warnings" | "legacyIssues">;
|
||||
|
||||
function snapshot(params: Partial<PolicySnapshot>): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ConfigFileSnapshot, "valid" | "issues" | "legacyIssues"> &
|
||||
Partial<Pick<ConfigFileSnapshot, "warnings">>,
|
||||
): 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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user