diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 16861e00a05..6beec11b9fc 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -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 = { gateway: { bind: "lan", @@ -88,9 +94,17 @@ let cliLoadedConfig: Record = { }; 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: { diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 30ebda3909a..8dd94f3e4b8 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -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 { 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 { 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, + opts: { deep?: boolean } = {}, ): Promise { const mergedDaemonEnv = { ...(process.env as Record), @@ -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, diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index a38f5c17e4e..46756e99185 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -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"), + ); + }); }); diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 4f6f164db63..61c0e515e04 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -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(