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
This commit is contained in:
@@ -332,6 +332,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/DM session-key normalization: rewrite legacy `discord:dm:*` and phantom direct-message `discord:channel:<user>` 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
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ describe("resolveCommandSecretRefsViaGateway", () => {
|
||||
});
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config,
|
||||
method: "secrets.resolve",
|
||||
requiredMethods: ["secrets.resolve"],
|
||||
params: {
|
||||
|
||||
@@ -396,6 +396,7 @@ export async function resolveCommandSecretRefsViaGateway(params: {
|
||||
let payload: GatewaySecretsResolveResult;
|
||||
try {
|
||||
payload = await callGateway<GatewaySecretsResolveResult>({
|
||||
config: params.config,
|
||||
method: "secrets.resolve",
|
||||
requiredMethods: ["secrets.resolve"],
|
||||
params: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ const service = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: loadConfigMock,
|
||||
readBestEffortConfig: loadConfigMock,
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
resolveGatewayPort: resolveGatewayPortMock,
|
||||
writeConfigFile: writeConfigFileMock,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -33,6 +33,7 @@ const loadConfig = vi.fn(() => ({}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: () => loadConfig(),
|
||||
readBestEffortConfig: async () => loadConfig(),
|
||||
resolveGatewayPort,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -28,10 +28,15 @@ const shouldEagerRegisterSubcommands = (_argv: string[]) => {
|
||||
return isTruthyEnvValue(process.env.OPENCLAW_DISABLE_LAZY_SUBCOMMANDS);
|
||||
};
|
||||
|
||||
const loadConfig = async (): Promise<OpenClawConfig> => {
|
||||
const mod = await import("../../config/config.js");
|
||||
return mod.loadConfig();
|
||||
};
|
||||
export const loadValidatedConfigForPluginRegistration =
|
||||
async (): Promise<OpenClawConfig | null> => {
|
||||
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);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof loadConfig>);
|
||||
} 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<typeof loadConfig>);
|
||||
} 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<typeof loadConfig>);
|
||||
} 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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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<void> {
|
||||
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),
|
||||
|
||||
@@ -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<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
@@ -25,13 +33,9 @@ async function fileExists(p: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgentLocalStatuses(): Promise<{
|
||||
defaultId: string;
|
||||
agents: AgentLocalStatus[];
|
||||
totalSessions: number;
|
||||
bootstrapPendingCount: number;
|
||||
}> {
|
||||
const cfg = loadConfig();
|
||||
export async function getAgentLocalStatuses(
|
||||
cfg: OpenClawConfig = loadConfig(),
|
||||
): Promise<AgentLocalStatusesResult> {
|
||||
const agentList = listAgentsForGateway(cfg);
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<T>(result: DeferredResult<T>): T {
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function resolveMemoryPluginStatus(cfg: ReturnType<typeof loadConfig>): 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<typeof loadConfig>): MemoryPl
|
||||
}
|
||||
|
||||
async function resolveGatewayProbeSnapshot(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
cfg: OpenClawConfig;
|
||||
opts: { timeoutMs?: number; all?: boolean };
|
||||
}): Promise<GatewayProbeSnapshot> {
|
||||
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<typeof loadConfig>;
|
||||
sourceConfig: ReturnType<typeof loadConfig>;
|
||||
cfg: OpenClawConfig;
|
||||
sourceConfig: OpenClawConfig;
|
||||
secretDiagnostics: string[];
|
||||
osSummary: ReturnType<typeof resolveOsSummary>;
|
||||
tailscaleMode: string;
|
||||
@@ -152,7 +155,7 @@ export type StatusScanResult = {
|
||||
};
|
||||
|
||||
async function resolveMemoryStatusSnapshot(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
cfg: OpenClawConfig;
|
||||
agentStatus: Awaited<ReturnType<typeof getAgentLocalStatuses>>;
|
||||
memoryPlugin: MemoryPluginStatus;
|
||||
}): Promise<MemoryStatusSnapshot | null> {
|
||||
@@ -180,7 +183,7 @@ async function scanStatusJsonFast(opts: {
|
||||
timeoutMs?: number;
|
||||
all?: boolean;
|
||||
}): Promise<StatusScanResult> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
getRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
loadConfig,
|
||||
readBestEffortConfig,
|
||||
parseConfigJson5,
|
||||
readConfigFileSnapshot,
|
||||
readConfigFileSnapshotForWrite,
|
||||
|
||||
@@ -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`),
|
||||
);
|
||||
|
||||
@@ -1383,6 +1383,11 @@ export function loadConfig(): OpenClawConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function readBestEffortConfig(): Promise<OpenClawConfig> {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
return snapshot.valid ? loadConfig() : snapshot.config;
|
||||
}
|
||||
|
||||
export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
return await createConfigIO().readConfigFileSnapshot();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user