From 2c7fb549560c01d928e34396c65d3b781d3ccb9b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 7 Mar 2026 20:48:13 -0500 Subject: [PATCH] Config: fail closed invalid config loads (#39071) * Config: fail closed invalid config loads * CLI: keep diagnostics on explicit best-effort config * Tests: cover invalid config best-effort diagnostics * Changelog: note invalid config fail-closed fix * Status: pass best-effort config through status-all gateway RPCs * CLI: pass config through gateway secret RPC * CLI: skip plugin loading from invalid config * Tests: align daemon token drift env precedence --- CHANGELOG.md | 1 + src/cli/command-secret-gateway.test.ts | 1 + src/cli/command-secret-gateway.ts | 1 + .../daemon-cli/gateway-token-drift.test.ts | 23 +++++++++++++ src/cli/daemon-cli/gateway-token-drift.ts | 6 ++-- src/cli/daemon-cli/install.test.ts | 1 + src/cli/daemon-cli/install.ts | 4 +-- src/cli/daemon-cli/lifecycle-core.test.ts | 3 +- src/cli/daemon-cli/lifecycle-core.ts | 8 +++-- src/cli/daemon-cli/lifecycle.test.ts | 1 + src/cli/daemon-cli/lifecycle.ts | 8 ++--- src/cli/gateway-cli/call.ts | 3 ++ src/cli/gateway-cli/register.ts | 13 +++++--- src/cli/program/register.subclis.test.ts | 33 ++++++++++++++++++- src/cli/program/register.subclis.ts | 23 +++++++++---- src/cli/run-main.ts | 8 +++-- src/commands/gateway-status.test.ts | 29 ++++++++-------- src/commands/gateway-status.ts | 4 +-- src/commands/health.ts | 4 +-- src/commands/status-all.ts | 10 ++++-- src/commands/status.agent-local.ts | 18 ++++++---- src/commands/status.command.ts | 4 ++- src/commands/status.scan.test.ts | 6 ++-- src/commands/status.scan.ts | 29 ++++++++-------- src/config/config.ts | 1 + src/config/io.compat.test.ts | 2 +- src/config/io.ts | 5 +++ 27 files changed, 178 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5845d706a7b..48255f36ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -332,6 +332,7 @@ Docs: https://docs.openclaw.ai - Discord/DM session-key normalization: rewrite legacy `discord:dm:*` and phantom direct-message `discord:channel:` session keys to `discord:direct:*` when the sender matches, so multi-agent Discord DMs stop falling into empty channel-shaped sessions and resume replying correctly. - Discord/native slash session fallback: treat empty configured bound-session keys as missing so `/status` and other native commands fall back to the routed slash session and routed channel session instead of blanking Discord session keys in normal channel bindings. - Agents/tool-call dispatch normalization: normalize provider-prefixed tool names before dispatch across `toolCall`, `toolUse`, and `functionCall` blocks, while preserving multi-segment tool suffixes when stripping provider wrappers so malformed-but-recoverable tool names no longer fail with `Tool not found`. (#39328) Thanks @vincentkoc. +- Config/invalid-load fail-closed: stop converting `INVALID_CONFIG` into an empty runtime config, keep valid settings available only through explicit best-effort diagnostic reads, and route read-only CLI diagnostics through that path so unknown keys no longer silently drop security-sensitive config. (#28140) Thanks @bobsahur-robot and @vincentkoc. ## 2026.3.2 diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index 7e078f45ecf..9f1f6c402e5 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -132,6 +132,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { }); expect(callGateway).toHaveBeenCalledWith( expect.objectContaining({ + config, method: "secrets.resolve", requiredMethods: ["secrets.resolve"], params: { diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index b1eb174a512..89b8c78a3e3 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -396,6 +396,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { let payload: GatewaySecretsResolveResult; try { payload = await callGateway({ + config: params.config, method: "secrets.resolve", requiredMethods: ["secrets.resolve"], params: { diff --git a/src/cli/daemon-cli/gateway-token-drift.test.ts b/src/cli/daemon-cli/gateway-token-drift.test.ts index 58f4b706ef6..ff221b24e44 100644 --- a/src/cli/daemon-cli/gateway-token-drift.test.ts +++ b/src/cli/daemon-cli/gateway-token-drift.test.ts @@ -20,4 +20,27 @@ describe("resolveGatewayTokenForDriftCheck", () => { expect(token).toBe("config-token"); }); + + it("does not fall back to caller env for unresolved config token refs", () => { + expect(() => + resolveGatewayTokenForDriftCheck({ + cfg: { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + mode: "local", + auth: { + token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + }, + }, + } as OpenClawConfig, + env: { + OPENCLAW_GATEWAY_TOKEN: "env-token", + } as NodeJS.ProcessEnv, + }), + ).toThrow(/gateway\.auth\.token/i); + }); }); diff --git a/src/cli/daemon-cli/gateway-token-drift.ts b/src/cli/daemon-cli/gateway-token-drift.ts index e4421b32ac4..e382a7a91c3 100644 --- a/src/cli/daemon-cli/gateway-token-drift.ts +++ b/src/cli/daemon-cli/gateway-token-drift.ts @@ -7,10 +7,10 @@ export function resolveGatewayTokenForDriftCheck(params: { }) { return resolveGatewayCredentialsFromConfig({ cfg: params.cfg, - env: params.env, + env: {} as NodeJS.ProcessEnv, modeOverride: "local", - // Drift checks should compare the persisted gateway token against the - // service token, not let an exported shell env mask config drift. + // Drift checks should compare the configured local token source against the + // persisted service token, not let exported shell env hide stale service state. localTokenPrecedence: "config-first", }).token; } diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 2a948668fab..7401dc3b1a2 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -52,6 +52,7 @@ const service = vi.hoisted(() => ({ vi.mock("../../config/config.js", () => ({ loadConfig: loadConfigMock, + readBestEffortConfig: loadConfigMock, readConfigFileSnapshot: readConfigFileSnapshotMock, resolveGatewayPort: resolveGatewayPortMock, writeConfigFile: writeConfigFileMock, diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index fb76bc38002..96a74bdc748 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -4,7 +4,7 @@ import { isGatewayDaemonRuntime, } from "../../commands/daemon-runtime.js"; import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js"; -import { loadConfig, resolveGatewayPort } from "../../config/config.js"; +import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js"; import { resolveIsNixMode } from "../../config/paths.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd.js"; @@ -27,7 +27,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { return; } - const cfg = loadConfig(); + const cfg = await readBestEffortConfig(); const portOverride = parsePort(opts.port); if (opts.port !== undefined && portOverride === null) { fail("Invalid port"); diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index c1a536323c2..989a4e772ba 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -32,6 +32,7 @@ const service = { vi.mock("../../config/config.js", () => ({ loadConfig: () => loadConfig(), + readBestEffortConfig: async () => loadConfig(), })); vi.mock("../../runtime.js", () => ({ @@ -87,7 +88,7 @@ describe("runServiceRestart token drift", () => { ); }); - it("uses gateway.auth.token when checking drift", async () => { + it("compares restart drift against config token even when caller env is set", async () => { loadConfig.mockReturnValue({ gateway: { auth: { diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index db0b2182e3a..4a9c0845a17 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -1,11 +1,13 @@ import type { Writable } from "node:stream"; -import { loadConfig } from "../../config/config.js"; +import { readBestEffortConfig } from "../../config/config.js"; import { resolveIsNixMode } from "../../config/paths.js"; import { checkTokenDrift } from "../../daemon/service-audit.js"; import type { GatewayService } from "../../daemon/service.js"; import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js"; import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js"; -import { isGatewaySecretRefUnavailableError } from "../../gateway/credentials.js"; +import { + isGatewaySecretRefUnavailableError, +} from "../../gateway/credentials.js"; import { isWSL } from "../../infra/wsl.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveGatewayTokenForDriftCheck } from "./gateway-token-drift.js"; @@ -281,7 +283,7 @@ export async function runServiceRestart(params: { try { const command = await params.service.readCommand(process.env); const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN; - const cfg = loadConfig(); + const cfg = await readBestEffortConfig(); const configToken = resolveGatewayTokenForDriftCheck({ cfg, env: process.env }); const driftIssue = checkTokenDrift({ serviceToken, configToken }); if (driftIssue) { diff --git a/src/cli/daemon-cli/lifecycle.test.ts b/src/cli/daemon-cli/lifecycle.test.ts index 9eedb9deca2..70d94c0630c 100644 --- a/src/cli/daemon-cli/lifecycle.test.ts +++ b/src/cli/daemon-cli/lifecycle.test.ts @@ -33,6 +33,7 @@ const loadConfig = vi.fn(() => ({})); vi.mock("../../config/config.js", () => ({ loadConfig: () => loadConfig(), + readBestEffortConfig: async () => loadConfig(), resolveGatewayPort, })); diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 9c23011d2df..5088316a021 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -1,4 +1,4 @@ -import { loadConfig, resolveGatewayPort } from "../../config/config.js"; +import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { defaultRuntime } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; @@ -32,7 +32,7 @@ async function resolveGatewayRestartPort() { } as NodeJS.ProcessEnv; const portFromArgs = parsePortFromArgs(command?.programArguments); - return portFromArgs ?? resolveGatewayPort(loadConfig(), mergedEnv); + return portFromArgs ?? resolveGatewayPort(await readBestEffortConfig(), mergedEnv); } export async function runDaemonUninstall(opts: DaemonLifecycleOptions = {}) { @@ -70,8 +70,8 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) { export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promise { const json = Boolean(opts.json); const service = resolveGatewayService(); - const restartPort = await resolveGatewayRestartPort().catch(() => - resolveGatewayPort(loadConfig(), process.env), + const restartPort = await resolveGatewayRestartPort().catch(async () => + resolveGatewayPort(await readBestEffortConfig(), process.env), ); const restartWaitMs = POST_RESTART_HEALTH_ATTEMPTS * POST_RESTART_HEALTH_DELAY_MS; const restartWaitSeconds = Math.round(restartWaitMs / 1000); diff --git a/src/cli/gateway-cli/call.ts b/src/cli/gateway-cli/call.ts index 704a3ee3c8f..da321a8cd36 100644 --- a/src/cli/gateway-cli/call.ts +++ b/src/cli/gateway-cli/call.ts @@ -1,9 +1,11 @@ import type { Command } from "commander"; +import type { OpenClawConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { withProgress } from "../progress.js"; export type GatewayRpcOpts = { + config?: OpenClawConfig; url?: string; token?: string; password?: string; @@ -30,6 +32,7 @@ export const callGatewayCli = async (method: string, opts: GatewayRpcOpts, param }, async () => await callGateway({ + config: opts.config, url: opts.url, token: opts.token, password: opts.password, diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index 29a06a845f1..d19e53d10b9 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import { gatewayStatusCommand } from "../../commands/gateway-status.js"; import { formatHealthChannelLines, type HealthSummary } from "../../commands/health.js"; -import { loadConfig } from "../../config/config.js"; +import { readBestEffortConfig } from "../../config/config.js"; import { discoverGatewayBeacons } from "../../infra/bonjour-discovery.js"; import type { CostUsageSummary } from "../../infra/session-cost-usage.js"; import { resolveWideAreaDiscoveryDomain } from "../../infra/widearea-dns.js"; @@ -120,8 +120,9 @@ export function registerGatewayCli(program: Command) { .action(async (method, opts, command) => { await runGatewayCommand(async () => { const rpcOpts = resolveGatewayRpcOptions(opts, command); + const config = await readBestEffortConfig(); const params = JSON.parse(String(opts.params ?? "{}")); - const result = await callGatewayCli(method, rpcOpts, params); + const result = await callGatewayCli(method, { ...rpcOpts, config }, params); if (rpcOpts.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; @@ -144,7 +145,8 @@ export function registerGatewayCli(program: Command) { await runGatewayCommand(async () => { const rpcOpts = resolveGatewayRpcOptions(opts, command); const days = parseDaysOption(opts.days); - const result = await callGatewayCli("usage.cost", rpcOpts, { days }); + const config = await readBestEffortConfig(); + const result = await callGatewayCli("usage.cost", { ...rpcOpts, config }, { days }); if (rpcOpts.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; @@ -165,7 +167,8 @@ export function registerGatewayCli(program: Command) { .action(async (opts, command) => { await runGatewayCommand(async () => { const rpcOpts = resolveGatewayRpcOptions(opts, command); - const result = await callGatewayCli("health", rpcOpts); + const config = await readBestEffortConfig(); + const result = await callGatewayCli("health", { ...rpcOpts, config }); if (rpcOpts.json) { defaultRuntime.log(JSON.stringify(result, null, 2)); return; @@ -211,7 +214,7 @@ export function registerGatewayCli(program: Command) { .option("--json", "Output JSON", false) .action(async (opts: GatewayDiscoverOpts) => { await runGatewayCommand(async () => { - const cfg = loadConfig(); + const cfg = await readBestEffortConfig(); const wideAreaDomain = resolveWideAreaDiscoveryDomain({ configDomain: cfg.discovery?.wideArea?.domain, }); diff --git a/src/cli/program/register.subclis.test.ts b/src/cli/program/register.subclis.test.ts index 15833df6b35..56ba4401f46 100644 --- a/src/cli/program/register.subclis.test.ts +++ b/src/cli/program/register.subclis.test.ts @@ -18,10 +18,17 @@ const { nodesAction, registerNodesCli } = vi.hoisted(() => { return { nodesAction: action, registerNodesCli: register }; }); +const configModule = vi.hoisted(() => ({ + loadConfig: vi.fn(), + readConfigFileSnapshot: vi.fn(), +})); + vi.mock("../acp-cli.js", () => ({ registerAcpCli })); vi.mock("../nodes-cli.js", () => ({ registerNodesCli })); +vi.mock("../../config/config.js", () => configModule); -const { registerSubCliByName, registerSubCliCommands } = await import("./register.subclis.js"); +const { loadValidatedConfigForPluginRegistration, registerSubCliByName, registerSubCliCommands } = + await import("./register.subclis.js"); describe("registerSubCliCommands", () => { const originalArgv = process.argv; @@ -47,6 +54,8 @@ describe("registerSubCliCommands", () => { acpAction.mockClear(); registerNodesCli.mockClear(); nodesAction.mockClear(); + configModule.loadConfig.mockReset(); + configModule.readConfigFileSnapshot.mockReset(); }); afterEach(() => { @@ -79,6 +88,28 @@ describe("registerSubCliCommands", () => { expect(registerAcpCli).not.toHaveBeenCalled(); }); + it("returns null for plugin registration when the config snapshot is invalid", async () => { + configModule.readConfigFileSnapshot.mockResolvedValueOnce({ + valid: false, + config: { plugins: { load: { paths: ["/tmp/evil"] } } }, + }); + + await expect(loadValidatedConfigForPluginRegistration()).resolves.toBeNull(); + expect(configModule.loadConfig).not.toHaveBeenCalled(); + }); + + it("loads validated config for plugin registration when the snapshot is valid", async () => { + const loadedConfig = { plugins: { enabled: true } }; + configModule.readConfigFileSnapshot.mockResolvedValueOnce({ + valid: true, + config: loadedConfig, + }); + configModule.loadConfig.mockReturnValueOnce(loadedConfig); + + await expect(loadValidatedConfigForPluginRegistration()).resolves.toBe(loadedConfig); + expect(configModule.loadConfig).toHaveBeenCalledTimes(1); + }); + it("re-parses argv for lazy subcommands", async () => { const program = createRegisteredProgram(["node", "openclaw", "nodes", "list"], "openclaw"); diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index fc044dbcd92..ad120cc0417 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -28,10 +28,15 @@ const shouldEagerRegisterSubcommands = (_argv: string[]) => { return isTruthyEnvValue(process.env.OPENCLAW_DISABLE_LAZY_SUBCOMMANDS); }; -const loadConfig = async (): Promise => { - const mod = await import("../../config/config.js"); - return mod.loadConfig(); -}; +export const loadValidatedConfigForPluginRegistration = + async (): Promise => { + const mod = await import("../../config/config.js"); + const snapshot = await mod.readConfigFileSnapshot(); + if (!snapshot.valid) { + return null; + } + return mod.loadConfig(); + }; // Note for humans and agents: // If you update the list of commands, also check whether they have subcommands @@ -217,7 +222,10 @@ const entries: SubCliEntry[] = [ // The pairing CLI calls listPairingChannels() at registration time, // which requires the plugin registry to be populated with channel plugins. const { registerPluginCliCommands } = await import("../../plugins/cli.js"); - registerPluginCliCommands(program, await loadConfig()); + const config = await loadValidatedConfigForPluginRegistration(); + if (config) { + registerPluginCliCommands(program, config); + } const mod = await import("../pairing-cli.js"); mod.registerPairingCli(program); }, @@ -230,7 +238,10 @@ const entries: SubCliEntry[] = [ const mod = await import("../plugins-cli.js"); mod.registerPluginsCli(program); const { registerPluginCliCommands } = await import("../../plugins/cli.js"); - registerPluginCliCommands(program, await loadConfig()); + const config = await loadValidatedConfigForPluginRegistration(); + if (config) { + registerPluginCliCommands(program, config); + } }, }, { diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index b304f213bfb..e80ce97b845 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -126,8 +126,12 @@ export async function runCli(argv: string[] = process.argv) { if (!shouldSkipPluginRegistration) { // Register plugin CLI commands before parsing const { registerPluginCliCommands } = await import("../plugins/cli.js"); - const { loadConfig } = await import("../config/config.js"); - registerPluginCliCommands(program, loadConfig()); + const { loadValidatedConfigForPluginRegistration } = + await import("./program/register.subclis.js"); + const config = await loadValidatedConfigForPluginRegistration(); + if (config) { + registerPluginCliCommands(program, config); + } } await program.parseAsync(parseArgv); diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 84c6fa8df41..64d515c0b4d 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import { withEnvAsync } from "../test-utils/env.js"; -const loadConfig = vi.fn(() => ({ +const readBestEffortConfig = vi.fn(async () => ({ gateway: { mode: "remote", remote: { url: "wss://remote.example:18789", token: "rtok" }, @@ -94,7 +94,7 @@ const probeGateway = vi.fn(async (opts: { url: string }) => { }); vi.mock("../config/config.js", () => ({ - loadConfig, + readBestEffortConfig, resolveGatewayPort, })); @@ -150,8 +150,7 @@ function makeRemoteGatewayConfig(url: string, token = "rtok", localToken = "ltok } function mockLocalTokenEnvRefConfig(envTokenId = "MISSING_GATEWAY_TOKEN") { - // pragma: allowlist secret - loadConfig.mockReturnValueOnce({ + readBestEffortConfig.mockResolvedValueOnce({ secrets: { providers: { default: { source: "env" }, @@ -164,7 +163,7 @@ function mockLocalTokenEnvRefConfig(envTokenId = "MISSING_GATEWAY_TOKEN") { token: { source: "env", provider: "default", id: envTokenId }, }, }, - } as unknown as ReturnType); + } as never); } async function runGatewayStatus( @@ -266,7 +265,7 @@ describe("gateway-status command", () => { MISSING_GATEWAY_PASSWORD: undefined, }, async () => { - loadConfig.mockReturnValueOnce({ + readBestEffortConfig.mockResolvedValueOnce({ secrets: { providers: { default: { source: "env" }, @@ -280,7 +279,7 @@ describe("gateway-status command", () => { password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" }, }, }, - } as unknown as ReturnType); + } as never); await runGatewayStatus(runtime, { timeout: "1000", json: true }); }, @@ -307,7 +306,7 @@ describe("gateway-status command", () => { CLAWDBOT_GATEWAY_TOKEN: undefined, }, async () => { - loadConfig.mockReturnValueOnce({ + readBestEffortConfig.mockResolvedValueOnce({ secrets: { providers: { default: { source: "env" }, @@ -320,7 +319,7 @@ describe("gateway-status command", () => { token: "${CUSTOM_GATEWAY_TOKEN}", }, }, - } as unknown as ReturnType); + } as never); await runGatewayStatus(runtime, { timeout: "1000", json: true }); }, @@ -463,7 +462,7 @@ describe("gateway-status command", () => { it("skips invalid ssh-auto discovery targets", async () => { const { runtime } = createRuntimeCapture(); await withEnvAsync({ USER: "steipete" }, async () => { - loadConfig.mockReturnValueOnce(makeRemoteGatewayConfig("", "", "ltok")); + readBestEffortConfig.mockResolvedValueOnce(makeRemoteGatewayConfig("", "", "ltok")); discoverGatewayBeacons.mockResolvedValueOnce([ { tailnetDns: "-V" }, { tailnetDns: "goodhost" }, @@ -481,7 +480,7 @@ describe("gateway-status command", () => { it("infers SSH target from gateway.remote.url and ssh config", async () => { const { runtime } = createRuntimeCapture(); await withEnvAsync({ USER: "steipete" }, async () => { - loadConfig.mockReturnValueOnce( + readBestEffortConfig.mockResolvedValueOnce( makeRemoteGatewayConfig("ws://peters-mac-studio-1.sheep-coho.ts.net:18789"), ); resolveSshConfig.mockResolvedValueOnce({ @@ -507,7 +506,9 @@ describe("gateway-status command", () => { it("falls back to host-only when USER is missing and ssh config is unavailable", async () => { const { runtime } = createRuntimeCapture(); await withEnvAsync({ USER: "" }, async () => { - loadConfig.mockReturnValueOnce(makeRemoteGatewayConfig("wss://studio.example:18789")); + readBestEffortConfig.mockResolvedValueOnce( + makeRemoteGatewayConfig("wss://studio.example:18789"), + ); resolveSshConfig.mockResolvedValueOnce(null); startSshPortForward.mockClear(); @@ -523,7 +524,9 @@ describe("gateway-status command", () => { it("keeps explicit SSH identity even when ssh config provides one", async () => { const { runtime } = createRuntimeCapture(); - loadConfig.mockReturnValueOnce(makeRemoteGatewayConfig("wss://studio.example:18789")); + readBestEffortConfig.mockResolvedValueOnce( + makeRemoteGatewayConfig("wss://studio.example:18789"), + ); resolveSshConfig.mockResolvedValueOnce({ user: "me", host: "studio.example", diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index 2b71558202f..4ac54eca0c4 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -1,5 +1,5 @@ import { withProgress } from "../cli/progress.js"; -import { loadConfig, resolveGatewayPort } from "../config/config.js"; +import { readBestEffortConfig, resolveGatewayPort } from "../config/config.js"; import { probeGateway } from "../gateway/probe.js"; import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; import { resolveSshConfig } from "../infra/ssh-config.js"; @@ -35,7 +35,7 @@ export async function gatewayStatusCommand( runtime: RuntimeEnv, ) { const startedAt = Date.now(); - const cfg = loadConfig(); + const cfg = await readBestEffortConfig(); const rich = isRich() && opts.json !== true; const overallTimeoutMs = parseTimeoutMs(opts.timeout, 3000); const wideAreaDomain = resolveWideAreaDiscoveryDomain({ diff --git a/src/commands/health.ts b/src/commands/health.ts index 0280c5dab67..56705c96270 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -4,7 +4,7 @@ import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index. import type { ChannelAccountSnapshot } from "../channels/plugins/types.js"; import { withProgress } from "../cli/progress.js"; import type { OpenClawConfig } from "../config/config.js"; -import { loadConfig } from "../config/config.js"; +import { loadConfig, readBestEffortConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; @@ -526,7 +526,7 @@ export async function healthCommand( opts: { json?: boolean; timeoutMs?: number; verbose?: boolean; config?: OpenClawConfig }, runtime: RuntimeEnv, ) { - const cfg = opts.config ?? loadConfig(); + const cfg = opts.config ?? (await readBestEffortConfig()); // Always query the running gateway; do not open a direct Baileys socket here. const summary = await withProgress( { diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index 5adc26327af..fa4e3dcb435 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -3,7 +3,11 @@ import { formatCliCommand } from "../cli/command-format.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { withProgress } from "../cli/progress.js"; -import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js"; +import { + readBestEffortConfig, + readConfigFileSnapshot, + resolveGatewayPort, +} from "../config/config.js"; import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { resolveNodeService } from "../daemon/node-service.js"; import type { GatewayService } from "../daemon/service.js"; @@ -39,7 +43,7 @@ export async function statusAllCommand( ): Promise { await withProgress({ label: "Scanning status --all…", total: 11 }, async (progress) => { progress.setLabel("Loading config…"); - const loadedRaw = loadConfig(); + const loadedRaw = await readBestEffortConfig(); const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({ config: loadedRaw, commandName: "status --all", @@ -190,6 +194,7 @@ export async function statusAllCommand( progress.setLabel("Querying gateway…"); const health = gatewayReachable ? await callGateway({ + config: cfg, method: "health", timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000), ...callOverrides, @@ -198,6 +203,7 @@ export async function statusAllCommand( const channelsStatus = gatewayReachable ? await callGateway({ + config: cfg, method: "channels.status", params: { probe: false, timeoutMs: opts?.timeoutMs ?? 10_000 }, timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000), diff --git a/src/commands/status.agent-local.ts b/src/commands/status.agent-local.ts index b7bb8bdf127..5c57036eb97 100644 --- a/src/commands/status.agent-local.ts +++ b/src/commands/status.agent-local.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { listAgentsForGateway } from "../gateway/session-utils.js"; @@ -16,6 +17,13 @@ export type AgentLocalStatus = { lastActiveAgeMs: number | null; }; +type AgentLocalStatusesResult = { + defaultId: string; + agents: AgentLocalStatus[]; + totalSessions: number; + bootstrapPendingCount: number; +}; + async function fileExists(p: string): Promise { try { await fs.access(p); @@ -25,13 +33,9 @@ async function fileExists(p: string): Promise { } } -export async function getAgentLocalStatuses(): Promise<{ - defaultId: string; - agents: AgentLocalStatus[]; - totalSessions: number; - bootstrapPendingCount: number; -}> { - const cfg = loadConfig(); +export async function getAgentLocalStatuses( + cfg: OpenClawConfig = loadConfig(), +): Promise { const agentList = listAgentsForGateway(cfg); const now = Date.now(); diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 97be8c9e412..0d412c9715a 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -153,6 +153,7 @@ export async function statusCommand( method: "health", params: { probe: true }, timeoutMs: opts.timeoutMs, + config: scan.cfg, }), ) : undefined; @@ -162,6 +163,7 @@ export async function statusCommand( method: "last-heartbeat", params: {}, timeoutMs: opts.timeoutMs, + config: scan.cfg, }).catch(() => null) : null; @@ -219,7 +221,7 @@ export async function statusCommand( const warn = (value: string) => (rich ? theme.warn(value) : value); if (opts.verbose) { - const details = buildGatewayConnectionDetails(); + const details = buildGatewayConnectionDetails({ config: scan.cfg }); runtime.log(info("Gateway connection:")); for (const line of details.message.split("\n")) { runtime.log(` ${line}`); diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 721d4fdeea4..6592b84c864 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - loadConfig: vi.fn(), + readBestEffortConfig: vi.fn(), resolveCommandSecretRefsViaGateway: vi.fn(), buildChannelsTable: vi.fn(), getUpdateCheckResult: vi.fn(), @@ -17,7 +17,7 @@ vi.mock("../cli/progress.js", () => ({ })); vi.mock("../config/config.js", () => ({ - loadConfig: mocks.loadConfig, + readBestEffortConfig: mocks.readBestEffortConfig, })); vi.mock("../cli/command-secret-gateway.js", () => ({ @@ -74,7 +74,7 @@ import { scanStatus } from "./status.scan.js"; describe("scanStatus", () => { it("passes sourceConfig into buildChannelsTable for summary-mode status output", async () => { - mocks.loadConfig.mockReturnValue({ + mocks.readBestEffortConfig.mockResolvedValue({ marker: "source", session: {}, plugins: { enabled: false }, diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index bce208af0cc..38e15e6417b 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -1,7 +1,8 @@ import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { withProgress } from "../cli/progress.js"; -import { loadConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { readBestEffortConfig } from "../config/config.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { probeGateway } from "../gateway/probe.js"; @@ -59,7 +60,7 @@ function unwrapDeferredResult(result: DeferredResult): T { return result.value; } -function resolveMemoryPluginStatus(cfg: ReturnType): MemoryPluginStatus { +function resolveMemoryPluginStatus(cfg: OpenClawConfig): MemoryPluginStatus { const pluginsEnabled = cfg.plugins?.enabled !== false; if (!pluginsEnabled) { return { enabled: false, slot: null, reason: "plugins disabled" }; @@ -72,10 +73,10 @@ function resolveMemoryPluginStatus(cfg: ReturnType): MemoryPl } async function resolveGatewayProbeSnapshot(params: { - cfg: ReturnType; + cfg: OpenClawConfig; opts: { timeoutMs?: number; all?: boolean }; }): Promise { - const gatewayConnection = buildGatewayConnectionDetails(); + const gatewayConnection = buildGatewayConnectionDetails({ config: params.cfg }); const isRemoteMode = params.cfg.gateway?.mode === "remote"; const remoteUrlRaw = typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url : ""; @@ -107,6 +108,7 @@ async function resolveGatewayProbeSnapshot(params: { } async function resolveChannelsStatus(params: { + cfg: OpenClawConfig; gatewayReachable: boolean; opts: { timeoutMs?: number; all?: boolean }; }) { @@ -114,6 +116,7 @@ async function resolveChannelsStatus(params: { return null; } return await callGateway({ + config: params.cfg, method: "channels.status", params: { probe: false, @@ -124,8 +127,8 @@ async function resolveChannelsStatus(params: { } export type StatusScanResult = { - cfg: ReturnType; - sourceConfig: ReturnType; + cfg: OpenClawConfig; + sourceConfig: OpenClawConfig; secretDiagnostics: string[]; osSummary: ReturnType; tailscaleMode: string; @@ -152,7 +155,7 @@ export type StatusScanResult = { }; async function resolveMemoryStatusSnapshot(params: { - cfg: ReturnType; + cfg: OpenClawConfig; agentStatus: Awaited>; memoryPlugin: MemoryPluginStatus; }): Promise { @@ -180,7 +183,7 @@ async function scanStatusJsonFast(opts: { timeoutMs?: number; all?: boolean; }): Promise { - const loadedRaw = loadConfig(); + const loadedRaw = await readBestEffortConfig(); const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = await resolveCommandSecretRefsViaGateway({ config: loadedRaw, @@ -196,7 +199,7 @@ async function scanStatusJsonFast(opts: { fetchGit: true, includeRegistry: true, }); - const agentStatusPromise = getAgentLocalStatuses(); + const agentStatusPromise = getAgentLocalStatuses(cfg); const summaryPromise = getStatusSummary({ config: cfg, sourceConfig: loadedRaw }); const tailscaleDnsPromise = @@ -232,7 +235,7 @@ async function scanStatusJsonFast(opts: { const gatewaySelf = gatewayProbe?.presence ? pickGatewaySelfPresence(gatewayProbe.presence) : null; - const channelsStatusPromise = resolveChannelsStatus({ gatewayReachable, opts }); + const channelsStatusPromise = resolveChannelsStatus({ cfg, gatewayReachable, opts }); const memoryPlugin = resolveMemoryPluginStatus(cfg); const memoryPromise = resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); const [channelsStatus, memory] = await Promise.all([channelsStatusPromise, memoryPromise]); @@ -283,7 +286,7 @@ export async function scanStatus( }, async (progress) => { progress.setLabel("Loading config…"); - const loadedRaw = loadConfig(); + const loadedRaw = await readBestEffortConfig(); const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = await resolveCommandSecretRefsViaGateway({ config: loadedRaw, @@ -307,7 +310,7 @@ export async function scanStatus( includeRegistry: true, }), ); - const agentStatusPromise = deferResult(getAgentLocalStatuses()); + const agentStatusPromise = deferResult(getAgentLocalStatuses(cfg)); const summaryPromise = deferResult( getStatusSummary({ config: cfg, sourceConfig: loadedRaw }), ); @@ -345,7 +348,7 @@ export async function scanStatus( progress.tick(); progress.setLabel("Querying channel status…"); - const channelsStatus = await resolveChannelsStatus({ gatewayReachable, opts }); + const channelsStatus = await resolveChannelsStatus({ cfg, gatewayReachable, opts }); const channelIssues = channelsStatus ? collectChannelStatusIssues(channelsStatus) : []; progress.tick(); diff --git a/src/config/config.ts b/src/config/config.ts index 35fe656c666..2c7d6a75f1b 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -5,6 +5,7 @@ export { getRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, loadConfig, + readBestEffortConfig, parseConfigJson5, readConfigFileSnapshot, readConfigFileSnapshotForWrite, diff --git a/src/config/io.compat.test.ts b/src/config/io.compat.test.ts index b6a2f0ffcfc..7c357c63c68 100644 --- a/src/config/io.compat.test.ts +++ b/src/config/io.compat.test.ts @@ -159,7 +159,7 @@ describe("config io paths", () => { logger, }); - expect(() => io.loadConfig()).toThrow("Invalid config"); + expect(() => io.loadConfig()).toThrow(/Invalid config/); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining(`Invalid config at ${configPath}:\\n`), ); diff --git a/src/config/io.ts b/src/config/io.ts index f65e70d7a81..7b1af76438a 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1383,6 +1383,11 @@ export function loadConfig(): OpenClawConfig { return config; } +export async function readBestEffortConfig(): Promise { + const snapshot = await readConfigFileSnapshot(); + return snapshot.valid ? loadConfig() : snapshot.config; +} + export async function readConfigFileSnapshot(): Promise { return await createConfigIO().readConfigFileSnapshot(); }