config: add Nacos config source, reload subscribe, validation and NACOS_NAMESPACE
Made-with: Cursor
This commit is contained in:
parent
4c60956d8e
commit
01c25d7280
@ -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)
|
||||
|
||||
@ -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)**.
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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);
|
||||
|
||||
17
src/config/sources/current.ts
Normal file
17
src/config/sources/current.ts
Normal 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;
|
||||
}
|
||||
36
src/config/sources/file.test.ts
Normal file
36
src/config/sources/file.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
14
src/config/sources/file.ts
Normal file
14
src/config/sources/file.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
69
src/config/sources/nacos-client.test.ts
Normal file
69
src/config/sources/nacos-client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
90
src/config/sources/nacos-client.ts
Normal file
90
src/config/sources/nacos-client.ts
Normal 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;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
28
src/config/sources/nacos.test.ts
Normal file
28
src/config/sources/nacos.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
47
src/config/sources/nacos.ts
Normal file
47
src/config/sources/nacos.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
38
src/config/sources/resolve.test.ts
Normal file
38
src/config/sources/resolve.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
35
src/config/sources/resolve.ts
Normal file
35
src/config/sources/resolve.ts
Normal 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 });
|
||||
}
|
||||
16
src/config/sources/snapshot-from-raw.test.ts
Normal file
16
src/config/sources/snapshot-from-raw.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
143
src/config/sources/snapshot-from-raw.ts
Normal file
143
src/config/sources/snapshot-from-raw.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
src/config/sources/types.test.ts
Normal file
21
src/config/sources/types.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
10
src/config/sources/types.ts
Normal file
10
src/config/sources/types.ts
Normal 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;
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user