Merge d8579217e1d77eafb7100f220499c8e37874ae0c into 5e417b44e1540f528d2ae63e3e20229a902d1db2

This commit is contained in:
GatewayJ 2026-03-21 05:00:17 +03:00 committed by GitHub
commit cbbbe042cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 920 additions and 33 deletions

View File

@ -44,6 +44,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

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

View File

@ -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,7 +201,16 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
await ensureDevGatewayConfig({ reset: Boolean(opts.reset) });
}
const cfg = loadConfig();
// 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));
// 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");
@ -311,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;

View File

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

View File

@ -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 {
@ -48,6 +48,7 @@ import { normalizeConfigPaths } from "./normalize-paths.js";
import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
import { applyConfigOverrides } from "./runtime-overrides.js";
import { getConfigSource } from "./sources/current.js";
import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
import {
validateConfigObjectRawWithPlugins,
@ -1496,11 +1497,54 @@ export async function readBestEffortConfig(): Promise<OpenClawConfig> {
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,
resolved,
valid: true,
config,
hash: hashConfigRaw(raw),
issues: [],
warnings: [],
legacyIssues: [],
};
}
export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
const source = getConfigSource();
if (source?.kind === "nacos") {
const fromRuntime = buildSnapshotFromRuntimeSnapshots("nacos:openclaw.json");
if (fromRuntime) return fromRuntime;
}
if (source !== null) {
return source.readSnapshot();
}
return await createConfigIO().readConfigFileSnapshot();
}
export async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
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,
writeOptions: { expectedConfigPath: snapshot.path },
};
}
return await createConfigIO().readConfigFileSnapshotForWrite();
}
@ -1508,6 +1552,36 @@ 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, 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();
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 { 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;
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 { createConfigIO } from "../io.js";
import type { ConfigSource } from "./types.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,104 @@
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();
});
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();
});
});

View File

@ -0,0 +1,115 @@
/**
* 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=<dataId>%02<group>%02<contentMD5>%02<tenant>%01
*/
import crypto from "node:crypto";
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`;
// MD5 of last-fetched content; used by listener so Nacos can compare and hold connection until change.
let lastContentMD5 = "";
return {
async fetchConfig(): Promise<string> {
const res = await doFetch(getConfigUrl());
if (!res.ok) {
throw new Error(`Nacos get config failed: ${res.status} ${res.statusText}`);
}
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<void> => {
if (stopped) return;
// Nacos v1 listener expects Listening-Configs=<dataId>%02<group>%02<contentMD5>%02<tenant>%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",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Long-Pulling-Timeout": "30000",
},
body: body.toString(),
});
if (stopped) return;
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 {
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));
}
if (!stopped) {
void poll();
}
};
void poll();
return () => {
stopped = true;
};
},
};
}

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
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 () => {
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 { 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;
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,33 @@
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");
});
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];
}
}
});
});

View File

@ -0,0 +1,152 @@
/**
* 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 {
applyAgentDefaults,
applyCompactionDefaults,
applyContextPruningDefaults,
applyLoggingDefaults,
applyMessageDefaults,
applyModelDefaults,
applySessionDefaults,
applyTalkApiKey,
applyTalkConfigNormalization,
} from "../defaults.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 { 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");
}
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)
) {
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),
});
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,20 @@
import { describe, expect, it } 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,11 +17,15 @@ import {
loadConfig,
migrateLegacyConfig,
readConfigFileSnapshot,
getRuntimeConfigSourceSnapshot,
setRuntimeConfigSnapshot,
writeConfigFile,
} from "../config/config.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,
@ -30,6 +34,7 @@ import {
resolveControlUiRootSync,
} from "../infra/control-ui-assets.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";
@ -74,6 +79,7 @@ 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 type { GatewayReloadPlan } from "./config-reload-plan.js";
import { startGatewayConfigReloader } from "./config-reload.js";
import type { ControlUiRootState } from "./control-ui.js";
import {
@ -377,6 +383,9 @@ export async function startGatewayServer(
description: "raw stream log path override",
});
loadDotEnv({ quiet: true });
setConfigSource(resolveConfigSource(process.env));
let configSnapshot = await readConfigFileSnapshot();
if (configSnapshot.legacyIssues.length > 0) {
if (isNixMode) {
@ -546,6 +555,16 @@ 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") {
// 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();
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
@ -1219,6 +1238,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 +1287,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 +1298,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 +1310,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({