Merge d8579217e1d77eafb7100f220499c8e37874ae0c into 5e417b44e1540f528d2ae63e3e20229a902d1db2
This commit is contained in:
commit
cbbbe042cb
@ -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)
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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);
|
||||
|
||||
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 { 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);
|
||||
});
|
||||
});
|
||||
14
src/config/sources/file.ts
Normal file
14
src/config/sources/file.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
104
src/config/sources/nacos-client.test.ts
Normal file
104
src/config/sources/nacos-client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
115
src/config/sources/nacos-client.ts
Normal file
115
src/config/sources/nacos-client.ts
Normal 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;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
28
src/config/sources/nacos.test.ts
Normal file
28
src/config/sources/nacos.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
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 { 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
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 });
|
||||
}
|
||||
33
src/config/sources/snapshot-from-raw.test.ts
Normal file
33
src/config/sources/snapshot-from-raw.test.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
152
src/config/sources/snapshot-from-raw.ts
Normal file
152
src/config/sources/snapshot-from-raw.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
20
src/config/sources/types.test.ts
Normal file
20
src/config/sources/types.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
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,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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user