diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 604e27666c9..59c23b3d893 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -7,6 +7,8 @@ import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-f import { CONFIG_PATH } from "../config/paths.js"; import { isBlockedObjectKey } from "../config/prototype-keys.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; +import { setConfigSource } from "../config/sources/current.js"; +import { resolveConfigSource } from "../config/sources/resolve.js"; import { coerceSecretRef, isValidEnvSecretRefId, @@ -18,6 +20,7 @@ import { import { validateConfigObjectRaw } from "../config/validation.js"; import { SecretProviderSchema } from "../config/zod-schema.core.js"; import { danger, info, success } from "../globals.js"; +import { loadDotEnv } from "../infra/dotenv.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { @@ -99,6 +102,13 @@ class ConfigSetDryRunValidationError extends Error { } } +function initializeConfigSourceForStandaloneConfigCli(): void { + // Standalone config commands run in a fresh process. + // Ensure config helpers read from the same source (file vs Nacos) as the gateway. + loadDotEnv({ quiet: true }); + setConfigSource(resolveConfigSource(process.env)); +} + function isIndexSegment(raw: string): boolean { return /^[0-9]+$/.test(raw); } @@ -969,6 +979,7 @@ export async function runConfigSet(opts: { cliOptions: ConfigSetOptions; runtime?: RuntimeEnv; }) { + initializeConfigSourceForStandaloneConfigCli(); const runtime = opts.runtime ?? defaultRuntime; try { const isBatchMode = hasBatchMode(opts.cliOptions); @@ -1130,6 +1141,7 @@ export async function runConfigSet(opts: { } export async function runConfigGet(opts: { path: string; json?: boolean; runtime?: RuntimeEnv }) { + initializeConfigSourceForStandaloneConfigCli(); const runtime = opts.runtime ?? defaultRuntime; try { const parsedPath = parseRequiredPath(opts.path); @@ -1161,6 +1173,7 @@ export async function runConfigGet(opts: { path: string; json?: boolean; runtime } export async function runConfigUnset(opts: { path: string; runtime?: RuntimeEnv }) { + initializeConfigSourceForStandaloneConfigCli(); const runtime = opts.runtime ?? defaultRuntime; try { const parsedPath = parseRequiredPath(opts.path); @@ -1184,6 +1197,7 @@ export async function runConfigUnset(opts: { path: string; runtime?: RuntimeEnv } export async function runConfigFile(opts: { runtime?: RuntimeEnv }) { + initializeConfigSourceForStandaloneConfigCli(); const runtime = opts.runtime ?? defaultRuntime; try { const snapshot = await readConfigFileSnapshot(); @@ -1195,6 +1209,7 @@ export async function runConfigFile(opts: { runtime?: RuntimeEnv }) { } export async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } = {}) { + initializeConfigSourceForStandaloneConfigCli(); const runtime = opts.runtime ?? defaultRuntime; let outputPath = CONFIG_PATH ?? "openclaw.json"; diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 0aa0e8ff36e..c2026777567 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -10,12 +10,15 @@ import { resolveStateDir, resolveGatewayPort, } from "../../config/config.js"; +import { setConfigSource } from "../../config/sources/current.js"; +import { resolveConfigSource } from "../../config/sources/resolve.js"; import { hasConfiguredSecretInput } from "../../config/types.secrets.js"; import { resolveGatewayAuth } from "../../gateway/auth.js"; import { startGatewayServer } from "../../gateway/server.js"; import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js"; import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js"; import { setVerbose } from "../../globals.js"; +import { loadDotEnv } from "../../infra/dotenv.js"; import { GatewayLockError } from "../../infra/gateway-lock.js"; import { formatPortDiagnostics, inspectPortUsage } from "../../infra/ports.js"; import { cleanStaleGatewayProcessesSync } from "../../infra/restart-stale-pids.js"; @@ -198,6 +201,11 @@ async function runGatewayCommand(opts: GatewayRunOpts) { await ensureDevGatewayConfig({ reset: Boolean(opts.reset) }); } + // Initialize config source (file vs Nacos) before preflight so port/bind and + // config-exists / gateway.mode checks use Nacos when OPENCLAW_CONFIG_SOURCE=nacos. + loadDotEnv({ quiet: true }); + setConfigSource(resolveConfigSource(process.env)); + const cfg = loadConfig(); const portOverride = parsePort(opts.port); if (opts.port !== undefined && portOverride === null) { diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 252b44efaca..61ab8e938c6 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -12,12 +12,15 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig } from "../config/config.js"; import { CONFIG_PATH, readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; +import { setConfigSource } from "../config/sources/current.js"; +import { resolveConfigSource } from "../config/sources/resolve.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import { resolveGatewayService } from "../daemon/service.js"; import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { runStartupMatrixMigration } from "../gateway/server-startup-matrix-migration.js"; +import { loadDotEnv } from "../infra/dotenv.js"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -80,6 +83,11 @@ export async function doctorCommand( printWizardHeader(runtime); intro("OpenClaw doctor"); + // Standalone CLI process: initialize config source (file vs Nacos) + // before any config reads/writes. + loadDotEnv({ quiet: true }); + setConfigSource(resolveConfigSource(process.env)); + const root = await resolveOpenClawPackageRoot({ moduleUrl: import.meta.url, argv1: process.argv[1], diff --git a/src/config/sources/nacos-client.test.ts b/src/config/sources/nacos-client.test.ts index 0692400d132..b631e55cebf 100644 --- a/src/config/sources/nacos-client.test.ts +++ b/src/config/sources/nacos-client.test.ts @@ -66,4 +66,39 @@ describe("createNacosConfigClient", () => { expect(onChange).toHaveBeenCalled(); teardown(); }); + + it("subscribe backs off when listener returns HTTP error", async () => { + vi.useFakeTimers(); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: () => Promise.resolve(""), + }); + + const client = createNacosConfigClient({ + serverAddr: "http://127.0.0.1:8848", + dataId: "openclaw.json", + group: "DEFAULT_GROUP", + fetch: fetchMock, + }); + + const onChange = vi.fn(); + const teardown = client.subscribe(onChange); + + // Flush the initial microtasks so poll() reaches the HTTP error branch. + await Promise.resolve(); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000); + teardown(); + + // Let the scheduled backoff settle and avoid leaving pending timers/promises. + await vi.advanceTimersByTimeAsync(5000); + + expect(onChange).not.toHaveBeenCalled(); + setTimeoutSpy.mockRestore(); + vi.useRealTimers(); + }); }); diff --git a/src/config/sources/nacos-client.ts b/src/config/sources/nacos-client.ts index 5c2dd82266b..d70fbc3703c 100644 --- a/src/config/sources/nacos-client.ts +++ b/src/config/sources/nacos-client.ts @@ -79,7 +79,11 @@ export function createNacosConfigClient(opts: NacosConfigClientOptions): NacosCo body: body.toString(), }); if (stopped) return; - if (res.ok) { + if (!res.ok) { + // Nacos can respond quickly with HTTP errors (401/403/5xx) when ACL/service is unhealthy. + // Add a backoff to avoid a tight POST loop hammering the server. + await new Promise((r) => setTimeout(r, 5000)); + } else { const text = await res.text(); if (text.trim()) { try { diff --git a/src/config/sources/snapshot-from-raw.test.ts b/src/config/sources/snapshot-from-raw.test.ts index 8c1cacbc8ad..a8778b126f0 100644 --- a/src/config/sources/snapshot-from-raw.test.ts +++ b/src/config/sources/snapshot-from-raw.test.ts @@ -13,4 +13,21 @@ describe("buildSnapshotFromRaw", () => { expect(snap.valid).toBe(true); expect(snap.config?.gateway?.mode).toBe("local"); }); + + it("applies config.env to process.env for Nacos snapshots", async () => { + const envKey = "OPENAI_API_KEY"; + const previous = process.env[envKey]; + delete process.env[envKey]; + try { + const raw = `{"env":{"${envKey}":"sk-test"}}`; + await buildSnapshotFromRaw(raw, "nacos:openclaw.json", { env: process.env }); + expect(process.env[envKey]).toBe("sk-test"); + } finally { + if (previous !== undefined) { + process.env[envKey] = previous; + } else { + delete process.env[envKey]; + } + } + }); }); diff --git a/src/config/sources/snapshot-from-raw.ts b/src/config/sources/snapshot-from-raw.ts index d8aad813bbe..90e5ef10e4a 100644 --- a/src/config/sources/snapshot-from-raw.ts +++ b/src/config/sources/snapshot-from-raw.ts @@ -80,7 +80,13 @@ export async function buildSnapshotFromRaw( typeof parsedRes.parsed === "object" && "env" in (parsedRes.parsed as object) ) { - applyConfigEnvVars(parsedRes.parsed as OpenClawConfig, envCopy); + const cfgWithEnv = parsedRes.parsed as OpenClawConfig; + // Nacos-backed snapshots should also hydrate the real process env so features + // that consult process.env later in the process see config.env values. + if (opts.env === process.env) { + applyConfigEnvVars(cfgWithEnv, opts.env); + } + applyConfigEnvVars(cfgWithEnv, envCopy); } const resolvedConfigRaw = resolveConfigEnvVars(parsedRes.parsed, envCopy, { onMissing: (w) => envWarnings.push(w), diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index b7a7731ac4f..c589845a4f0 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -17,6 +17,7 @@ import { loadConfig, migrateLegacyConfig, readConfigFileSnapshot, + getRuntimeConfigSourceSnapshot, setRuntimeConfigSnapshot, writeConfigFile, } from "../config/config.js"; @@ -557,7 +558,11 @@ export async function startGatewayServer( // Nacos source: keep loadConfig() in sync with the config we use (no file path). const configSource = getConfigSource(); if (configSource?.kind === "nacos") { - setRuntimeConfigSnapshot(cfgAtStart); + // Preserve the original Nacos "source" snapshot captured during secret activation. + // If we overwrite it with cfgAtStart, config.env and ${ENV} substitutions can lose + // their original surface values in subsequent config.get/set/patch operations. + const sourceSnapshot = getRuntimeConfigSourceSnapshot(); + setRuntimeConfigSnapshot(cfgAtStart, sourceSnapshot ?? cfgAtStart); } initSubagentRegistry();