fix: show deep status config warnings

This commit is contained in:
Peter Steinberger
2026-05-10 16:12:07 +01:00
parent 5e73b2cb2c
commit fea1c8e71d
4 changed files with 135 additions and 4 deletions

View File

@@ -72,8 +72,14 @@ const resolveStateDir = vi.fn(
const resolveConfigPath = vi.fn((env: NodeJS.ProcessEnv, stateDir: string) => {
return env.OPENCLAW_CONFIG_PATH ?? `${stateDir}/openclaw.json`;
});
const createConfigIOCalls = vi.fn((configPath: string, pluginValidation?: "full" | "skip") => ({
configPath,
pluginValidation,
}));
const readConfigFileSnapshotCalls = vi.fn((configPath: string) => configPath);
const loadConfigCalls = vi.fn((configPath: string) => configPath);
let daemonConfigWarnings: Array<{ path: string; message: string }> = [];
let cliConfigWarnings: Array<{ path: string; message: string }> = [];
let daemonLoadedConfig: Record<string, unknown> = {
gateway: {
bind: "lan",
@@ -88,9 +94,17 @@ let cliLoadedConfig: Record<string, unknown> = {
};
vi.mock("../../config/config.js", () => ({
createConfigIO: ({ configPath }: { configPath: string }) => {
createConfigIO: ({
configPath,
pluginValidation,
}: {
configPath: string;
pluginValidation?: "full" | "skip";
}) => {
const isDaemon = configPath.includes("/openclaw-daemon/");
const runtimeConfig = isDaemon ? daemonLoadedConfig : cliLoadedConfig;
const warnings = isDaemon ? daemonConfigWarnings : cliConfigWarnings;
createConfigIOCalls(configPath, pluginValidation);
return {
readConfigFileSnapshot: async () => {
readConfigFileSnapshotCalls(configPath);
@@ -99,6 +113,7 @@ vi.mock("../../config/config.js", () => ({
exists: true,
valid: true,
issues: [],
warnings: pluginValidation === "full" ? warnings : [],
runtimeConfig,
config: runtimeConfig,
};
@@ -186,11 +201,14 @@ describe("gatherDaemonStatus", () => {
delete process.env.DAEMON_GATEWAY_TOKEN;
delete process.env.DAEMON_GATEWAY_PASSWORD;
callGatewayStatusProbe.mockClear();
createConfigIOCalls.mockClear();
loadGatewayTlsRuntime.mockClear();
inspectGatewayRestart.mockClear();
readGatewayRestartHandoffSync.mockClear();
readConfigFileSnapshotCalls.mockClear();
loadConfigCalls.mockClear();
daemonConfigWarnings = [];
cliConfigWarnings = [];
daemonLoadedConfig = {
gateway: {
bind: "lan",
@@ -479,6 +497,51 @@ describe("gatherDaemonStatus", () => {
}
});
it("uses full plugin-aware config validation for deep status", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-status-config-"));
const configPath = path.join(tmp, "openclaw.json");
await fs.writeFile(
configPath,
JSON.stringify({
gateway: {
bind: "loopback",
},
}),
);
process.env.OPENCLAW_STATE_DIR = tmp;
process.env.OPENCLAW_CONFIG_PATH = configPath;
cliLoadedConfig = {
gateway: {
bind: "loopback",
},
};
cliConfigWarnings = [
{
path: "plugins.entries.test-bad-plugin",
message:
"plugin test-bad-plugin: channel plugin manifest declares test-bad-plugin without channelConfigs metadata",
},
];
serviceReadCommand.mockResolvedValueOnce({
programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"],
});
try {
const status = await gatherDaemonStatus({
rpc: {},
probe: false,
deep: true,
});
expect(createConfigIOCalls).toHaveBeenCalledWith(configPath, "full");
expect(readConfigFileSnapshotCalls).toHaveBeenCalledWith(configPath);
expect(status.config?.cli.warnings).toEqual(cliConfigWarnings);
expect(status.config?.daemon).toBe(status.config?.cli);
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
});
it("resolves daemon gateway auth password SecretRef values before probing", async () => {
daemonLoadedConfig = {
gateway: {

View File

@@ -44,6 +44,7 @@ type ConfigSummary = {
exists: boolean;
valid: boolean;
issues?: Array<{ path: string; message: string }>;
warnings?: ConfigFileSnapshot["warnings"];
controlUi?: GatewayControlUiConfig;
};
@@ -198,11 +199,16 @@ async function readFastStatusConfig(configPath: string): Promise<StatusConfigRea
async function readFullStatusConfig(params: {
env: NodeJS.ProcessEnv;
configPath: string;
pluginValidation?: "full" | "skip";
}): Promise<StatusConfigRead> {
const io = createConfigIO({
env: params.env,
configPath: params.configPath,
pluginValidation: "skip",
pluginValidation: params.pluginValidation ?? "skip",
logger: {
error: () => {},
warn: () => {},
},
});
const snapshot = await io.readConfigFileSnapshot().catch(() => null);
const cfg = resolveSnapshotRuntimeConfig(snapshot) ?? io.loadConfig();
@@ -212,6 +218,7 @@ async function readFullStatusConfig(params: {
exists: snapshot?.exists ?? false,
valid: snapshot?.valid ?? true,
...(snapshot?.issues?.length ? { issues: snapshot.issues } : {}),
...(snapshot?.warnings?.length ? { warnings: snapshot.warnings } : {}),
controlUi: cfg.gateway?.controlUi,
},
cfg,
@@ -222,12 +229,14 @@ async function readFullStatusConfig(params: {
async function readStatusConfig(params: {
env: NodeJS.ProcessEnv;
configPath: string;
deep?: boolean;
}): Promise<StatusConfigRead> {
return (
(await readFastStatusConfig(params.configPath)) ??
(params.deep ? null : await readFastStatusConfig(params.configPath)) ??
(await readFullStatusConfig({
env: params.env,
configPath: params.configPath,
pluginValidation: params.deep ? "full" : "skip",
}))
);
}
@@ -323,6 +332,7 @@ function resolveCliStatusSummary(argv: string[] = process.argv): CliStatusSummar
async function loadDaemonConfigContext(
serviceEnv?: Record<string, string>,
opts: { deep?: boolean } = {},
): Promise<DaemonConfigContext> {
const mergedDaemonEnv = {
...(process.env as Record<string, string | undefined>),
@@ -338,6 +348,7 @@ async function loadDaemonConfigContext(
const cliConfigRead = await readStatusConfig({
env: process.env,
configPath: cliConfigPath,
deep: opts.deep,
});
const sharesDaemonConfigContext =
sameConfigPath && (cliConfigRead.mode === "fast" || !serviceEnv);
@@ -346,6 +357,7 @@ async function loadDaemonConfigContext(
: await readStatusConfig({
env: mergedDaemonEnv as NodeJS.ProcessEnv,
configPath: daemonConfigPath,
deep: opts.deep,
});
return {
@@ -477,7 +489,7 @@ export async function gatherDaemonStatus(
cliConfigSummary,
daemonConfigSummary,
configMismatch,
} = await loadDaemonConfigContext(command?.environment);
} = await loadDaemonConfigContext(command?.environment, { deep: opts.deep });
const { gateway, daemonPort, cliPort, probeUrlOverride } = await resolveGatewayStatusSummary({
cliCfg,
daemonCfg,

View File

@@ -290,4 +290,40 @@ describe("printDaemonStatus", () => {
tlsEnabled: true,
});
});
it("prints deep config warnings", () => {
printDaemonStatus(
{
service: {
label: "LaunchAgent",
loaded: true,
loadedText: "loaded",
notLoadedText: "not loaded",
runtime: { status: "running", pid: 8000 },
},
config: {
cli: {
path: "/tmp/openclaw-cli/openclaw.json",
exists: true,
valid: true,
warnings: [
{
path: "plugins.entries.test-bad-plugin",
message:
"plugin test-bad-plugin: channel plugin manifest declares test-bad-plugin without channelConfigs metadata",
},
],
},
mismatch: false,
},
extraServices: [],
},
{ json: false },
);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Config warnings:"));
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("without channelConfigs metadata"),
);
});
});

View File

@@ -132,6 +132,14 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
);
}
}
if (status.config.cli.warnings?.length) {
defaultRuntime.error(warnText("Config warnings:"));
for (const warning of status.config.cli.warnings.slice(0, 5)) {
defaultRuntime.error(
warnText(formatConfigIssueLine(warning, "-", { normalizeRoot: true })),
);
}
}
if (status.config.daemon) {
const daemonCfg = `${shortenHomePath(status.config.daemon.path)}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`;
defaultRuntime.log(`${label("Config (service):")} ${infoText(daemonCfg)}`);
@@ -142,6 +150,18 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
);
}
}
if (status.config.daemon !== status.config.cli && status.config.daemon.warnings?.length) {
const warningsLabel =
status.config.daemon.path === status.config.cli.path
? "Config warnings:"
: "Service config warnings:";
defaultRuntime.error(warnText(warningsLabel));
for (const warning of status.config.daemon.warnings.slice(0, 5)) {
defaultRuntime.error(
warnText(formatConfigIssueLine(warning, "-", { normalizeRoot: true })),
);
}
}
}
if (status.config.mismatch) {
defaultRuntime.error(