config: add Nacos config source, reload subscribe, validation and NACOS_NAMESPACE

Made-with: Cursor
This commit is contained in:
GatewayJ 2026-03-18 22:23:06 +08:00
parent 4c60956d8e
commit 01c25d7280
20 changed files with 740 additions and 29 deletions

View File

@ -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)

View File

@ -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)**.

View File

@ -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 |

View File

@ -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<OpenClawConfig> {
}
export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
const source = getConfigSource();
if (source !== null) {
return source.readSnapshot();
}
return await createConfigIO().readConfigFileSnapshot();
}
export async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
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<void> {
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 : "<root>";
const issueMessage = issue?.message ?? "invalid";
throw new Error(formatConfigValidationFailure(pathLabel, issueMessage));
}
setRuntimeConfigSnapshot(cfg);
return;
}
const io = createConfigIO();
let nextCfg = cfg;
const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot);

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -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(),
};
}

View File

@ -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<string> }) => void;
const listenerPromise = new Promise<{ ok: boolean; text: () => Promise<string> }>((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();
});
});

View File

@ -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<string>;
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<string> {
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<void> => {
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;
};
},
};
}

View File

@ -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");
});
});

View File

@ -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);
},
};
}

View File

@ -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();
});
});

View File

@ -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 });
}

View File

@ -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");
});
});

View File

@ -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<ConfigFileSnapshot> {
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,
};
}

View File

@ -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");
});
});

View File

@ -0,0 +1,10 @@
import type { ConfigFileSnapshot } from "../types.js";
export type ConfigSourceKind = "file" | "nacos";
export type ConfigSource = {
kind: ConfigSourceKind;
readSnapshot: () => Promise<ConfigFileSnapshot>;
subscribe?: (onChange: () => void) => () => void;
watchPath?: string | null;
};

View File

@ -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<ConfigFileSnapshot>>()
.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);
});
});

View File

@ -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<typeof setTimeout> | 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<typeof chokidar.watch> | 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(() => {});
}
},
};
}

View File

@ -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({