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/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.
|
- 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.
|
- 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/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.
|
- 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)
|
- 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.
|
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
|
## Full reference
|
||||||
|
|
||||||
For the complete field-by-field reference, see **[Configuration Reference](/gateway/configuration-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_STATE_DIR` | Override the state directory (default `~/.openclaw`). |
|
||||||
| `OPENCLAW_CONFIG_PATH` | Override the config file path (default `~/.openclaw/openclaw.json`). |
|
| `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
|
## Logging
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-f
|
|||||||
import { CONFIG_PATH } from "../config/paths.js";
|
import { CONFIG_PATH } from "../config/paths.js";
|
||||||
import { isBlockedObjectKey } from "../config/prototype-keys.js";
|
import { isBlockedObjectKey } from "../config/prototype-keys.js";
|
||||||
import { redactConfigObject } from "../config/redact-snapshot.js";
|
import { redactConfigObject } from "../config/redact-snapshot.js";
|
||||||
|
import { setConfigSource } from "../config/sources/current.js";
|
||||||
|
import { resolveConfigSource } from "../config/sources/resolve.js";
|
||||||
import {
|
import {
|
||||||
coerceSecretRef,
|
coerceSecretRef,
|
||||||
isValidEnvSecretRefId,
|
isValidEnvSecretRefId,
|
||||||
@ -18,6 +20,7 @@ import {
|
|||||||
import { validateConfigObjectRaw } from "../config/validation.js";
|
import { validateConfigObjectRaw } from "../config/validation.js";
|
||||||
import { SecretProviderSchema } from "../config/zod-schema.core.js";
|
import { SecretProviderSchema } from "../config/zod-schema.core.js";
|
||||||
import { danger, info, success } from "../globals.js";
|
import { danger, info, success } from "../globals.js";
|
||||||
|
import { loadDotEnv } from "../infra/dotenv.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import {
|
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 {
|
function isIndexSegment(raw: string): boolean {
|
||||||
return /^[0-9]+$/.test(raw);
|
return /^[0-9]+$/.test(raw);
|
||||||
}
|
}
|
||||||
@ -969,6 +979,7 @@ export async function runConfigSet(opts: {
|
|||||||
cliOptions: ConfigSetOptions;
|
cliOptions: ConfigSetOptions;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
}) {
|
}) {
|
||||||
|
initializeConfigSourceForStandaloneConfigCli();
|
||||||
const runtime = opts.runtime ?? defaultRuntime;
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
try {
|
try {
|
||||||
const isBatchMode = hasBatchMode(opts.cliOptions);
|
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 }) {
|
export async function runConfigGet(opts: { path: string; json?: boolean; runtime?: RuntimeEnv }) {
|
||||||
|
initializeConfigSourceForStandaloneConfigCli();
|
||||||
const runtime = opts.runtime ?? defaultRuntime;
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
try {
|
try {
|
||||||
const parsedPath = parseRequiredPath(opts.path);
|
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 }) {
|
export async function runConfigUnset(opts: { path: string; runtime?: RuntimeEnv }) {
|
||||||
|
initializeConfigSourceForStandaloneConfigCli();
|
||||||
const runtime = opts.runtime ?? defaultRuntime;
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
try {
|
try {
|
||||||
const parsedPath = parseRequiredPath(opts.path);
|
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 }) {
|
export async function runConfigFile(opts: { runtime?: RuntimeEnv }) {
|
||||||
|
initializeConfigSourceForStandaloneConfigCli();
|
||||||
const runtime = opts.runtime ?? defaultRuntime;
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
try {
|
try {
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const snapshot = await readConfigFileSnapshot();
|
||||||
@ -1195,6 +1209,7 @@ export async function runConfigFile(opts: { runtime?: RuntimeEnv }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } = {}) {
|
export async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } = {}) {
|
||||||
|
initializeConfigSourceForStandaloneConfigCli();
|
||||||
const runtime = opts.runtime ?? defaultRuntime;
|
const runtime = opts.runtime ?? defaultRuntime;
|
||||||
let outputPath = CONFIG_PATH ?? "openclaw.json";
|
let outputPath = CONFIG_PATH ?? "openclaw.json";
|
||||||
|
|
||||||
|
|||||||
@ -10,12 +10,15 @@ import {
|
|||||||
resolveStateDir,
|
resolveStateDir,
|
||||||
resolveGatewayPort,
|
resolveGatewayPort,
|
||||||
} from "../../config/config.js";
|
} 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 { hasConfiguredSecretInput } from "../../config/types.secrets.js";
|
||||||
import { resolveGatewayAuth } from "../../gateway/auth.js";
|
import { resolveGatewayAuth } from "../../gateway/auth.js";
|
||||||
import { startGatewayServer } from "../../gateway/server.js";
|
import { startGatewayServer } from "../../gateway/server.js";
|
||||||
import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
|
import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js";
|
||||||
import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js";
|
import { setGatewayWsLogStyle } from "../../gateway/ws-logging.js";
|
||||||
import { setVerbose } from "../../globals.js";
|
import { setVerbose } from "../../globals.js";
|
||||||
|
import { loadDotEnv } from "../../infra/dotenv.js";
|
||||||
import { GatewayLockError } from "../../infra/gateway-lock.js";
|
import { GatewayLockError } from "../../infra/gateway-lock.js";
|
||||||
import { formatPortDiagnostics, inspectPortUsage } from "../../infra/ports.js";
|
import { formatPortDiagnostics, inspectPortUsage } from "../../infra/ports.js";
|
||||||
import { cleanStaleGatewayProcessesSync } from "../../infra/restart-stale-pids.js";
|
import { cleanStaleGatewayProcessesSync } from "../../infra/restart-stale-pids.js";
|
||||||
@ -198,7 +201,16 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
|||||||
await ensureDevGatewayConfig({ reset: Boolean(opts.reset) });
|
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);
|
const portOverride = parsePort(opts.port);
|
||||||
if (opts.port !== undefined && portOverride === null) {
|
if (opts.port !== undefined && portOverride === null) {
|
||||||
defaultRuntime.error("Invalid port");
|
defaultRuntime.error("Invalid port");
|
||||||
@ -311,7 +323,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
|
|||||||
}
|
}
|
||||||
const tokenRaw = toOptionString(opts.token);
|
const tokenRaw = toOptionString(opts.token);
|
||||||
|
|
||||||
const snapshot = await readConfigFileSnapshot().catch(() => null);
|
const snapshot = preflightSnapshot;
|
||||||
const configExists = snapshot?.exists ?? fs.existsSync(CONFIG_PATH);
|
const configExists = snapshot?.exists ?? fs.existsSync(CONFIG_PATH);
|
||||||
const configAuditPath = path.join(resolveStateDir(process.env), "logs", "config-audit.jsonl");
|
const configAuditPath = path.join(resolveStateDir(process.env), "logs", "config-audit.jsonl");
|
||||||
const mode = cfg.gateway?.mode;
|
const mode = cfg.gateway?.mode;
|
||||||
|
|||||||
@ -12,12 +12,15 @@ import { formatCliCommand } from "../cli/command-format.js";
|
|||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { CONFIG_PATH, readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
import { CONFIG_PATH, readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
||||||
import { logConfigUpdated } from "../config/logging.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 { resolveSecretInputRef } from "../config/types.secrets.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
|
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
|
||||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||||
import { runStartupMatrixMigration } from "../gateway/server-startup-matrix-migration.js";
|
import { runStartupMatrixMigration } from "../gateway/server-startup-matrix-migration.js";
|
||||||
|
import { loadDotEnv } from "../infra/dotenv.js";
|
||||||
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
|
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@ -80,6 +83,11 @@ export async function doctorCommand(
|
|||||||
printWizardHeader(runtime);
|
printWizardHeader(runtime);
|
||||||
intro("OpenClaw doctor");
|
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({
|
const root = await resolveOpenClawPackageRoot({
|
||||||
moduleUrl: import.meta.url,
|
moduleUrl: import.meta.url,
|
||||||
argv1: process.argv[1],
|
argv1: process.argv[1],
|
||||||
|
|||||||
@ -18,15 +18,15 @@ import { VERSION } from "../version.js";
|
|||||||
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
|
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
|
||||||
import { maintainConfigBackups } from "./backup-rotation.js";
|
import { maintainConfigBackups } from "./backup-rotation.js";
|
||||||
import {
|
import {
|
||||||
|
applyAgentDefaults,
|
||||||
applyCompactionDefaults,
|
applyCompactionDefaults,
|
||||||
applyContextPruningDefaults,
|
applyContextPruningDefaults,
|
||||||
applyAgentDefaults,
|
|
||||||
applyLoggingDefaults,
|
applyLoggingDefaults,
|
||||||
applyMessageDefaults,
|
applyMessageDefaults,
|
||||||
applyModelDefaults,
|
applyModelDefaults,
|
||||||
applySessionDefaults,
|
applySessionDefaults,
|
||||||
applyTalkConfigNormalization,
|
|
||||||
applyTalkApiKey,
|
applyTalkApiKey,
|
||||||
|
applyTalkConfigNormalization,
|
||||||
} from "./defaults.js";
|
} from "./defaults.js";
|
||||||
import { restoreEnvVarRefs } from "./env-preserve.js";
|
import { restoreEnvVarRefs } from "./env-preserve.js";
|
||||||
import {
|
import {
|
||||||
@ -48,6 +48,7 @@ import { normalizeConfigPaths } from "./normalize-paths.js";
|
|||||||
import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js";
|
import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js";
|
||||||
import { isBlockedObjectKey } from "./prototype-keys.js";
|
import { isBlockedObjectKey } from "./prototype-keys.js";
|
||||||
import { applyConfigOverrides } from "./runtime-overrides.js";
|
import { applyConfigOverrides } from "./runtime-overrides.js";
|
||||||
|
import { getConfigSource } from "./sources/current.js";
|
||||||
import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
|
import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
|
||||||
import {
|
import {
|
||||||
validateConfigObjectRawWithPlugins,
|
validateConfigObjectRawWithPlugins,
|
||||||
@ -1496,11 +1497,54 @@ export async function readBestEffortConfig(): Promise<OpenClawConfig> {
|
|||||||
return snapshot.valid ? loadConfig() : snapshot.config;
|
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> {
|
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();
|
return await createConfigIO().readConfigFileSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
|
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();
|
return await createConfigIO().readConfigFileSnapshotForWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1508,6 +1552,36 @@ export async function writeConfigFile(
|
|||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
options: ConfigWriteOptions = {},
|
options: ConfigWriteOptions = {},
|
||||||
): Promise<void> {
|
): 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();
|
const io = createConfigIO();
|
||||||
let nextCfg = cfg;
|
let nextCfg = cfg;
|
||||||
const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot);
|
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();
|
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;
|
warn: (msg: string) => void;
|
||||||
error: (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 {
|
}): GatewayConfigReloader {
|
||||||
|
if (opts.subscribe == null && opts.watchPath == null) {
|
||||||
|
throw new Error("startGatewayConfigReloader: provide watchPath or subscribe");
|
||||||
|
}
|
||||||
let currentConfig = opts.initialConfig;
|
let currentConfig = opts.initialConfig;
|
||||||
let settings = resolveGatewayReloadSettings(currentConfig);
|
let settings = resolveGatewayReloadSettings(currentConfig);
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@ -89,6 +95,7 @@ export function startGatewayConfigReloader(opts: {
|
|||||||
let stopped = false;
|
let stopped = false;
|
||||||
let restartQueued = false;
|
let restartQueued = false;
|
||||||
let missingConfigRetries = 0;
|
let missingConfigRetries = 0;
|
||||||
|
let teardownSubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
const scheduleAfter = (wait: number) => {
|
const scheduleAfter = (wait: number) => {
|
||||||
if (stopped) {
|
if (stopped) {
|
||||||
@ -214,34 +221,46 @@ export function startGatewayConfigReloader(opts: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const watcher = chokidar.watch(opts.watchPath, {
|
let watcher: ReturnType<typeof chokidar.watch> | null = null;
|
||||||
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 watcherClosed = false;
|
let watcherClosed = false;
|
||||||
watcher.on("error", (err) => {
|
|
||||||
if (watcherClosed) {
|
if (opts.subscribe != null) {
|
||||||
return;
|
teardownSubscribe = opts.subscribe(schedule);
|
||||||
}
|
} else {
|
||||||
watcherClosed = true;
|
const watchPath = opts.watchPath as string;
|
||||||
opts.log.warn(`config watcher error: ${String(err)}`);
|
watcher = chokidar.watch(watchPath, {
|
||||||
void watcher.close().catch(() => {});
|
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 {
|
return {
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
if (teardownSubscribe != null) {
|
||||||
|
teardownSubscribe();
|
||||||
|
teardownSubscribe = null;
|
||||||
|
}
|
||||||
if (debounceTimer) {
|
if (debounceTimer) {
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
}
|
}
|
||||||
debounceTimer = null;
|
debounceTimer = null;
|
||||||
watcherClosed = true;
|
if (watcher != null) {
|
||||||
await watcher.close().catch(() => {});
|
watcherClosed = true;
|
||||||
|
await watcher.close().catch(() => {});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,11 +17,15 @@ import {
|
|||||||
loadConfig,
|
loadConfig,
|
||||||
migrateLegacyConfig,
|
migrateLegacyConfig,
|
||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
|
getRuntimeConfigSourceSnapshot,
|
||||||
|
setRuntimeConfigSnapshot,
|
||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||||
import { resolveMainSessionKey } from "../config/sessions.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 { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
|
||||||
import {
|
import {
|
||||||
ensureControlUiAssetsBuilt,
|
ensureControlUiAssetsBuilt,
|
||||||
@ -30,6 +34,7 @@ import {
|
|||||||
resolveControlUiRootSync,
|
resolveControlUiRootSync,
|
||||||
} from "../infra/control-ui-assets.js";
|
} from "../infra/control-ui-assets.js";
|
||||||
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
|
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
|
||||||
|
import { loadDotEnv } from "../infra/dotenv.js";
|
||||||
import { logAcceptedEnvOption } from "../infra/env.js";
|
import { logAcceptedEnvOption } from "../infra/env.js";
|
||||||
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
|
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
|
||||||
import { onHeartbeatEvent } from "../infra/heartbeat-events.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 { runSetupWizard } from "../wizard/setup.js";
|
||||||
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
|
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
|
||||||
import { startChannelHealthMonitor } from "./channel-health-monitor.js";
|
import { startChannelHealthMonitor } from "./channel-health-monitor.js";
|
||||||
|
import type { GatewayReloadPlan } from "./config-reload-plan.js";
|
||||||
import { startGatewayConfigReloader } from "./config-reload.js";
|
import { startGatewayConfigReloader } from "./config-reload.js";
|
||||||
import type { ControlUiRootState } from "./control-ui.js";
|
import type { ControlUiRootState } from "./control-ui.js";
|
||||||
import {
|
import {
|
||||||
@ -377,6 +383,9 @@ export async function startGatewayServer(
|
|||||||
description: "raw stream log path override",
|
description: "raw stream log path override",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
loadDotEnv({ quiet: true });
|
||||||
|
setConfigSource(resolveConfigSource(process.env));
|
||||||
|
|
||||||
let configSnapshot = await readConfigFileSnapshot();
|
let configSnapshot = await readConfigFileSnapshot();
|
||||||
if (configSnapshot.legacyIssues.length > 0) {
|
if (configSnapshot.legacyIssues.length > 0) {
|
||||||
if (isNixMode) {
|
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();
|
initSubagentRegistry();
|
||||||
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
|
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
|
||||||
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
|
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);
|
||||||
@ -1219,6 +1238,10 @@ export async function startGatewayServer(
|
|||||||
const configReloader = minimalTestGateway
|
const configReloader = minimalTestGateway
|
||||||
? { stop: async () => {} }
|
? { stop: async () => {} }
|
||||||
: (() => {
|
: (() => {
|
||||||
|
const source = getConfigSource();
|
||||||
|
if (!source) {
|
||||||
|
throw new Error("gateway config reloader: config source not set");
|
||||||
|
}
|
||||||
const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({
|
const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({
|
||||||
deps,
|
deps,
|
||||||
broadcast,
|
broadcast,
|
||||||
@ -1264,10 +1287,10 @@ export async function startGatewayServer(
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return startGatewayConfigReloader({
|
const reloaderOpts = {
|
||||||
initialConfig: cfgAtStart,
|
initialConfig: cfgAtStart,
|
||||||
readSnapshot: readConfigFileSnapshot,
|
readSnapshot: () => source.readSnapshot(),
|
||||||
onHotReload: async (plan, nextConfig) => {
|
onHotReload: async (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => {
|
||||||
const previousSnapshot = getActiveSecretsRuntimeSnapshot();
|
const previousSnapshot = getActiveSecretsRuntimeSnapshot();
|
||||||
const prepared = await activateRuntimeSecrets(nextConfig, {
|
const prepared = await activateRuntimeSecrets(nextConfig, {
|
||||||
reason: "reload",
|
reason: "reload",
|
||||||
@ -1275,6 +1298,9 @@ export async function startGatewayServer(
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await applyHotReload(plan, prepared.config);
|
await applyHotReload(plan, prepared.config);
|
||||||
|
if (getConfigSource()?.kind === "nacos") {
|
||||||
|
setRuntimeConfigSnapshot(prepared.config);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (previousSnapshot) {
|
if (previousSnapshot) {
|
||||||
activateSecretsRuntimeSnapshot(previousSnapshot);
|
activateSecretsRuntimeSnapshot(previousSnapshot);
|
||||||
@ -1284,17 +1310,21 @@ export async function startGatewayServer(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRestart: async (plan, nextConfig) => {
|
onRestart: async (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => {
|
||||||
await activateRuntimeSecrets(nextConfig, { reason: "restart-check", activate: false });
|
await activateRuntimeSecrets(nextConfig, { reason: "restart-check", activate: false });
|
||||||
requestGatewayRestart(plan, nextConfig);
|
requestGatewayRestart(plan, nextConfig);
|
||||||
},
|
},
|
||||||
log: {
|
log: {
|
||||||
info: (msg) => logReload.info(msg),
|
info: (msg: string) => logReload.info(msg),
|
||||||
warn: (msg) => logReload.warn(msg),
|
warn: (msg: string) => logReload.warn(msg),
|
||||||
error: (msg) => logReload.error(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({
|
const close = createGatewayCloseHandler({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user