From 01c25d72806f41be2952861301503290bdfa83b6 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Wed, 18 Mar 2026 22:23:06 +0800 Subject: [PATCH 1/8] config: add Nacos config source, reload subscribe, validation and NACOS_NAMESPACE Made-with: Cursor --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 21 +++ docs/help/environment.md | 14 ++ src/config/io.ts | 25 ++++ src/config/sources/current.ts | 17 +++ src/config/sources/file.test.ts | 36 +++++ src/config/sources/file.ts | 14 ++ src/config/sources/nacos-client.test.ts | 69 +++++++++ src/config/sources/nacos-client.ts | 90 ++++++++++++ src/config/sources/nacos.test.ts | 28 ++++ src/config/sources/nacos.ts | 47 ++++++ src/config/sources/resolve.test.ts | 38 +++++ src/config/sources/resolve.ts | 35 +++++ src/config/sources/snapshot-from-raw.test.ts | 16 +++ src/config/sources/snapshot-from-raw.ts | 143 +++++++++++++++++++ src/config/sources/types.test.ts | 21 +++ src/config/sources/types.ts | 10 ++ src/gateway/config-reload.test.ts | 44 ++++++ src/gateway/config-reload.ts | 59 +++++--- src/gateway/server.impl.ts | 41 ++++-- 20 files changed, 740 insertions(+), 29 deletions(-) create mode 100644 src/config/sources/current.ts create mode 100644 src/config/sources/file.test.ts create mode 100644 src/config/sources/file.ts create mode 100644 src/config/sources/nacos-client.test.ts create mode 100644 src/config/sources/nacos-client.ts create mode 100644 src/config/sources/nacos.test.ts create mode 100644 src/config/sources/nacos.ts create mode 100644 src/config/sources/resolve.test.ts create mode 100644 src/config/sources/resolve.ts create mode 100644 src/config/sources/snapshot-from-raw.test.ts create mode 100644 src/config/sources/snapshot-from-raw.ts create mode 100644 src/config/sources/types.test.ts create mode 100644 src/config/sources/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8098c0578..afa2a677704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev. - Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev. - Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman. +- Gateway: optional Nacos config source so config can be loaded from Nacos into memory with push-based hot reload (env: OPENCLAW_CONFIG_SOURCE=nacos, NACOS_SERVER_ADDR, NACOS_DATA_ID, NACOS_GROUP). - Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97. - Plugins/Xiaomi: switch the bundled Xiaomi provider to the `/v1` OpenAI-compatible endpoint and add MiMo V2 Pro plus MiMo V2 Omni to the built-in catalog. (#49214) thanks @DJjjjhao. - Android/Talk: move Talk speech synthesis behind gateway `talk.speak`, keep Talk secrets on the gateway, and switch Android playback to final-response audio instead of device-local ElevenLabs streaming. (#50849) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 80972376dc3..298a5220427 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -624,6 +624,27 @@ Supported credential paths are listed in [SecretRef Credential Surface](/referen See [Environment](/help/environment) for full precedence and sources. +## Config source: Nacos + +You can load configuration from [Nacos](https://nacos.io/) instead of a local file. Use this when: + +- The gateway runs in a pod or container and you do not want to mount a config file (config lives only in memory). +- You want centralized config management and push-based updates from Nacos. + +Set these environment variables: + +| Variable | Required | Purpose | +| -------- | -------- | ------- | +| `OPENCLAW_CONFIG_SOURCE` | Yes | Set to `nacos` to use Nacos; omit or set to `file` for the default file-based config. | +| `NACOS_SERVER_ADDR` | Yes | Nacos server address (e.g. `http://nacos:8848`). | +| `NACOS_DATA_ID` | Yes | Data ID of the config (e.g. `openclaw.json`). | +| `NACOS_GROUP` | No | Nacos group (default: `DEFAULT_GROUP`). | +| `NACOS_NAMESPACE` | No | Nacos namespace (tenant); omit for default namespace. | + +When `OPENCLAW_CONFIG_SOURCE=nacos`, the gateway fetches the config from Nacos at startup. No config file is written on disk; the in-memory config is updated by Nacos long-polling. Hot reload is driven by Nacos (changes in Nacos are applied without restart). Config read RPC (`config.get`) uses the in-memory config. Config write RPC (`config.apply`, `config.patch`) and CLI `config set` update only the in-memory snapshot (no write to disk or to Nacos). To change config persistently when using Nacos, update the config in Nacos and rely on hot reload. + +See [Environment](/help/environment#config-source-nacos) for these variables in the env reference. + ## Full reference For the complete field-by-field reference, see **[Configuration Reference](/gateway/configuration-reference)**. diff --git a/docs/help/environment.md b/docs/help/environment.md index 45faad7c66c..51aae1948df 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -109,6 +109,20 @@ Both resolve from process env at activation time. SecretRef details are document | `OPENCLAW_STATE_DIR` | Override the state directory (default `~/.openclaw`). | | `OPENCLAW_CONFIG_PATH` | Override the config file path (default `~/.openclaw/openclaw.json`). | +## Config source (Nacos) + +When loading config from Nacos instead of a local file (e.g. in Kubernetes pods with no config file mount): + +| Variable | Required | Purpose | +| ------------------------ | -------- | ----------------------------------------------------------------------- | +| `OPENCLAW_CONFIG_SOURCE` | Yes | Set to `nacos` to use Nacos; omit or `file` for file-based config. | +| `NACOS_SERVER_ADDR` | Yes | Nacos server address (e.g. `http://nacos:8848`). | +| `NACOS_DATA_ID` | Yes | Data ID of the config (e.g. `openclaw.json`). | +| `NACOS_GROUP` | No | Nacos group (default: `DEFAULT_GROUP`). | +| `NACOS_NAMESPACE` | No | Nacos namespace (tenant); omit for default namespace. | + +Config is kept only in memory; hot reload is driven by Nacos long-polling. See [Configuration: Config source: Nacos](/gateway/configuration#config-source-nacos). + ## Logging | Variable | Purpose | diff --git a/src/config/io.ts b/src/config/io.ts index fba17f253aa..5704406be4c 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -47,6 +47,7 @@ import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin. import { normalizeConfigPaths } from "./normalize-paths.js"; import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; +import { getConfigSource } from "./sources/current.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import { @@ -1497,10 +1498,22 @@ export async function readBestEffortConfig(): Promise { } export async function readConfigFileSnapshot(): Promise { + const source = getConfigSource(); + if (source !== null) { + return source.readSnapshot(); + } return await createConfigIO().readConfigFileSnapshot(); } export async function readConfigFileSnapshotForWrite(): Promise { + const source = getConfigSource(); + if (source?.kind === "nacos") { + const snapshot = await source.readSnapshot(); + return { + snapshot, + writeOptions: { expectedConfigPath: snapshot.path }, + }; + } return await createConfigIO().readConfigFileSnapshotForWrite(); } @@ -1508,6 +1521,18 @@ export async function writeConfigFile( cfg: OpenClawConfig, options: ConfigWriteOptions = {}, ): Promise { + const source = getConfigSource(); + if (source?.kind === "nacos") { + const validated = validateConfigObjectRawWithPlugins(cfg); + if (!validated.ok) { + const issue = validated.issues[0]; + const pathLabel = issue?.path ? issue.path : ""; + const issueMessage = issue?.message ?? "invalid"; + throw new Error(formatConfigValidationFailure(pathLabel, issueMessage)); + } + setRuntimeConfigSnapshot(cfg); + return; + } const io = createConfigIO(); let nextCfg = cfg; const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot); diff --git a/src/config/sources/current.ts b/src/config/sources/current.ts new file mode 100644 index 00000000000..7ac74bab064 --- /dev/null +++ b/src/config/sources/current.ts @@ -0,0 +1,17 @@ +/** + * Process-level current config source. Gateway sets this at startup so + * readConfigFileSnapshot() and the config reloader use the resolved source + * (file or Nacos). Import only types from ./types.js to avoid cycles. + */ + +import type { ConfigSource } from "./types.js"; + +let currentSource: ConfigSource | null = null; + +export function setConfigSource(source: ConfigSource | null): void { + currentSource = source; +} + +export function getConfigSource(): ConfigSource | null { + return currentSource; +} diff --git a/src/config/sources/file.test.ts b/src/config/sources/file.test.ts new file mode 100644 index 00000000000..79d3306703a --- /dev/null +++ b/src/config/sources/file.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createFileConfigSource } from "./file.js"; +import * as path from "node:path"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; + +describe("createFileConfigSource", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), "openclaw-file-source-")); + }); + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("returns source with kind file and watchPath", async () => { + const configPath = path.join(tmpDir, "openclaw.json"); + await writeFile(configPath, '{"gateway":{"mode":"local"}}', "utf-8"); + const source = createFileConfigSource({ configPath, env: process.env }); + expect(source.kind).toBe("file"); + expect(source.watchPath).toBe(configPath); + const snap = await source.readSnapshot(); + expect(snap.path).toBe(configPath); + expect(snap.exists).toBe(true); + expect(snap.config?.gateway?.mode).toBe("local"); + }); + + it("readSnapshot returns valid snapshot for missing file", async () => { + const configPath = path.join(tmpDir, "openclaw.json"); + const source = createFileConfigSource({ configPath, env: process.env }); + const snap = await source.readSnapshot(); + expect(snap.exists).toBe(false); + expect(snap.valid).toBe(true); + }); +}); diff --git a/src/config/sources/file.ts b/src/config/sources/file.ts new file mode 100644 index 00000000000..6520a99c176 --- /dev/null +++ b/src/config/sources/file.ts @@ -0,0 +1,14 @@ +import type { ConfigSource } from "./types.js"; +import { createConfigIO } from "../io.js"; + +export function createFileConfigSource(opts: { + configPath: string; + env: NodeJS.ProcessEnv; +}): ConfigSource { + const io = createConfigIO({ configPath: opts.configPath, env: opts.env }); + return { + kind: "file", + watchPath: opts.configPath, + readSnapshot: () => io.readConfigFileSnapshot(), + }; +} diff --git a/src/config/sources/nacos-client.test.ts b/src/config/sources/nacos-client.test.ts new file mode 100644 index 00000000000..0692400d132 --- /dev/null +++ b/src/config/sources/nacos-client.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createNacosConfigClient } from "./nacos-client.js"; + +describe("createNacosConfigClient", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("fetchConfig returns content from Nacos server", async () => { + const client = createNacosConfigClient({ + serverAddr: "http://127.0.0.1:8848", + dataId: "openclaw.json", + group: "DEFAULT_GROUP", + fetch: vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve('{"a":1}'), + }), + }); + const content = await client.fetchConfig(); + expect(content).toBe('{"a":1}'); + }); + + it("fetchConfig includes tenant in URL when provided", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve("{}"), + }); + const client = createNacosConfigClient({ + serverAddr: "http://127.0.0.1:8848", + dataId: "openclaw.json", + group: "DEFAULT_GROUP", + tenant: "ns-1", + fetch: fetchMock, + }); + await client.fetchConfig(); + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:8848/nacos/v1/cs/configs?dataId=openclaw.json&group=DEFAULT_GROUP&tenant=ns-1", + ); + }); + + it("subscribe returns teardown and invokes callback when listener reports change", async () => { + let listenerResolve!: (value: { ok: boolean; text: () => Promise }) => void; + const listenerPromise = new Promise<{ ok: boolean; text: () => Promise }>((r) => { + listenerResolve = r; + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("{}"), + }) + .mockImplementationOnce(() => listenerPromise.then((res) => res)) + .mockResolvedValue({ ok: true, 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); + expect(typeof teardown).toBe("function"); + // Simulate Nacos long-poll return + listenerResolve({ ok: true, text: () => Promise.resolve("openclaw.json") }); + await new Promise((r) => setTimeout(r, 10)); + expect(onChange).toHaveBeenCalled(); + teardown(); + }); +}); diff --git a/src/config/sources/nacos-client.ts b/src/config/sources/nacos-client.ts new file mode 100644 index 00000000000..548fae9ef25 --- /dev/null +++ b/src/config/sources/nacos-client.ts @@ -0,0 +1,90 @@ +/** + * Nacos config client: fetch config via Open API and long-poll for changes. + * No nacos npm dependency; plain HTTP only. Uses opts.fetch for test injection. + */ + +export type NacosConfigClientOptions = { + serverAddr: string; + dataId: string; + group: string; + /** Optional tenant (namespace). */ + tenant?: string; + /** Optional fetch implementation; default globalThis.fetch. */ + fetch?: typeof globalThis.fetch; +}; + +export type NacosConfigClient = { + fetchConfig: () => Promise; + subscribe: (onChange: () => void) => () => void; +}; + +function trimTrailingSlash(s: string): string { + return s.endsWith("/") ? s.slice(0, -1) : s; +} + +/** + * Create a Nacos config client. fetchConfig GETs config content; + * subscribe starts a long-poll loop and calls onChange when the server indicates change. + */ +export function createNacosConfigClient(opts: NacosConfigClientOptions): NacosConfigClient { + const base = trimTrailingSlash(opts.serverAddr); + const doFetch = opts.fetch ?? globalThis.fetch; + + const getConfigUrl = (): string => { + const params = new URLSearchParams({ + dataId: opts.dataId, + group: opts.group, + }); + if (opts.tenant) params.set("tenant", opts.tenant); + return `${base}/nacos/v1/cs/configs?${params.toString()}`; + }; + + const listenerUrl = `${base}/nacos/v1/cs/configs/listener`; + + return { + async fetchConfig(): Promise { + const res = await doFetch(getConfigUrl()); + if (!res.ok) { + throw new Error(`Nacos get config failed: ${res.status} ${res.statusText}`); + } + return res.text(); + }, + + subscribe(onChange: () => void): () => void { + let stopped = false; + + const poll = async (): Promise => { + if (stopped) return; + const body = new URLSearchParams({ + dataId: opts.dataId, + group: opts.group, + }); + if (opts.tenant) body.set("tenant", opts.tenant); + try { + const res = await doFetch(listenerUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Long-Pulling-Timeout": "30000", + }, + body: body.toString(), + }); + if (stopped) return; + if (res.ok) { + onChange(); + } + } catch { + // Ignore errors; loop will retry or exit when stopped + } + if (!stopped) { + void poll(); + } + }; + + void poll(); + return () => { + stopped = true; + }; + }, + }; +} diff --git a/src/config/sources/nacos.test.ts b/src/config/sources/nacos.test.ts new file mode 100644 index 00000000000..d5c60bc2ca6 --- /dev/null +++ b/src/config/sources/nacos.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { createNacosConfigSource } from "./nacos.js"; +import type { NacosConfigClient } from "./nacos-client.js"; + +describe("createNacosConfigSource", () => { + it("returns source with kind nacos, watchPath null, subscribe function, and readSnapshot builds snapshot", async () => { + const mockClient: NacosConfigClient = { + fetchConfig: async () => '{"gateway":{"mode":"local"}}', + subscribe: () => () => {}, + }; + const source = createNacosConfigSource({ + serverAddr: "http://localhost:8848", + dataId: "openclaw.json", + group: "DEFAULT_GROUP", + env: process.env, + nacosClient: mockClient, + }); + + expect(source.kind).toBe("nacos"); + expect(source.watchPath).toBeNull(); + expect(typeof source.subscribe).toBe("function"); + + const snap = await source.readSnapshot(); + expect(snap.config?.gateway?.mode).toBe("local"); + expect(snap.valid).toBe(true); + expect(snap.path).toBe("nacos:openclaw.json"); + }); +}); diff --git a/src/config/sources/nacos.ts b/src/config/sources/nacos.ts new file mode 100644 index 00000000000..300ee3c227f --- /dev/null +++ b/src/config/sources/nacos.ts @@ -0,0 +1,47 @@ +/** + * Nacos config source adapter: ConfigSource backed by Nacos config API. + */ + +import type { ConfigSource } from "./types.js"; +import type { NacosConfigClient } from "./nacos-client.js"; +import { createNacosConfigClient } from "./nacos-client.js"; +import { buildSnapshotFromRaw } from "./snapshot-from-raw.js"; + +export type CreateNacosConfigSourceOptions = { + serverAddr: string; + dataId: string; + group: string; + /** Optional Nacos namespace (tenant). */ + tenant?: string; + env: NodeJS.ProcessEnv; + /** Optional client for test injection; otherwise created from serverAddr/dataId/group. */ + nacosClient?: NacosConfigClient; +}; + +/** + * Create a ConfigSource that reads config from Nacos (fetch + long-poll subscribe). + */ +export function createNacosConfigSource(opts: CreateNacosConfigSourceOptions): ConfigSource { + const client = + opts.nacosClient ?? + createNacosConfigClient({ + serverAddr: opts.serverAddr, + dataId: opts.dataId, + group: opts.group, + tenant: opts.tenant, + }); + + const path = `nacos:${opts.dataId}`; + + return { + kind: "nacos", + watchPath: null, + async readSnapshot() { + const content = await client.fetchConfig(); + return buildSnapshotFromRaw(content, path, { env: opts.env }); + }, + subscribe(onChange: () => void): () => void { + return client.subscribe(onChange); + }, + }; +} diff --git a/src/config/sources/resolve.test.ts b/src/config/sources/resolve.test.ts new file mode 100644 index 00000000000..d08dd79c9c9 --- /dev/null +++ b/src/config/sources/resolve.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { resolveConfigSource } from "./resolve.js"; + +describe("resolveConfigSource", () => { + it("returns file source when OPENCLAW_CONFIG_SOURCE is unset", () => { + const source = resolveConfigSource({}); + expect(source.kind).toBe("file"); + expect(source.watchPath).toBeDefined(); + }); + + it("returns file source when OPENCLAW_CONFIG_SOURCE is not nacos", () => { + const source = resolveConfigSource({ + OPENCLAW_CONFIG_SOURCE: "file", + } as NodeJS.ProcessEnv); + expect(source.kind).toBe("file"); + expect(source.watchPath).toBeDefined(); + }); + + it("returns nacos source when OPENCLAW_CONFIG_SOURCE=nacos and Nacos env set", () => { + const env = { + OPENCLAW_CONFIG_SOURCE: "nacos", + NACOS_SERVER_ADDR: "http://nacos:8848", + NACOS_DATA_ID: "openclaw.json", + NACOS_GROUP: "DEFAULT_GROUP", + }; + const source = resolveConfigSource(env as NodeJS.ProcessEnv); + expect(source.kind).toBe("nacos"); + expect(source.watchPath).toBeNull(); + }); + + it("returns file source when OPENCLAW_CONFIG_SOURCE=nacos but Nacos env vars missing", () => { + const source = resolveConfigSource({ + OPENCLAW_CONFIG_SOURCE: "nacos", + } as NodeJS.ProcessEnv); + expect(source.kind).toBe("file"); + expect(source.watchPath).toBeDefined(); + }); +}); diff --git a/src/config/sources/resolve.ts b/src/config/sources/resolve.ts new file mode 100644 index 00000000000..c0bb8f2d6cc --- /dev/null +++ b/src/config/sources/resolve.ts @@ -0,0 +1,35 @@ +/** + * Resolve the active config source from environment (file vs Nacos). + * Avoid importing from ../io.js to prevent cycles: resolve → file → io. + */ + +import { resolveConfigPath } from "../paths.js"; +import { createFileConfigSource } from "./file.js"; +import { createNacosConfigSource } from "./nacos.js"; +import type { ConfigSource } from "./types.js"; + +const DEFAULT_NACOS_GROUP = "DEFAULT_GROUP"; + +function hasNacosEnv(env: NodeJS.ProcessEnv): boolean { + const serverAddr = env.NACOS_SERVER_ADDR?.trim(); + const dataId = env.NACOS_DATA_ID?.trim(); + return Boolean(serverAddr && dataId); +} + +/** + * Resolve config source from environment. + * - If OPENCLAW_CONFIG_SOURCE=nacos and NACOS_SERVER_ADDR, NACOS_DATA_ID are set, + * returns Nacos source (NACOS_GROUP optional, default DEFAULT_GROUP). + * - Otherwise returns file source using resolveConfigPath(env). + */ +export function resolveConfigSource(env: NodeJS.ProcessEnv): ConfigSource { + if (env.OPENCLAW_CONFIG_SOURCE === "nacos" && hasNacosEnv(env)) { + const serverAddr = env.NACOS_SERVER_ADDR!.trim(); + const dataId = env.NACOS_DATA_ID!.trim(); + const group = env.NACOS_GROUP?.trim() || DEFAULT_NACOS_GROUP; + const tenant = env.NACOS_NAMESPACE?.trim() || undefined; + return createNacosConfigSource({ serverAddr, dataId, group, tenant, env }); + } + const configPath = resolveConfigPath(env); + return createFileConfigSource({ configPath, env }); +} diff --git a/src/config/sources/snapshot-from-raw.test.ts b/src/config/sources/snapshot-from-raw.test.ts new file mode 100644 index 00000000000..8c1cacbc8ad --- /dev/null +++ b/src/config/sources/snapshot-from-raw.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { buildSnapshotFromRaw } from "./snapshot-from-raw.js"; + +describe("buildSnapshotFromRaw", () => { + it("produces valid ConfigFileSnapshot from JSON string", async () => { + const raw = '{"gateway":{"mode":"local"}}'; + const snap = await buildSnapshotFromRaw(raw, "nacos:openclaw.json", { + env: process.env, + }); + expect(snap.path).toBe("nacos:openclaw.json"); + expect(snap.exists).toBe(true); + expect(snap.raw).toBe(raw); + expect(snap.valid).toBe(true); + expect(snap.config?.gateway?.mode).toBe("local"); + }); +}); diff --git a/src/config/sources/snapshot-from-raw.ts b/src/config/sources/snapshot-from-raw.ts new file mode 100644 index 00000000000..5bd2ee935e6 --- /dev/null +++ b/src/config/sources/snapshot-from-raw.ts @@ -0,0 +1,143 @@ +/** + * Build a ConfigFileSnapshot from a raw config string (e.g. from Nacos). + * Uses the same parse/validate/defaults pipeline as file-based config; $include + * resolution is skipped (Nacos mode does not support includes). + */ + +import crypto from "node:crypto"; +import JSON5 from "json5"; +import { + applyTalkApiKey, + applyTalkConfigNormalization, + applyModelDefaults, + applyCompactionDefaults, + applyContextPruningDefaults, + applyAgentDefaults, + applySessionDefaults, + applyLoggingDefaults, + applyMessageDefaults, +} from "../defaults.js"; +import { applyConfigEnvVars } from "../env-vars.js"; +import type { EnvSubstitutionWarning } from "../env-substitution.js"; +import { resolveConfigEnvVars } from "../env-substitution.js"; +import { findLegacyConfigIssues } from "../legacy.js"; +import { normalizeConfigPaths } from "../normalize-paths.js"; +import { normalizeExecSafeBinProfilesInConfig } from "../normalize-exec-safe-bin.js"; +import type { ConfigFileSnapshot, LegacyConfigIssue, OpenClawConfig } from "../types.js"; +import { validateConfigObjectWithPlugins } from "../validation.js"; +import { parseConfigJson5 } from "../io.js"; + +function hashConfigRaw(raw: string | null): string { + return crypto.createHash("sha256").update(raw ?? "").digest("hex"); +} + +function coerceConfig(value: unknown): OpenClawConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return value as OpenClawConfig; +} + +export type BuildSnapshotFromRawOptions = { + env: NodeJS.ProcessEnv; +}; + +/** + * Build a ConfigFileSnapshot from a raw JSON5 string and virtual path. + * Same parsing/validation/defaults chain as file-based config; $include is not resolved. + */ +export async function buildSnapshotFromRaw( + raw: string, + path: string, + opts: BuildSnapshotFromRawOptions, +): Promise { + const hash = hashConfigRaw(raw); + const parsedRes = parseConfigJson5(raw, JSON5); + if (!parsedRes.ok) { + return { + path, + exists: true, + raw, + parsed: {}, + resolved: {}, + valid: false, + config: {}, + hash, + issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }], + warnings: [], + legacyIssues: [], + }; + } + + // Nacos: no $include resolution; use parsed as resolved input for env substitution. + const envWarnings: EnvSubstitutionWarning[] = []; + const envCopy = { ...opts.env }; + if ( + parsedRes.parsed && + typeof parsedRes.parsed === "object" && + "env" in (parsedRes.parsed as object) + ) { + applyConfigEnvVars(parsedRes.parsed as OpenClawConfig, envCopy); + } + const resolvedConfigRaw = resolveConfigEnvVars(parsedRes.parsed, envCopy, { + onMissing: (w) => envWarnings.push(w), + }); + + const envVarWarnings = envWarnings.map((w) => ({ + path: w.configPath, + message: `Missing env var "${w.varName}" — feature using this value will be unavailable`, + })); + + const legacyIssues: LegacyConfigIssue[] = findLegacyConfigIssues( + resolvedConfigRaw, + parsedRes.parsed, + ); + + const validated = validateConfigObjectWithPlugins(resolvedConfigRaw); + if (!validated.ok) { + return { + path, + exists: true, + raw, + parsed: parsedRes.parsed, + resolved: coerceConfig(resolvedConfigRaw), + valid: false, + config: coerceConfig(resolvedConfigRaw), + hash, + issues: validated.issues, + warnings: [...validated.warnings, ...envVarWarnings], + legacyIssues, + }; + } + + const snapshotConfig = normalizeConfigPaths( + applyTalkApiKey( + applyTalkConfigNormalization( + applyModelDefaults( + applyCompactionDefaults( + applyContextPruningDefaults( + applyAgentDefaults( + applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))), + ), + ), + ), + ), + ), + ), + ); + normalizeExecSafeBinProfilesInConfig(snapshotConfig); + + return { + path, + exists: true, + raw, + parsed: parsedRes.parsed, + resolved: coerceConfig(resolvedConfigRaw), + valid: true, + config: snapshotConfig, + hash, + issues: [], + warnings: [...validated.warnings, ...envVarWarnings], + legacyIssues, + }; +} diff --git a/src/config/sources/types.test.ts b/src/config/sources/types.test.ts new file mode 100644 index 00000000000..69501153df6 --- /dev/null +++ b/src/config/sources/types.test.ts @@ -0,0 +1,21 @@ +// src/config/sources/types.test.ts +import { describe, it, expect } from "vitest"; +import type { ConfigSource, ConfigSourceKind } from "./types.js"; + +describe("ConfigSource types", () => { + it("ConfigSourceKind is file | nacos", () => { + const k: ConfigSourceKind = "file"; + expect(k).toBe("file"); + const n: ConfigSourceKind = "nacos"; + expect(n).toBe("nacos"); + }); + + it("ConfigSource has readSnapshot and optional subscribe", () => { + const source: ConfigSource = { + kind: "file", + readSnapshot: async () => ({} as never), + }; + expect(source.kind).toBe("file"); + expect(typeof source.readSnapshot).toBe("function"); + }); +}); diff --git a/src/config/sources/types.ts b/src/config/sources/types.ts new file mode 100644 index 00000000000..f37b1dec23a --- /dev/null +++ b/src/config/sources/types.ts @@ -0,0 +1,10 @@ +import type { ConfigFileSnapshot } from "../types.js"; + +export type ConfigSourceKind = "file" | "nacos"; + +export type ConfigSource = { + kind: ConfigSourceKind; + readSnapshot: () => Promise; + subscribe?: (onChange: () => void) => () => void; + watchPath?: string | null; +}; diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 9c4994541e9..fb6b8ed3451 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -415,4 +415,48 @@ describe("startGatewayConfigReloader", () => { await reloader.stop(); } }); + + it("uses subscribe when provided (no chokidar), runs reload on onChange, teardown stops subscription", async () => { + const chokidarWatch = vi.spyOn(chokidar, "watch"); + const nextConfig = { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: true } }; + const readSnapshot = vi + .fn<() => Promise>() + .mockResolvedValue(makeSnapshot({ config: nextConfig })); + let savedOnChange: (() => void) | null = null; + const teardown = vi.fn(); + const subscribe = vi.fn((onChange: () => void) => { + savedOnChange = onChange; + return teardown; + }); + const onHotReload = vi.fn(async () => {}); + const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + const reloader = startGatewayConfigReloader({ + initialConfig: {}, + readSnapshot, + onHotReload, + onRestart: vi.fn(), + log, + subscribe, + }); + + expect(chokidarWatch).not.toHaveBeenCalled(); + expect(savedOnChange).not.toBeNull(); + savedOnChange!(); + await vi.runOnlyPendingTimersAsync(); + + expect(readSnapshot).toHaveBeenCalledTimes(1); + expect(onHotReload).toHaveBeenCalledTimes(1); + expect(onHotReload).toHaveBeenCalledWith( + expect.objectContaining({ restartGateway: false }), + nextConfig, + ); + + await reloader.stop(); + expect(teardown).toHaveBeenCalledTimes(1); + const readCountBefore = readSnapshot.mock.calls.length; + savedOnChange!(); + await vi.runOnlyPendingTimersAsync(); + expect(readSnapshot.mock.calls.length).toBe(readCountBefore); + }); }); diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 3887548e51b..548d3649b0d 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -79,8 +79,14 @@ export function startGatewayConfigReloader(opts: { warn: (msg: string) => void; error: (msg: string) => void; }; - watchPath: string; + /** File-based reload: path to watch. Omit when using subscribe. */ + watchPath?: string; + /** Subscribe-based reload (e.g. Nacos): call onChange when config may have changed; return teardown. */ + subscribe?: (onChange: () => void) => () => void; }): GatewayConfigReloader { + if (opts.subscribe == null && opts.watchPath == null) { + throw new Error("startGatewayConfigReloader: provide watchPath or subscribe"); + } let currentConfig = opts.initialConfig; let settings = resolveGatewayReloadSettings(currentConfig); let debounceTimer: ReturnType | null = null; @@ -89,6 +95,7 @@ export function startGatewayConfigReloader(opts: { let stopped = false; let restartQueued = false; let missingConfigRetries = 0; + let teardownSubscribe: (() => void) | null = null; const scheduleAfter = (wait: number) => { if (stopped) { @@ -214,34 +221,46 @@ export function startGatewayConfigReloader(opts: { } }; - const watcher = chokidar.watch(opts.watchPath, { - ignoreInitial: true, - awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }, - usePolling: Boolean(process.env.VITEST), - }); - - watcher.on("add", schedule); - watcher.on("change", schedule); - watcher.on("unlink", schedule); + let watcher: ReturnType | null = null; let watcherClosed = false; - watcher.on("error", (err) => { - if (watcherClosed) { - return; - } - watcherClosed = true; - opts.log.warn(`config watcher error: ${String(err)}`); - void watcher.close().catch(() => {}); - }); + + if (opts.subscribe != null) { + teardownSubscribe = opts.subscribe(schedule); + } else { + const watchPath = opts.watchPath as string; + watcher = chokidar.watch(watchPath, { + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }, + usePolling: Boolean(process.env.VITEST), + }); + watcher.on("add", schedule); + watcher.on("change", schedule); + watcher.on("unlink", schedule); + watcher.on("error", (err) => { + if (watcherClosed) { + return; + } + watcherClosed = true; + opts.log.warn(`config watcher error: ${String(err)}`); + void watcher!.close().catch(() => {}); + }); + } return { stop: async () => { stopped = true; + if (teardownSubscribe != null) { + teardownSubscribe(); + teardownSubscribe = null; + } if (debounceTimer) { clearTimeout(debounceTimer); } debounceTimer = null; - watcherClosed = true; - await watcher.close().catch(() => {}); + if (watcher != null) { + watcherClosed = true; + await watcher.close().catch(() => {}); + } }, }; } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 7a4c18b6593..56d4b40c3b6 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -17,8 +17,11 @@ import { loadConfig, migrateLegacyConfig, readConfigFileSnapshot, + setRuntimeConfigSnapshot, writeConfigFile, } from "../config/config.js"; +import { getConfigSource, setConfigSource } from "../config/sources/current.js"; +import { resolveConfigSource } from "../config/sources/resolve.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { resolveMainSessionKey } from "../config/sessions.js"; @@ -75,6 +78,7 @@ import { runSetupWizard } from "../wizard/setup.js"; import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js"; import { startChannelHealthMonitor } from "./channel-health-monitor.js"; import { startGatewayConfigReloader } from "./config-reload.js"; +import type { GatewayReloadPlan } from "./config-reload-plan.js"; import type { ControlUiRootState } from "./control-ui.js"; import { GATEWAY_EVENT_UPDATE_AVAILABLE, @@ -377,6 +381,8 @@ export async function startGatewayServer( description: "raw stream log path override", }); + setConfigSource(resolveConfigSource(process.env)); + let configSnapshot = await readConfigFileSnapshot(); if (configSnapshot.legacyIssues.length > 0) { if (isNixMode) { @@ -546,6 +552,12 @@ 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); + } + initSubagentRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); @@ -1219,6 +1231,10 @@ export async function startGatewayServer( const configReloader = minimalTestGateway ? { stop: async () => {} } : (() => { + const source = getConfigSource(); + if (!source) { + throw new Error("gateway config reloader: config source not set"); + } const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({ deps, broadcast, @@ -1264,10 +1280,10 @@ export async function startGatewayServer( }), }); - return startGatewayConfigReloader({ + const reloaderOpts = { initialConfig: cfgAtStart, - readSnapshot: readConfigFileSnapshot, - onHotReload: async (plan, nextConfig) => { + readSnapshot: () => source.readSnapshot(), + onHotReload: async (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => { const previousSnapshot = getActiveSecretsRuntimeSnapshot(); const prepared = await activateRuntimeSecrets(nextConfig, { reason: "reload", @@ -1275,6 +1291,9 @@ export async function startGatewayServer( }); try { await applyHotReload(plan, prepared.config); + if (getConfigSource()?.kind === "nacos") { + setRuntimeConfigSnapshot(prepared.config); + } } catch (err) { if (previousSnapshot) { activateSecretsRuntimeSnapshot(previousSnapshot); @@ -1284,17 +1303,21 @@ export async function startGatewayServer( throw err; } }, - onRestart: async (plan, nextConfig) => { + onRestart: async (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => { await activateRuntimeSecrets(nextConfig, { reason: "restart-check", activate: false }); requestGatewayRestart(plan, nextConfig); }, log: { - info: (msg) => logReload.info(msg), - warn: (msg) => logReload.warn(msg), - error: (msg) => logReload.error(msg), + info: (msg: string) => logReload.info(msg), + warn: (msg: string) => logReload.warn(msg), + error: (msg: string) => logReload.error(msg), }, - watchPath: configSnapshot.path, - }); + }; + if (source.subscribe != null) { + return startGatewayConfigReloader({ ...reloaderOpts, subscribe: source.subscribe }); + } + const watchPath = source.watchPath ?? configSnapshot.path; + return startGatewayConfigReloader({ ...reloaderOpts, watchPath }); })(); const close = createGatewayCloseHandler({ From 0f8069a6771305641de3485d1e5abc80f76b17ec Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Wed, 18 Mar 2026 22:30:06 +0800 Subject: [PATCH 2/8] style: oxfmt fixes (import order, markdown wrap) Made-with: Cursor --- docs/gateway/configuration.md | 8 +++++++- docs/help/environment.md | 3 ++- src/config/io.ts | 4 ++-- src/config/sources/file.test.ts | 8 ++++---- src/config/sources/file.ts | 2 +- src/config/sources/nacos.test.ts | 2 +- src/config/sources/nacos.ts | 2 +- src/config/sources/snapshot-from-raw.ts | 12 ++++++------ src/config/sources/types.test.ts | 3 +-- 9 files changed, 25 insertions(+), 19 deletions(-) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 298a5220427..a05f503c26f 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -641,7 +641,13 @@ Set these environment variables: | `NACOS_GROUP` | No | Nacos group (default: `DEFAULT_GROUP`). | | `NACOS_NAMESPACE` | No | Nacos namespace (tenant); omit for default namespace. | -When `OPENCLAW_CONFIG_SOURCE=nacos`, the gateway fetches the config from Nacos at startup. No config file is written on disk; the in-memory config is updated by Nacos long-polling. Hot reload is driven by Nacos (changes in Nacos are applied without restart). Config read RPC (`config.get`) uses the in-memory config. Config write RPC (`config.apply`, `config.patch`) and CLI `config set` update only the in-memory snapshot (no write to disk or to Nacos). To change config persistently when using Nacos, update the config in Nacos and rely on hot reload. +When `OPENCLAW_CONFIG_SOURCE=nacos`, the gateway fetches the config from Nacos at +startup. No config file is written on disk; the in-memory config is updated by +Nacos long-polling. Hot reload is driven by Nacos (changes in Nacos are applied +without restart). Config read RPC (`config.get`) uses the in-memory config. Config +write RPC (`config.apply`, `config.patch`) and CLI `config set` update only the +in-memory snapshot (no write to disk or to Nacos). To change config persistently +when using Nacos, update the config in Nacos and rely on hot reload. See [Environment](/help/environment#config-source-nacos) for these variables in the env reference. diff --git a/docs/help/environment.md b/docs/help/environment.md index 51aae1948df..7a33d55fdf5 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -121,7 +121,8 @@ When loading config from Nacos instead of a local file (e.g. in Kubernetes pods | `NACOS_GROUP` | No | Nacos group (default: `DEFAULT_GROUP`). | | `NACOS_NAMESPACE` | No | Nacos namespace (tenant); omit for default namespace. | -Config is kept only in memory; hot reload is driven by Nacos long-polling. See [Configuration: Config source: Nacos](/gateway/configuration#config-source-nacos). +Config is kept only in memory; hot reload is driven by Nacos long-polling. See +[Configuration: Config source: Nacos](/gateway/configuration#config-source-nacos). ## Logging diff --git a/src/config/io.ts b/src/config/io.ts index 5704406be4c..6a5fbd697f2 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -18,15 +18,15 @@ import { VERSION } from "../version.js"; import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js"; import { maintainConfigBackups } from "./backup-rotation.js"; import { + applyAgentDefaults, applyCompactionDefaults, applyContextPruningDefaults, - applyAgentDefaults, applyLoggingDefaults, applyMessageDefaults, applyModelDefaults, applySessionDefaults, - applyTalkConfigNormalization, applyTalkApiKey, + applyTalkConfigNormalization, } from "./defaults.js"; import { restoreEnvVarRefs } from "./env-preserve.js"; import { diff --git a/src/config/sources/file.test.ts b/src/config/sources/file.test.ts index 79d3306703a..3b02b8ec8d2 100644 --- a/src/config/sources/file.test.ts +++ b/src/config/sources/file.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { createFileConfigSource } from "./file.js"; -import * as path from "node:path"; -import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createFileConfigSource } from "./file.js"; describe("createFileConfigSource", () => { let tmpDir: string; diff --git a/src/config/sources/file.ts b/src/config/sources/file.ts index 6520a99c176..764f82e5109 100644 --- a/src/config/sources/file.ts +++ b/src/config/sources/file.ts @@ -1,5 +1,5 @@ -import type { ConfigSource } from "./types.js"; import { createConfigIO } from "../io.js"; +import type { ConfigSource } from "./types.js"; export function createFileConfigSource(opts: { configPath: string; diff --git a/src/config/sources/nacos.test.ts b/src/config/sources/nacos.test.ts index d5c60bc2ca6..aa15d7d8659 100644 --- a/src/config/sources/nacos.test.ts +++ b/src/config/sources/nacos.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { createNacosConfigSource } from "./nacos.js"; import type { NacosConfigClient } from "./nacos-client.js"; diff --git a/src/config/sources/nacos.ts b/src/config/sources/nacos.ts index 300ee3c227f..fc3fa62a5ae 100644 --- a/src/config/sources/nacos.ts +++ b/src/config/sources/nacos.ts @@ -2,10 +2,10 @@ * Nacos config source adapter: ConfigSource backed by Nacos config API. */ -import type { ConfigSource } from "./types.js"; import type { NacosConfigClient } from "./nacos-client.js"; import { createNacosConfigClient } from "./nacos-client.js"; import { buildSnapshotFromRaw } from "./snapshot-from-raw.js"; +import type { ConfigSource } from "./types.js"; export type CreateNacosConfigSourceOptions = { serverAddr: string; diff --git a/src/config/sources/snapshot-from-raw.ts b/src/config/sources/snapshot-from-raw.ts index 5bd2ee935e6..73cc1cb316a 100644 --- a/src/config/sources/snapshot-from-raw.ts +++ b/src/config/sources/snapshot-from-raw.ts @@ -7,25 +7,25 @@ import crypto from "node:crypto"; import JSON5 from "json5"; import { - applyTalkApiKey, - applyTalkConfigNormalization, - applyModelDefaults, + applyAgentDefaults, applyCompactionDefaults, applyContextPruningDefaults, - applyAgentDefaults, - applySessionDefaults, applyLoggingDefaults, applyMessageDefaults, + applyModelDefaults, + applySessionDefaults, + applyTalkApiKey, + applyTalkConfigNormalization, } from "../defaults.js"; import { applyConfigEnvVars } from "../env-vars.js"; import type { EnvSubstitutionWarning } from "../env-substitution.js"; import { resolveConfigEnvVars } from "../env-substitution.js"; +import { parseConfigJson5 } from "../io.js"; import { findLegacyConfigIssues } from "../legacy.js"; import { normalizeConfigPaths } from "../normalize-paths.js"; import { normalizeExecSafeBinProfilesInConfig } from "../normalize-exec-safe-bin.js"; import type { ConfigFileSnapshot, LegacyConfigIssue, OpenClawConfig } from "../types.js"; import { validateConfigObjectWithPlugins } from "../validation.js"; -import { parseConfigJson5 } from "../io.js"; function hashConfigRaw(raw: string | null): string { return crypto.createHash("sha256").update(raw ?? "").digest("hex"); diff --git a/src/config/sources/types.test.ts b/src/config/sources/types.test.ts index 69501153df6..10902f92242 100644 --- a/src/config/sources/types.test.ts +++ b/src/config/sources/types.test.ts @@ -1,5 +1,4 @@ -// src/config/sources/types.test.ts -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import type { ConfigSource, ConfigSourceKind } from "./types.js"; describe("ConfigSource types", () => { From 4d2d02e59cf5da627b4de12dc49a287cec5fc59f Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Wed, 18 Mar 2026 22:39:02 +0800 Subject: [PATCH 3/8] docs: oxfmt table alignment and single-line paragraphs Made-with: Cursor --- docs/gateway/configuration.md | 22 ++++++++-------------- docs/help/environment.md | 3 +-- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index a05f503c26f..23599d3f611 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -633,21 +633,15 @@ You can load configuration from [Nacos](https://nacos.io/) instead of a local fi Set these environment variables: -| Variable | Required | Purpose | -| -------- | -------- | ------- | -| `OPENCLAW_CONFIG_SOURCE` | Yes | Set to `nacos` to use Nacos; omit or set to `file` for the default file-based config. | -| `NACOS_SERVER_ADDR` | Yes | Nacos server address (e.g. `http://nacos:8848`). | -| `NACOS_DATA_ID` | Yes | Data ID of the config (e.g. `openclaw.json`). | -| `NACOS_GROUP` | No | Nacos group (default: `DEFAULT_GROUP`). | -| `NACOS_NAMESPACE` | No | Nacos namespace (tenant); omit for default namespace. | +| Variable | Required | Purpose | +| ------------------------ | -------- | ----------------------------------------------------------------------- | +| `OPENCLAW_CONFIG_SOURCE` | Yes | Set to `nacos` to use Nacos; omit or set to `file` for the default file-based config. | +| `NACOS_SERVER_ADDR` | Yes | Nacos server address (e.g. `http://nacos:8848`). | +| `NACOS_DATA_ID` | Yes | Data ID of the config (e.g. `openclaw.json`). | +| `NACOS_GROUP` | No | Nacos group (default: `DEFAULT_GROUP`). | +| `NACOS_NAMESPACE` | No | Nacos namespace (tenant); omit for default namespace. | -When `OPENCLAW_CONFIG_SOURCE=nacos`, the gateway fetches the config from Nacos at -startup. No config file is written on disk; the in-memory config is updated by -Nacos long-polling. Hot reload is driven by Nacos (changes in Nacos are applied -without restart). Config read RPC (`config.get`) uses the in-memory config. Config -write RPC (`config.apply`, `config.patch`) and CLI `config set` update only the -in-memory snapshot (no write to disk or to Nacos). To change config persistently -when using Nacos, update the config in Nacos and rely on hot reload. +When `OPENCLAW_CONFIG_SOURCE=nacos`, the gateway fetches the config from Nacos at startup. No config file is written on disk; the in-memory config is updated by Nacos long-polling. Hot reload is driven by Nacos (changes in Nacos are applied without restart). Config read RPC (`config.get`) uses the in-memory config. Config write RPC (`config.apply`, `config.patch`) and CLI `config set` update only the in-memory snapshot (no write to disk or to Nacos). To change config persistently when using Nacos, update the config in Nacos and rely on hot reload. See [Environment](/help/environment#config-source-nacos) for these variables in the env reference. diff --git a/docs/help/environment.md b/docs/help/environment.md index 7a33d55fdf5..51aae1948df 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -121,8 +121,7 @@ When loading config from Nacos instead of a local file (e.g. in Kubernetes pods | `NACOS_GROUP` | No | Nacos group (default: `DEFAULT_GROUP`). | | `NACOS_NAMESPACE` | No | Nacos namespace (tenant); omit for default namespace. | -Config is kept only in memory; hot reload is driven by Nacos long-polling. See -[Configuration: Config source: Nacos](/gateway/configuration#config-source-nacos). +Config is kept only in memory; hot reload is driven by Nacos long-polling. See [Configuration: Config source: Nacos](/gateway/configuration#config-source-nacos). ## Logging From 6f938c3a9965b788a8e8f9fe1d253af0919843e0 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Wed, 18 Mar 2026 22:54:11 +0800 Subject: [PATCH 4/8] config: address Nacos PR review (listener body, onChange-on-change-only, backoff, runtime refresh, dotenv) Made-with: Cursor --- src/config/io.ts | 51 +++++++++++++++++++++++++++++- src/config/sources/nacos-client.ts | 27 +++++++++++----- src/gateway/server.impl.ts | 2 ++ 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/config/io.ts b/src/config/io.ts index 6a5fbd697f2..63f1f5b2792 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1497,8 +1497,32 @@ export async function readBestEffortConfig(): Promise { return snapshot.valid ? loadConfig() : snapshot.config; } +function buildSnapshotFromRuntimeSnapshots(path: string): ConfigFileSnapshot | null { + if (!runtimeConfigSnapshot) return null; + const config = runtimeConfigSnapshot; + const resolved = runtimeConfigSourceSnapshot ?? config; + const raw = JSON.stringify(config); + return { + path, + exists: true, + raw, + parsed: config as unknown, + resolved: resolved as unknown, + valid: true, + config, + hash: hashConfigRaw(raw), + issues: [], + warnings: [], + legacyIssues: [], + }; +} + export async function readConfigFileSnapshot(): Promise { const source = getConfigSource(); + if (source?.kind === "nacos") { + const fromRuntime = buildSnapshotFromRuntimeSnapshots("nacos:openclaw.json"); + if (fromRuntime) return fromRuntime; + } if (source !== null) { return source.readSnapshot(); } @@ -1508,6 +1532,13 @@ export async function readConfigFileSnapshot(): Promise { export async function readConfigFileSnapshotForWrite(): Promise { const source = getConfigSource(); if (source?.kind === "nacos") { + const fromRuntime = buildSnapshotFromRuntimeSnapshots("nacos:openclaw.json"); + if (fromRuntime) { + return { + snapshot: fromRuntime, + writeOptions: { expectedConfigPath: fromRuntime.path }, + }; + } const snapshot = await source.readSnapshot(); return { snapshot, @@ -1530,7 +1561,25 @@ export async function writeConfigFile( const issueMessage = issue?.message ?? "invalid"; throw new Error(formatConfigValidationFailure(pathLabel, issueMessage)); } - setRuntimeConfigSnapshot(cfg); + setRuntimeConfigSnapshot(cfg, cfg); + const refreshHandler = runtimeConfigSnapshotRefreshHandler; + if (refreshHandler) { + try { + const refreshed = await refreshHandler.refresh({ sourceConfig: cfg }); + if (refreshed) return; + } catch (error) { + try { + refreshHandler.clearOnRefreshFailure?.(); + } catch { + // Keep the original refresh failure as the surfaced error. + } + const detail = error instanceof Error ? error.message : String(error); + throw new ConfigRuntimeRefreshError( + `Config was written (Nacos in-memory), but runtime snapshot refresh failed: ${detail}`, + { cause: error }, + ); + } + } return; } const io = createConfigIO(); diff --git a/src/config/sources/nacos-client.ts b/src/config/sources/nacos-client.ts index 548fae9ef25..eccbb6d3963 100644 --- a/src/config/sources/nacos-client.ts +++ b/src/config/sources/nacos-client.ts @@ -1,8 +1,11 @@ /** * Nacos config client: fetch config via Open API and long-poll for changes. * No nacos npm dependency; plain HTTP only. Uses opts.fetch for test injection. + * Listener uses Nacos v1 format: Listening-Configs=%02%02%02%01 */ +import crypto from "node:crypto"; + export type NacosConfigClientOptions = { serverAddr: string; dataId: string; @@ -41,25 +44,32 @@ export function createNacosConfigClient(opts: NacosConfigClientOptions): NacosCo const listenerUrl = `${base}/nacos/v1/cs/configs/listener`; + // MD5 of last-fetched content; used by listener so Nacos can compare and hold connection until change. + let lastContentMD5 = ""; + return { async fetchConfig(): Promise { const res = await doFetch(getConfigUrl()); if (!res.ok) { throw new Error(`Nacos get config failed: ${res.status} ${res.statusText}`); } - return res.text(); + const text = await res.text(); + lastContentMD5 = crypto.createHash("md5").update(text).digest("hex"); + return text; }, subscribe(onChange: () => void): () => void { let stopped = false; + const STX = "\x02"; + const SOH = "\x01"; const poll = async (): Promise => { if (stopped) return; - const body = new URLSearchParams({ - dataId: opts.dataId, - group: opts.group, - }); - if (opts.tenant) body.set("tenant", opts.tenant); + // Nacos v1 listener expects Listening-Configs=%02%02%02%01 + const tenant = opts.tenant ?? ""; + const listeningConfigs = + `${opts.dataId}${STX}${opts.group}${STX}${lastContentMD5}${STX}${tenant}${SOH}`; + const body = new URLSearchParams({ "Listening-Configs": listeningConfigs }); try { const res = await doFetch(listenerUrl, { method: "POST", @@ -71,10 +81,11 @@ export function createNacosConfigClient(opts: NacosConfigClientOptions): NacosCo }); if (stopped) return; if (res.ok) { - onChange(); + const text = await res.text(); + if (text.trim()) onChange(); } } catch { - // Ignore errors; loop will retry or exit when stopped + await new Promise((r) => setTimeout(r, 5000)); } if (!stopped) { void poll(); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 56d4b40c3b6..ba12fd934b5 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -32,6 +32,7 @@ import { resolveControlUiRootOverrideSync, resolveControlUiRootSync, } from "../infra/control-ui-assets.js"; +import { loadDotEnv } from "../infra/dotenv.js"; import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; import { logAcceptedEnvOption } from "../infra/env.js"; import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js"; @@ -381,6 +382,7 @@ export async function startGatewayServer( description: "raw stream log path override", }); + loadDotEnv({ quiet: true }); setConfigSource(resolveConfigSource(process.env)); let configSnapshot = await readConfigFileSnapshot(); From 005ce64c2d746ecdeedb98cb322f54b929dd4645 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Thu, 19 Mar 2026 10:15:08 +0800 Subject: [PATCH 5/8] config: refresh Nacos listener MD5 on change before repolling Made-with: Cursor --- src/config/sources/nacos-client.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/config/sources/nacos-client.ts b/src/config/sources/nacos-client.ts index eccbb6d3963..d4ea049c983 100644 --- a/src/config/sources/nacos-client.ts +++ b/src/config/sources/nacos-client.ts @@ -82,7 +82,18 @@ export function createNacosConfigClient(opts: NacosConfigClientOptions): NacosCo if (stopped) return; if (res.ok) { const text = await res.text(); - if (text.trim()) onChange(); + if (text.trim()) { + try { + const getRes = await doFetch(getConfigUrl()); + if (getRes.ok) { + const content = await getRes.text(); + lastContentMD5 = crypto.createHash("md5").update(content).digest("hex"); + } + } catch { + // Keep stale MD5; next poll may get same change again + } + onChange(); + } } } catch { await new Promise((r) => setTimeout(r, 5000)); From 7bae3314beb3b7af0af8674dc958ba5f9cf08258 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Thu, 19 Mar 2026 10:47:43 +0800 Subject: [PATCH 6/8] =?UTF-8?q?config:=20fix=20CI=20=E2=80=94=20oxfmt=20fo?= =?UTF-8?q?rmat=20(8=20files)=20and=20io.ts=20OpenClawConfig=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- docs/gateway/configuration.md | 12 ++++++------ docs/help/environment.md | 14 +++++++------- src/config/io.ts | 6 +++--- src/config/sources/nacos-client.ts | 3 +-- src/config/sources/nacos.test.ts | 2 +- src/config/sources/snapshot-from-raw.ts | 9 ++++++--- src/config/sources/types.test.ts | 2 +- src/gateway/server.impl.ts | 8 ++++---- 8 files changed, 29 insertions(+), 27 deletions(-) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 23599d3f611..c3de772ed21 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -633,13 +633,13 @@ You can load configuration from [Nacos](https://nacos.io/) instead of a local fi Set these environment variables: -| Variable | Required | Purpose | -| ------------------------ | -------- | ----------------------------------------------------------------------- | +| Variable | Required | Purpose | +| ------------------------ | -------- | ------------------------------------------------------------------------------------- | | `OPENCLAW_CONFIG_SOURCE` | Yes | Set to `nacos` to use Nacos; omit or set to `file` for the default file-based config. | -| `NACOS_SERVER_ADDR` | Yes | Nacos server address (e.g. `http://nacos:8848`). | -| `NACOS_DATA_ID` | Yes | Data ID of the config (e.g. `openclaw.json`). | -| `NACOS_GROUP` | No | Nacos group (default: `DEFAULT_GROUP`). | -| `NACOS_NAMESPACE` | No | Nacos namespace (tenant); omit for default namespace. | +| `NACOS_SERVER_ADDR` | Yes | Nacos server address (e.g. `http://nacos:8848`). | +| `NACOS_DATA_ID` | Yes | Data ID of the config (e.g. `openclaw.json`). | +| `NACOS_GROUP` | No | Nacos group (default: `DEFAULT_GROUP`). | +| `NACOS_NAMESPACE` | No | Nacos namespace (tenant); omit for default namespace. | When `OPENCLAW_CONFIG_SOURCE=nacos`, the gateway fetches the config from Nacos at startup. No config file is written on disk; the in-memory config is updated by Nacos long-polling. Hot reload is driven by Nacos (changes in Nacos are applied without restart). Config read RPC (`config.get`) uses the in-memory config. Config write RPC (`config.apply`, `config.patch`) and CLI `config set` update only the in-memory snapshot (no write to disk or to Nacos). To change config persistently when using Nacos, update the config in Nacos and rely on hot reload. diff --git a/docs/help/environment.md b/docs/help/environment.md index 51aae1948df..5c53e669eb8 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -113,13 +113,13 @@ Both resolve from process env at activation time. SecretRef details are document When loading config from Nacos instead of a local file (e.g. in Kubernetes pods with no config file mount): -| Variable | Required | Purpose | -| ------------------------ | -------- | ----------------------------------------------------------------------- | -| `OPENCLAW_CONFIG_SOURCE` | Yes | Set to `nacos` to use Nacos; omit or `file` for file-based config. | -| `NACOS_SERVER_ADDR` | Yes | Nacos server address (e.g. `http://nacos:8848`). | -| `NACOS_DATA_ID` | Yes | Data ID of the config (e.g. `openclaw.json`). | -| `NACOS_GROUP` | No | Nacos group (default: `DEFAULT_GROUP`). | -| `NACOS_NAMESPACE` | No | Nacos namespace (tenant); omit for default namespace. | +| Variable | Required | Purpose | +| ------------------------ | -------- | ------------------------------------------------------------------ | +| `OPENCLAW_CONFIG_SOURCE` | Yes | Set to `nacos` to use Nacos; omit or `file` for file-based config. | +| `NACOS_SERVER_ADDR` | Yes | Nacos server address (e.g. `http://nacos:8848`). | +| `NACOS_DATA_ID` | Yes | Data ID of the config (e.g. `openclaw.json`). | +| `NACOS_GROUP` | No | Nacos group (default: `DEFAULT_GROUP`). | +| `NACOS_NAMESPACE` | No | Nacos namespace (tenant); omit for default namespace. | Config is kept only in memory; hot reload is driven by Nacos long-polling. See [Configuration: Config source: Nacos](/gateway/configuration#config-source-nacos). diff --git a/src/config/io.ts b/src/config/io.ts index 63f1f5b2792..84f74f65ae0 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -47,8 +47,8 @@ import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin. import { normalizeConfigPaths } from "./normalize-paths.js"; import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; -import { getConfigSource } from "./sources/current.js"; import { applyConfigOverrides } from "./runtime-overrides.js"; +import { getConfigSource } from "./sources/current.js"; import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; import { validateConfigObjectRawWithPlugins, @@ -1506,8 +1506,8 @@ function buildSnapshotFromRuntimeSnapshots(path: string): ConfigFileSnapshot | n path, exists: true, raw, - parsed: config as unknown, - resolved: resolved as unknown, + parsed: config, + resolved, valid: true, config, hash: hashConfigRaw(raw), diff --git a/src/config/sources/nacos-client.ts b/src/config/sources/nacos-client.ts index d4ea049c983..5c2dd82266b 100644 --- a/src/config/sources/nacos-client.ts +++ b/src/config/sources/nacos-client.ts @@ -67,8 +67,7 @@ export function createNacosConfigClient(opts: NacosConfigClientOptions): NacosCo if (stopped) return; // Nacos v1 listener expects Listening-Configs=%02%02%02%01 const tenant = opts.tenant ?? ""; - const listeningConfigs = - `${opts.dataId}${STX}${opts.group}${STX}${lastContentMD5}${STX}${tenant}${SOH}`; + const listeningConfigs = `${opts.dataId}${STX}${opts.group}${STX}${lastContentMD5}${STX}${tenant}${SOH}`; const body = new URLSearchParams({ "Listening-Configs": listeningConfigs }); try { const res = await doFetch(listenerUrl, { diff --git a/src/config/sources/nacos.test.ts b/src/config/sources/nacos.test.ts index aa15d7d8659..ae36ae5a308 100644 --- a/src/config/sources/nacos.test.ts +++ b/src/config/sources/nacos.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createNacosConfigSource } from "./nacos.js"; import type { NacosConfigClient } from "./nacos-client.js"; +import { createNacosConfigSource } from "./nacos.js"; describe("createNacosConfigSource", () => { it("returns source with kind nacos, watchPath null, subscribe function, and readSnapshot builds snapshot", async () => { diff --git a/src/config/sources/snapshot-from-raw.ts b/src/config/sources/snapshot-from-raw.ts index 73cc1cb316a..d8aad813bbe 100644 --- a/src/config/sources/snapshot-from-raw.ts +++ b/src/config/sources/snapshot-from-raw.ts @@ -17,18 +17,21 @@ import { applyTalkApiKey, applyTalkConfigNormalization, } from "../defaults.js"; -import { applyConfigEnvVars } from "../env-vars.js"; import type { EnvSubstitutionWarning } from "../env-substitution.js"; import { resolveConfigEnvVars } from "../env-substitution.js"; +import { applyConfigEnvVars } from "../env-vars.js"; import { parseConfigJson5 } from "../io.js"; import { findLegacyConfigIssues } from "../legacy.js"; -import { normalizeConfigPaths } from "../normalize-paths.js"; import { normalizeExecSafeBinProfilesInConfig } from "../normalize-exec-safe-bin.js"; +import { normalizeConfigPaths } from "../normalize-paths.js"; import type { ConfigFileSnapshot, LegacyConfigIssue, OpenClawConfig } from "../types.js"; import { validateConfigObjectWithPlugins } from "../validation.js"; function hashConfigRaw(raw: string | null): string { - return crypto.createHash("sha256").update(raw ?? "").digest("hex"); + return crypto + .createHash("sha256") + .update(raw ?? "") + .digest("hex"); } function coerceConfig(value: unknown): OpenClawConfig { diff --git a/src/config/sources/types.test.ts b/src/config/sources/types.test.ts index 10902f92242..3a918951cbe 100644 --- a/src/config/sources/types.test.ts +++ b/src/config/sources/types.test.ts @@ -12,7 +12,7 @@ describe("ConfigSource types", () => { it("ConfigSource has readSnapshot and optional subscribe", () => { const source: ConfigSource = { kind: "file", - readSnapshot: async () => ({} as never), + readSnapshot: async () => ({}) as never, }; expect(source.kind).toBe("file"); expect(typeof source.readSnapshot).toBe("function"); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index ba12fd934b5..b7a7731ac4f 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -20,11 +20,11 @@ import { setRuntimeConfigSnapshot, writeConfigFile, } from "../config/config.js"; -import { getConfigSource, setConfigSource } from "../config/sources/current.js"; -import { resolveConfigSource } from "../config/sources/resolve.js"; import { formatConfigIssueLines } from "../config/issue-format.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { resolveMainSessionKey } from "../config/sessions.js"; +import { getConfigSource, setConfigSource } from "../config/sources/current.js"; +import { resolveConfigSource } from "../config/sources/resolve.js"; import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; import { ensureControlUiAssetsBuilt, @@ -32,8 +32,8 @@ import { resolveControlUiRootOverrideSync, resolveControlUiRootSync, } from "../infra/control-ui-assets.js"; -import { loadDotEnv } from "../infra/dotenv.js"; import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; +import { loadDotEnv } from "../infra/dotenv.js"; import { logAcceptedEnvOption } from "../infra/env.js"; import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js"; import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; @@ -78,8 +78,8 @@ import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { runSetupWizard } from "../wizard/setup.js"; import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js"; import { startChannelHealthMonitor } from "./channel-health-monitor.js"; -import { startGatewayConfigReloader } from "./config-reload.js"; import type { GatewayReloadPlan } from "./config-reload-plan.js"; +import { startGatewayConfigReloader } from "./config-reload.js"; import type { ControlUiRootState } from "./control-ui.js"; import { GATEWAY_EVENT_UPDATE_AVAILABLE, From bda40c7e574c8b5d77ebf087bd3cdd4d6aaf8499 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Thu, 19 Mar 2026 11:09:30 +0800 Subject: [PATCH 7/8] config: apply Nacos source review fixes Made-with: Cursor --- src/cli/config-cli.ts | 15 +++++++++ src/cli/gateway-cli/run.ts | 8 +++++ src/commands/doctor.ts | 8 +++++ src/config/sources/nacos-client.test.ts | 35 ++++++++++++++++++++ src/config/sources/nacos-client.ts | 6 +++- src/config/sources/snapshot-from-raw.test.ts | 17 ++++++++++ src/config/sources/snapshot-from-raw.ts | 8 ++++- src/gateway/server.impl.ts | 7 +++- 8 files changed, 101 insertions(+), 3 deletions(-) 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(); From d8579217e1d77eafb7100f220499c8e37874ae0c Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Thu, 19 Mar 2026 11:29:55 +0800 Subject: [PATCH 8/8] CLI: stop using loadConfig for Nacos gateway preflight Made-with: Cursor --- src/cli/gateway-cli/run.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index c2026777567..3fe561de0b6 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -206,7 +206,11 @@ async function runGatewayCommand(opts: GatewayRunOpts) { loadDotEnv({ quiet: true }); setConfigSource(resolveConfigSource(process.env)); - const cfg = loadConfig(); + // loadConfig() always uses file IO unless a runtime snapshot exists, so in Nacos-only + // deployments it would bind the wrong port and use file-based gateway.mode. Use the + // snapshot from the current source (Nacos or file) for preflight. + const preflightSnapshot = await readConfigFileSnapshot().catch(() => null); + const cfg = preflightSnapshot?.valid ? preflightSnapshot.config : loadConfig(); const portOverride = parsePort(opts.port); if (opts.port !== undefined && portOverride === null) { defaultRuntime.error("Invalid port"); @@ -319,7 +323,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) { } const tokenRaw = toOptionString(opts.token); - const snapshot = await readConfigFileSnapshot().catch(() => null); + const snapshot = preflightSnapshot; const configExists = snapshot?.exists ?? fs.existsSync(CONFIG_PATH); const configAuditPath = path.join(resolveStateDir(process.env), "logs", "config-audit.jsonl"); const mode = cfg.gateway?.mode;