diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 36da791292a..5df026d1773 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -662,7 +662,7 @@ Default slash command settings: - OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` (default) | `partial` | `block` | `progress`. `progress` keeps one editable status draft and updates it with tool progress until final delivery; `streamMode` is a legacy alias and is auto-migrated. + OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` (default) | `partial` | `block` | `progress`. `progress` keeps one editable status draft and updates it with tool progress until final delivery; `streamMode` is a legacy runtime alias. Run `openclaw doctor --fix` to rewrite persisted config to the canonical key. Default stays `off` because Discord preview edits hit rate limits quickly when multiple bots or gateways share an account. diff --git a/docs/channels/slack.md b/docs/channels/slack.md index dbb99cc3d9f..f26e3a94be3 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -1020,9 +1020,10 @@ Use draft preview instead of Slack native text streaming: Legacy keys: -- `channels.slack.streamMode` (`replace | status_final | append`) is auto-migrated to `channels.slack.streaming.mode`. -- boolean `channels.slack.streaming` is auto-migrated to `channels.slack.streaming.mode` and `channels.slack.streaming.nativeTransport`. -- legacy `channels.slack.nativeStreaming` is auto-migrated to `channels.slack.streaming.nativeTransport`. +- `channels.slack.streamMode` (`replace | status_final | append`) is a legacy runtime alias for `channels.slack.streaming.mode`. +- boolean `channels.slack.streaming` is a legacy runtime alias for `channels.slack.streaming.mode` and `channels.slack.streaming.nativeTransport`. +- legacy `channels.slack.nativeStreaming` is a runtime alias for `channels.slack.streaming.nativeTransport`. +- Run `openclaw doctor --fix` to rewrite persisted Slack streaming config to the canonical keys. ## Typing reaction fallback diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md index 93e616b4719..bf8f8441ef5 100644 --- a/docs/concepts/streaming.md +++ b/docs/concepts/streaming.md @@ -152,8 +152,8 @@ Slack-only: Legacy key migration: - Telegram: legacy `streamMode` and scalar/boolean `streaming` values are detected and migrated by doctor/config compatibility paths to `streaming.mode`. -- Discord: `streamMode` + boolean `streaming` auto-migrate to `streaming` enum. -- Slack: `streamMode` auto-migrates to `streaming.mode`; boolean `streaming` auto-migrates to `streaming.mode` plus `streaming.nativeTransport`; legacy `nativeStreaming` auto-migrates to `streaming.nativeTransport`. +- Discord: `streamMode` + boolean `streaming` remain runtime aliases for the `streaming` enum; run `openclaw doctor --fix` to rewrite persisted config. +- Slack: `streamMode` remains a runtime alias for `streaming.mode`; boolean `streaming` remains a runtime alias for `streaming.mode` plus `streaming.nativeTransport`; legacy `nativeStreaming` remains a runtime alias for `streaming.nativeTransport`. Run `openclaw doctor --fix` to rewrite persisted config. ### Runtime behavior diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index c42bce27e21..f48c08b1f66 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -1401,7 +1401,7 @@ Defaults for Talk mode (macOS/iOS/Android). ``` - `talk.provider` must match a key in `talk.providers` when multiple Talk providers are configured. -- Legacy flat Talk keys (`talk.voiceId`, `talk.voiceAliases`, `talk.modelId`, `talk.outputFormat`, `talk.apiKey`) are compatibility-only and are auto-migrated into `talk.providers.`. +- Legacy flat Talk keys (`talk.voiceId`, `talk.voiceAliases`, `talk.modelId`, `talk.outputFormat`, `talk.apiKey`) are compatibility-only. Run `openclaw doctor --fix` to rewrite persisted config into `talk.providers.`. - Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`. - `providers.*.apiKey` accepts plaintext strings or SecretRef objects. - `ELEVENLABS_API_KEY` fallback applies only when no Talk API key is configured. diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index 23baeea6983..7778709056c 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -345,7 +345,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat - `channels.discord.voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts (`30000` by default). - `channels.discord.voice.reconnectGraceMs` controls how long a disconnected voice session may take to enter reconnect signalling before OpenClaw destroys it (`15000` by default). - OpenClaw additionally attempts voice receive recovery by leaving/rejoining a voice session after repeated decrypt failures. -- `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated. +- `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values remain runtime aliases; run `openclaw doctor --fix` to rewrite persisted config. - `channels.discord.autoPresence` maps runtime availability to bot presence (healthy => online, degraded => idle, exhausted => dnd) and allows optional status text overrides. - `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode). - `channels.discord.execApprovals`: Discord-native exec approval delivery and approver authorization. @@ -475,7 +475,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat resolve the secret value. - `configWrites: false` blocks Slack-initiated config writes. - Optional `channels.slack.defaultAccount` overrides default account selection when it matches a configured account id. -- `channels.slack.streaming.mode` is the canonical Slack stream mode key. `channels.slack.streaming.nativeTransport` controls Slack's native streaming transport. Legacy `streamMode`, boolean `streaming`, and `nativeStreaming` values are auto-migrated. +- `channels.slack.streaming.mode` is the canonical Slack stream mode key. `channels.slack.streaming.nativeTransport` controls Slack's native streaming transport. Legacy `streamMode`, boolean `streaming`, and `nativeStreaming` values remain runtime aliases; run `openclaw doctor --fix` to rewrite persisted config. - Use `user:` (DM) or `channel:` for delivery targets. **Reaction notification modes:** `off`, `own` (default), `all`, `allowlist` (from `reactionAllowlist`). diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 82cb183db6e..7c6bac71f87 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -185,7 +185,7 @@ That stages grounded durable candidates into the short-term dreaming store while - Show the migration it applied. - Rewrite `~/.openclaw/openclaw.json` with the updated schema. - The Gateway also auto-runs doctor migrations on startup when it detects a legacy config format, so stale configs are repaired without manual intervention. Cron job store migrations are handled by `openclaw doctor --fix`. + Gateway startup refuses legacy config formats and asks you to run `openclaw doctor --fix`; it does not rewrite `openclaw.json` on startup. Cron job store migrations are also handled by `openclaw doctor --fix`. Current migrations: diff --git a/docs/help/faq.md b/docs/help/faq.md index 15ebbc82bbb..97ccea1412a 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -695,7 +695,7 @@ lives on the [First-run FAQ](/help/faq-first-run). - OpenClaw enforces gateway auth by default, including loopback. In the normal default path that means token auth: if no explicit auth path is configured, gateway startup resolves to token mode and auto-generates one, saving it to `gateway.auth.token`, so **local WS clients must authenticate**. This blocks other local processes from calling the Gateway. + OpenClaw enforces gateway auth by default, including loopback. In the normal default path that means token auth: if no explicit auth path is configured, gateway startup resolves to token mode and generates a runtime-only token for that startup, so **local WS clients must authenticate**. Configure `gateway.auth.token`, `gateway.auth.password`, `OPENCLAW_GATEWAY_TOKEN`, or `OPENCLAW_GATEWAY_PASSWORD` explicitly when clients need a stable secret across restarts. This blocks other local processes from calling the Gateway. If you prefer a different auth path, you can explicitly choose password mode (or, for identity-aware reverse proxies, `trusted-proxy`). If you **really** want open loopback, set `gateway.auth.mode: "none"` explicitly in your config. Doctor can generate a token for you any time: `openclaw doctor --generate-gateway-token`. diff --git a/docs/install/gcp.md b/docs/install/gcp.md index 4dc98ee91b1..7d0322ee244 100644 --- a/docs/install/gcp.md +++ b/docs/install/gcp.md @@ -220,10 +220,11 @@ For the generic Docker flow, see [Docker](/install/docker). XDG_CONFIG_HOME=/home/node/.openclaw ``` - Leave `OPENCLAW_GATEWAY_TOKEN` blank unless you explicitly want to - manage it through `.env`; OpenClaw writes a random gateway token to - config on first start. Generate a keyring password and paste it into - `GOG_KEYRING_PASSWORD`: + Set `OPENCLAW_GATEWAY_TOKEN` when you want to manage the stable gateway + token through `.env`; otherwise configure `gateway.auth.token` before + relying on clients across restarts. If neither source exists, OpenClaw uses + a runtime-only token for that startup. Generate a keyring password and paste + it into `GOG_KEYRING_PASSWORD`: ```bash openssl rand -hex 32 diff --git a/docs/install/hetzner.md b/docs/install/hetzner.md index 3ad3198b146..df2c2282b88 100644 --- a/docs/install/hetzner.md +++ b/docs/install/hetzner.md @@ -145,10 +145,11 @@ For the generic Docker flow, see [Docker](/install/docker). XDG_CONFIG_HOME=/home/node/.openclaw ``` - Leave `OPENCLAW_GATEWAY_TOKEN` blank unless you explicitly want to - manage it through `.env`; OpenClaw writes a random gateway token to - config on first start. Generate a keyring password and paste it into - `GOG_KEYRING_PASSWORD`: + Set `OPENCLAW_GATEWAY_TOKEN` when you want to manage the stable gateway + token through `.env`; otherwise configure `gateway.auth.token` before + relying on clients across restarts. If neither source exists, OpenClaw uses + a runtime-only token for that startup. Generate a keyring password and paste + it into `GOG_KEYRING_PASSWORD`: ```bash openssl rand -hex 32 diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 4421ec627e0..9dd093b54f8 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -488,7 +488,10 @@ Key ideas: - Tailscale Serve identity headers and `gateway.auth.mode: "trusted-proxy"` do **not** authenticate this standalone loopback browser API. - If browser control is enabled and no shared-secret auth is configured, OpenClaw - auto-generates `gateway.auth.token` on startup and persists it to config. + generates a runtime-only gateway token for that startup. Configure + `gateway.auth.token`, `gateway.auth.password`, `OPENCLAW_GATEWAY_TOKEN`, or + `OPENCLAW_GATEWAY_PASSWORD` explicitly if clients need a stable secret across + restarts. - OpenClaw does **not** auto-generate that token when `gateway.auth.mode` is already `password`, `none`, or `trusted-proxy`. - Keep the Gateway and any node hosts on a private network (Tailscale); avoid public exposure. diff --git a/src/config/config.ts b/src/config/config.ts index a86eb5d556a..9ce646c9b02 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -44,6 +44,10 @@ export type { ReadConfigFileSnapshotWithPluginMetadataResult, } from "./io.js"; export { ConfigMutationConflictError, mutateConfigFile, replaceConfigFile } from "./mutate.js"; +export { + assertConfigWriteAllowedInCurrentMode, + NixModeConfigMutationError, +} from "./nix-mode-write-guard.js"; export * from "./paths.js"; export * from "./recovery-policy.js"; export * from "./runtime-overrides.js"; diff --git a/src/config/io.owner-display-secret.test.ts b/src/config/io.owner-display-secret.test.ts index db76ad016c6..8dac53cf3fc 100644 --- a/src/config/io.owner-display-secret.test.ts +++ b/src/config/io.owner-display-secret.test.ts @@ -1,29 +1,18 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { - type OwnerDisplaySecretPersistState, - persistGeneratedOwnerDisplaySecret, + type OwnerDisplaySecretRuntimeState, + retainGeneratedOwnerDisplaySecret, } from "./io.owner-display-secret.js"; import type { OpenClawConfig } from "./types.openclaw.js"; -function createState(): OwnerDisplaySecretPersistState { +function createState(): OwnerDisplaySecretRuntimeState { return { pendingByPath: new Map(), - persistInFlight: new Set(), - persistWarned: new Set(), }; } -async function flushAsyncWork(): Promise { - await Promise.resolve(); - await Promise.resolve(); -} - -describe("persistGeneratedOwnerDisplaySecret", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("persists generated owner display secrets once and clears state on success", async () => { +describe("retainGeneratedOwnerDisplaySecret", () => { + it("keeps generated owner display secrets in runtime state without persisting config", () => { const state = createState(); const configPath = "/tmp/openclaw.json"; const config = { @@ -32,84 +21,22 @@ describe("persistGeneratedOwnerDisplaySecret", () => { ownerDisplaySecret: "generated-owner-secret", }, } as OpenClawConfig; - const persistConfig = vi.fn(async () => undefined); - const result = persistGeneratedOwnerDisplaySecret({ + const result = retainGeneratedOwnerDisplaySecret({ config, configPath, generatedSecret: "generated-owner-secret", - logger: { warn: vi.fn() }, state, - persistConfig, }); expect(result).toBe(config); expect(state.pendingByPath.get(configPath)).toBe("generated-owner-secret"); - expect(state.persistInFlight.has(configPath)).toBe(true); - expect(persistConfig).toHaveBeenCalledTimes(1); - expect(persistConfig).toHaveBeenCalledWith(config, { - expectedConfigPath: configPath, - }); - - await flushAsyncWork(); - - expect(state.pendingByPath.has(configPath)).toBe(false); - expect(state.persistInFlight.has(configPath)).toBe(false); - expect(state.persistWarned.has(configPath)).toBe(false); - }); - - it("warns once and keeps the generated secret pending when persistence fails", async () => { - const state = createState(); - const configPath = "/tmp/openclaw.json"; - const config = { - commands: { - ownerDisplay: "hash", - ownerDisplaySecret: "generated-owner-secret", - }, - } as OpenClawConfig; - const warn = vi.fn(); - const persistConfig = vi.fn(async () => { - throw new Error("disk full"); - }); - - persistGeneratedOwnerDisplaySecret({ - config, - configPath, - generatedSecret: "generated-owner-secret", - logger: { warn }, - state, - persistConfig, - }); - - await flushAsyncWork(); - - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("Failed to persist auto-generated commands.ownerDisplaySecret"), - ); - expect(state.pendingByPath.get(configPath)).toBe("generated-owner-secret"); - expect(state.persistInFlight.has(configPath)).toBe(false); - expect(state.persistWarned.has(configPath)).toBe(true); - - persistGeneratedOwnerDisplaySecret({ - config, - configPath, - generatedSecret: "generated-owner-secret", - logger: { warn }, - state, - persistConfig, - }); - - await flushAsyncWork(); - - expect(warn).toHaveBeenCalledTimes(1); }); it("clears pending state when no generated secret is present", () => { const state = createState(); const configPath = "/tmp/openclaw.json"; state.pendingByPath.set(configPath, "stale-secret"); - state.persistWarned.add(configPath); const config = { commands: { ownerDisplay: "hash", @@ -117,16 +44,13 @@ describe("persistGeneratedOwnerDisplaySecret", () => { }, } as OpenClawConfig; - const result = persistGeneratedOwnerDisplaySecret({ + const result = retainGeneratedOwnerDisplaySecret({ config, configPath, - logger: { warn: vi.fn() }, state, - persistConfig: vi.fn(async () => undefined), }); expect(result).toBe(config); expect(state.pendingByPath.has(configPath)).toBe(false); - expect(state.persistWarned.has(configPath)).toBe(false); }); }); diff --git a/src/config/io.owner-display-secret.ts b/src/config/io.owner-display-secret.ts index b61b876ffb1..d5a1777f85f 100644 --- a/src/config/io.owner-display-secret.ts +++ b/src/config/io.owner-display-secret.ts @@ -1,49 +1,21 @@ import type { OpenClawConfig } from "./types.openclaw.js"; -export type OwnerDisplaySecretPersistState = { +export type OwnerDisplaySecretRuntimeState = { pendingByPath: Map; - persistInFlight: Set; - persistWarned: Set; }; -export function persistGeneratedOwnerDisplaySecret(params: { +export function retainGeneratedOwnerDisplaySecret(params: { config: OpenClawConfig; configPath: string; generatedSecret?: string; - logger: Pick; - state: OwnerDisplaySecretPersistState; - persistConfig: ( - config: OpenClawConfig, - options: { expectedConfigPath: string }, - ) => Promise; + state: OwnerDisplaySecretRuntimeState; }): OpenClawConfig { - const { config, configPath, generatedSecret, logger, state, persistConfig } = params; + const { config, configPath, generatedSecret, state } = params; if (!generatedSecret) { state.pendingByPath.delete(configPath); - state.persistWarned.delete(configPath); return config; } state.pendingByPath.set(configPath, generatedSecret); - if (!state.persistInFlight.has(configPath)) { - state.persistInFlight.add(configPath); - void persistConfig(config, { expectedConfigPath: configPath }) - .then(() => { - state.pendingByPath.delete(configPath); - state.persistWarned.delete(configPath); - }) - .catch((err) => { - if (!state.persistWarned.has(configPath)) { - state.persistWarned.add(configPath); - logger.warn( - `Failed to persist auto-generated commands.ownerDisplaySecret at ${configPath}: ${String(err)}`, - ); - } - }) - .finally(() => { - state.persistInFlight.delete(configPath); - }); - } - return config; } diff --git a/src/config/io.ts b/src/config/io.ts index 91d045b4551..40248e97719 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -57,7 +57,7 @@ import { promoteConfigSnapshotToLastKnownGood as promoteConfigSnapshotToLastKnownGoodWithDeps, recoverConfigFromLastKnownGood as recoverConfigFromLastKnownGoodWithDeps, } from "./io.observe-recovery.js"; -import { persistGeneratedOwnerDisplaySecret } from "./io.owner-display-secret.js"; +import { retainGeneratedOwnerDisplaySecret } from "./io.owner-display-secret.js"; import { collectChangedPaths, createMergePatch, @@ -75,6 +75,7 @@ import { materializeRuntimeConfig, } from "./materialize.js"; import { applyMergePatch } from "./merge-patch.js"; +import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js"; import { resolveConfigPath, resolveIncludeRoots, resolveStateDir } from "./paths.js"; import { extractShippedPluginInstallConfigRecords, @@ -144,6 +145,7 @@ type ShippedPluginInstallConfigWriteMigration = type ShippedPluginInstallConfigReadMigration = { config: unknown; + validationConfig?: unknown; persistedRootParsed?: unknown; persistedRootRaw?: string; }; @@ -1254,17 +1256,13 @@ export function createConfigIO( cfg, () => pendingSecret ?? crypto.randomBytes(32).toString("hex"), ); - const cfgWithOwnerDisplaySecret = persistGeneratedOwnerDisplaySecret({ + const cfgWithOwnerDisplaySecret = retainGeneratedOwnerDisplaySecret({ config: ownerDisplaySecretResolution.config, configPath, generatedSecret: ownerDisplaySecretResolution.generatedSecret, - logger: deps.logger, state: { pendingByPath: AUTO_OWNER_DISPLAY_SECRET_BY_PATH, - persistInFlight: AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT, - persistWarned: AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED, }, - persistConfig: (nextConfig, options) => writeConfigFile(nextConfig, options), }); return applyConfigOverrides(cfgWithOwnerDisplaySecret); @@ -1335,7 +1333,7 @@ export function createConfigIO( return { config: stripped }; } if (options.persist === false) { - return { config: stripped }; + return { config: configRaw, validationConfig: stripped }; } try { @@ -1389,6 +1387,23 @@ export function createConfigIO( return { config: stripped }; } + function retainRuntimeOnlyShippedPluginInstallConfigRecords( + config: OpenClawConfig, + sourceRaw: unknown, + ): OpenClawConfig { + const installRecords = extractShippedPluginInstallConfigRecords(sourceRaw); + if (Object.keys(installRecords).length === 0) { + return config; + } + return { + ...config, + plugins: { + ...config.plugins, + installs: installRecords, + }, + }; + } + function ensureShippedPluginInstallConfigRecordsMigratedForWrite( snapshot: ConfigFileSnapshot, ): ShippedPluginInstallConfigWriteMigration { @@ -1491,9 +1506,11 @@ export function createConfigIO( ); const resolvedConfig = readResolution.resolvedConfigRaw; const installMigration = migrateAndStripShippedPluginInstallConfigRecords(resolvedConfig, { + persist: false, rootConfigRaw: parsed, }); const effectiveConfigRaw = installMigration.config; + const validationConfigRaw = installMigration.validationConfig ?? effectiveConfigRaw; const snapshotRaw = installMigration.persistedRootRaw ?? raw; const snapshotParsed = installMigration.persistedRootParsed ?? parsed; const hash = hashConfigRaw(snapshotRaw); @@ -1502,8 +1519,8 @@ export function createConfigIO( `Config (${configPath}): missing env var "${w.varName}" at ${w.configPath} - feature using this value will be unavailable`, ); } - warnOnConfigMiskeys(effectiveConfigRaw, deps.logger); - if (typeof effectiveConfigRaw !== "object" || effectiveConfigRaw === null) { + warnOnConfigMiskeys(validationConfigRaw, deps.logger); + if (typeof validationConfigRaw !== "object" || validationConfigRaw === null) { observeLoadConfigSnapshot({ ...createConfigFileSnapshot({ path: configPath, @@ -1521,10 +1538,13 @@ export function createConfigIO( }); return {}; } - const preValidationDuplicates = findDuplicateAgentDirs(effectiveConfigRaw as OpenClawConfig, { - env: deps.env, - homedir: deps.homedir, - }); + const preValidationDuplicates = findDuplicateAgentDirs( + validationConfigRaw as OpenClawConfig, + { + env: deps.env, + homedir: deps.homedir, + }, + ); if (preValidationDuplicates.length > 0) { throw new DuplicateAgentDirError(preValidationDuplicates); } @@ -1533,15 +1553,19 @@ export function createConfigIO( if (pluginMetadataSnapshot) { return pluginMetadataSnapshot; } - const defaultAgentId = resolveDefaultAgentId(config); - pluginMetadataSnapshot = loadPluginMetadataSnapshot({ + const metadataConfig = retainRuntimeOnlyShippedPluginInstallConfigRecords( config, - workspaceDir: resolveAgentWorkspaceDir(config, defaultAgentId), + effectiveConfigRaw, + ); + const defaultAgentId = resolveDefaultAgentId(metadataConfig); + pluginMetadataSnapshot = loadPluginMetadataSnapshot({ + config: metadataConfig, + workspaceDir: resolveAgentWorkspaceDir(metadataConfig, defaultAgentId), env: deps.env, }); return pluginMetadataSnapshot; }; - const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { + const validated = validateConfigObjectWithPlugins(validationConfigRaw, { env: deps.env, pluginValidation: overrides.pluginValidation, loadPluginMetadataSnapshot: loadValidationPluginMetadataSnapshot, @@ -1580,9 +1604,12 @@ export function createConfigIO( deps.logger.warn(`Config warnings:\n${details}`); } warnIfConfigFromFuture(validated.config, deps.logger); - const cfg = materializeRuntimeConfig(validated.config, "load", { - manifestRegistry: pluginMetadataSnapshot?.manifestRegistry, - }); + const cfg = retainRuntimeOnlyShippedPluginInstallConfigRecords( + materializeRuntimeConfig(validated.config, "load", { + manifestRegistry: pluginMetadataSnapshot?.manifestRegistry, + }), + effectiveConfigRaw, + ); observeLoadConfigSnapshot({ ...createConfigFileSnapshot({ path: configPath, @@ -1614,11 +1641,7 @@ export function createConfigIO( } } - async function readConfigFileSnapshotInternal( - options: { - persistShippedPluginInstallMigration?: boolean; - } = {}, - ): Promise { + async function readConfigFileSnapshotInternal(): Promise { maybeLoadDotEnvForConfig(deps.env); const exists = deps.fs.existsSync(configPath); if (!exists) { @@ -1729,11 +1752,12 @@ export function createConfigIO( "config.snapshot.read.plugin-install-migration", () => migrateAndStripShippedPluginInstallConfigRecords(resolvedConfigRaw, { - persist: options.persistShippedPluginInstallMigration !== false, + persist: false, rootConfigRaw: effectiveParsed, }), ); const effectiveConfigRaw = installMigration.config; + const validationConfigRaw = installMigration.validationConfig ?? effectiveConfigRaw; const snapshotRaw = installMigration.persistedRootRaw ?? raw; const snapshotParsed = installMigration.persistedRootParsed ?? effectiveParsed; const snapshotHash = installMigration.persistedRootRaw @@ -1745,16 +1769,20 @@ export function createConfigIO( if (pluginMetadataSnapshot) { return pluginMetadataSnapshot; } - const defaultAgentId = resolveDefaultAgentId(config); - pluginMetadataSnapshot = loadPluginMetadataSnapshot({ + const metadataConfig = retainRuntimeOnlyShippedPluginInstallConfigRecords( config, - workspaceDir: resolveAgentWorkspaceDir(config, defaultAgentId), + effectiveConfigRaw, + ); + const defaultAgentId = resolveDefaultAgentId(metadataConfig); + pluginMetadataSnapshot = loadPluginMetadataSnapshot({ + config: metadataConfig, + workspaceDir: resolveAgentWorkspaceDir(metadataConfig, defaultAgentId), env: deps.env, }); return pluginMetadataSnapshot; }; const validated = await deps.measure("config.snapshot.read.validate", () => - validateConfigObjectWithPlugins(effectiveConfigRaw, { + validateConfigObjectWithPlugins(validationConfigRaw, { env: deps.env, pluginValidation: overrides.pluginValidation, loadPluginMetadataSnapshot: loadValidationPluginMetadataSnapshot, @@ -1784,9 +1812,12 @@ export function createConfigIO( warnIfConfigFromFuture(validated.config, deps.logger); const snapshotConfig = await deps.measure("config.snapshot.read.materialize", () => - materializeRuntimeConfig(validated.config, "snapshot", { - manifestRegistry: pluginMetadataSnapshot?.manifestRegistry, - }), + retainRuntimeOnlyShippedPluginInstallConfigRecords( + materializeRuntimeConfig(validated.config, "snapshot", { + manifestRegistry: pluginMetadataSnapshot?.manifestRegistry, + }), + effectiveConfigRaw, + ), ); return await deps.measure("config.snapshot.read.observe", () => finalizeReadConfigSnapshotInternalResult(deps, { @@ -1892,9 +1923,7 @@ export function createConfigIO( } async function readConfigFileSnapshotForWrite(): Promise { - const result = await readConfigFileSnapshotInternal({ - persistShippedPluginInstallMigration: false, - }); + const result = await readConfigFileSnapshotInternal(); return { snapshot: result.snapshot, writeOptions: { @@ -1949,16 +1978,11 @@ export function createConfigIO( cfg: OpenClawConfig, options: ConfigWriteOptions = {}, ): Promise<{ persistedHash: string; persistedConfig: OpenClawConfig }> { + assertConfigWriteAllowedInCurrentMode({ configPath, env: deps.env }); clearConfigCache(); const unsetPaths = resolveManagedUnsetPathsForWrite(options.unsetPaths); let persistCandidate: unknown = cfg; - const snapshot = - options.baseSnapshot ?? - ( - await readConfigFileSnapshotInternal({ - persistShippedPluginInstallMigration: false, - }) - ).snapshot; + const snapshot = options.baseSnapshot ?? (await readConfigFileSnapshotInternal()).snapshot; let envRefMap: Map | null = null; let changedPaths: Set | null = null; if (snapshot.valid && snapshot.exists) { @@ -2241,8 +2265,6 @@ export function createConfigIO( // module scope. `OPENCLAW_CONFIG_PATH` (and friends) are expected to work even // when set after the module has been imported (tests, one-off scripts, etc.). const AUTO_OWNER_DISPLAY_SECRET_BY_PATH = new Map(); -const AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT = new Set(); -const AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED = new Set(); export function clearConfigCache(): void { // Compat shim: runtime snapshot is the only in-process cache now. } @@ -2381,6 +2403,7 @@ export async function writeConfigFile( options: ConfigWriteOptions = {}, ): Promise { const io = createConfigIO(options.skipPluginValidation ? { pluginValidation: "skip" } : {}); + assertConfigWriteAllowedInCurrentMode({ configPath: io.configPath }); let nextCfg = cfg; const runtimeConfigSnapshot = getRuntimeConfigSnapshotState(); const runtimeConfigSourceSnapshot = getRuntimeConfigSourceSnapshotState(); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index ba1d1c46a82..0b7705a8f2a 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -172,7 +172,31 @@ describe("config io write", () => { }); }); - it("migrates shipped plugin install config records into the plugin index", async () => { + it("refuses direct config writes in Nix mode without changing the file", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + const initialRaw = `${JSON.stringify({ gateway: { mode: "local" } }, null, 2)}\n`; + await fs.writeFile(configPath, initialRaw, "utf-8"); + const io = createConfigIO({ + configPath, + env: { + OPENCLAW_NIX_MODE: "1", + OPENCLAW_TEST_FAST: "1", + } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }); + + await expect(io.writeConfigFile({ gateway: { mode: "local", port: 19001 } })).rejects.toThrow( + "Agent-first Nix setup: https://github.com/openclaw/nix-openclaw#quick-start", + ); + + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(initialRaw); + }); + }); + + it("loads shipped plugin install config records without mutating config or plugin index", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); const pluginDir = path.join(home, ".openclaw", "plugins", "demo"); @@ -229,9 +253,103 @@ describe("config io write", () => { const io = createFastConfigIO(home); try { + const initialRaw = await fs.readFile(configPath, "utf-8"); const cfg = io.loadConfig(); - expect(cfg.plugins?.installs).toBeUndefined(); + expect(cfg.plugins?.installs?.demo).toMatchObject({ + source: "npm", + spec: "demo@1.0.0", + installPath: pluginDir, + }); + const snapshot = await io.readConfigFileSnapshot(); + expect(snapshot.sourceConfig.plugins?.installs?.demo).toMatchObject({ + source: "npm", + spec: "demo@1.0.0", + installPath: pluginDir, + }); + expect(snapshot.runtimeConfig.plugins?.installs?.demo).toMatchObject({ + source: "npm", + spec: "demo@1.0.0", + installPath: pluginDir, + }); + await expect( + readPersistedInstalledPluginIndex({ + stateDir: path.join(home, ".openclaw"), + }), + ).resolves.toBeNull(); + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(initialRaw); + } finally { + mockLoadPluginManifestRegistry.mockReturnValue({ + diagnostics: [], + plugins: [], + } satisfies PluginManifestRegistry); + } + }); + }); + + it("migrates shipped plugin install config records into the plugin index during explicit writes", async () => { + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const pluginDir = path.join(home, ".openclaw", "plugins", "demo"); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + const source = path.join(pluginDir, "index.ts"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile(source, "export function register() {}\n", "utf-8"); + await fs.writeFile( + manifestPath, + `${JSON.stringify({ id: "demo", configSchema: { type: "object" } }, null, 2)}\n`, + "utf-8", + ); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + plugins: { + entries: { demo: { enabled: true } }, + installs: { + demo: { + source: "npm", + spec: "demo@1.0.0", + installPath: pluginDir, + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + mockLoadPluginManifestRegistry.mockReturnValue({ + diagnostics: [], + plugins: [ + { + id: "demo", + origin: "global", + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + rootDir: pluginDir, + source, + manifestPath, + configSchema: { + type: "object", + }, + }, + ], + } satisfies PluginManifestRegistry); + + const io = createFastConfigIO(home); + try { + await io.writeConfigFile({ + plugins: { + entries: { demo: { enabled: true } }, + }, + }); + await expect( readPersistedInstalledPluginIndex({ stateDir: path.join(home, ".openclaw"), @@ -264,7 +382,7 @@ describe("config io write", () => { }); }); - it("migrates shipped plugin install config records even when the manifest is missing", async () => { + it("migrates shipped plugin install config records during explicit writes even when the manifest is missing", async () => { await withSuiteHome(async (home) => { const configPath = path.join(home, ".openclaw", "openclaw.json"); const pluginDir = path.join(home, ".openclaw", "plugins", "missing"); @@ -291,9 +409,12 @@ describe("config io write", () => { ); const io = createFastConfigIO(home); - const cfg = io.loadConfig(); + await io.writeConfigFile({ + plugins: { + entries: { missing: { enabled: true } }, + }, + }); - expect(cfg.plugins?.installs).toBeUndefined(); await expect( readPersistedInstalledPluginIndex({ stateDir: path.join(home, ".openclaw"), @@ -342,8 +463,16 @@ describe("config io write", () => { }); await fs.writeFile(path.join(unwritableStatePath, "plugins"), "not a directory", "utf-8"); - expect(() => io.loadConfig()).toThrow('Unrecognized key: "installs"'); - expect(warn).toHaveBeenCalledWith( + let loadedConfig: ReturnType | undefined; + expect(() => { + loadedConfig = io.loadConfig(); + }).not.toThrow(); + expect(loadedConfig?.plugins?.installs?.demo).toMatchObject({ + source: "npm", + spec: "demo@1.0.0", + installPath: pluginDir, + }); + expect(warn).not.toHaveBeenCalledWith( expect.stringContaining("could not migrate shipped plugins.installs records"), ); diff --git a/src/config/mutate.test.ts b/src/config/mutate.test.ts index 8a241c95766..210b1c733e9 100644 --- a/src/config/mutate.test.ts +++ b/src/config/mutate.test.ts @@ -43,12 +43,18 @@ function createSnapshot(params: { describe("config mutate helpers", () => { const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-config-mutate-" }); + const originalNixMode = process.env.OPENCLAW_NIX_MODE; beforeAll(async () => { await suiteRootTracker.setup(); }); afterAll(async () => { + if (originalNixMode === undefined) { + delete process.env.OPENCLAW_NIX_MODE; + } else { + process.env.OPENCLAW_NIX_MODE = originalNixMode; + } await suiteRootTracker.cleanup(); }); @@ -58,6 +64,7 @@ describe("config mutate helpers", () => { ioMocks.resolveConfigSnapshotHash.mockImplementation( (snapshot: { hash?: string }) => snapshot.hash ?? null, ); + delete process.env.OPENCLAW_NIX_MODE; }); it("mutates source config with optimistic hash protection", async () => { @@ -118,6 +125,50 @@ describe("config mutate helpers", () => { expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); }); + it("refuses replace writes in Nix mode before touching disk", async () => { + process.env.OPENCLAW_NIX_MODE = "1"; + const snapshot = createSnapshot({ + hash: "hash-1", + sourceConfig: { gateway: { port: 18789 } }, + }); + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot, + writeOptions: { expectedConfigPath: snapshot.path }, + }); + + await expect( + replaceConfigFile({ + nextConfig: { gateway: { port: 19001 } }, + }), + ).rejects.toThrow( + "Agent-first Nix setup: https://github.com/openclaw/nix-openclaw#quick-start", + ); + + expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("refuses mutate writes in Nix mode before touching disk", async () => { + process.env.OPENCLAW_NIX_MODE = "1"; + const snapshot = createSnapshot({ + hash: "hash-1", + sourceConfig: { gateway: { port: 18789 } }, + }); + ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot, + writeOptions: { expectedConfigPath: snapshot.path }, + }); + + await expect( + mutateConfigFile({ + mutate(draft) { + draft.gateway = { ...draft.gateway, port: 19001 }; + }, + }), + ).rejects.toThrow("OpenClaw Nix overview: https://docs.openclaw.ai/install/nix"); + + expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); + }); + it("reuses a provided snapshot and write options for replace", async () => { const snapshot = createSnapshot({ hash: "hash-1", diff --git a/src/config/mutate.ts b/src/config/mutate.ts index 851228d4b5e..3e6110b7775 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -15,6 +15,7 @@ import { type ConfigWriteOptions, } from "./io.js"; import { applyUnsetPathsForWrite, resolveManagedUnsetPathsForWrite } from "./io.write-prepare.js"; +import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js"; import { createRuntimeConfigWriteNotification, finalizeRuntimeSnapshotWrite, @@ -228,6 +229,7 @@ export async function replaceConfigFile(params: { ? { snapshot: params.snapshot, writeOptions: params.writeOptions } : await (params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite)(); const { snapshot, writeOptions } = prepared; + assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path }); const previousHash = assertBaseHashMatches(snapshot, params.baseHash); const afterWrite = resolveConfigWriteAfterWrite( params.afterWrite ?? params.writeOptions?.afterWrite, @@ -271,6 +273,7 @@ export async function mutateConfigFile(params: { const { snapshot, writeOptions } = await ( params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite )(); + assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path }); const previousHash = assertBaseHashMatches(snapshot, params.baseHash); const baseConfig = params.base === "runtime" ? snapshot.runtimeConfig : snapshot.sourceConfig; const draft = structuredClone(baseConfig) as OpenClawConfig; diff --git a/src/config/nix-mode-write-guard.ts b/src/config/nix-mode-write-guard.ts new file mode 100644 index 00000000000..e52add04887 --- /dev/null +++ b/src/config/nix-mode-write-guard.ts @@ -0,0 +1,37 @@ +import { resolveIsNixMode } from "./paths.js"; + +export const NIX_OPENCLAW_AGENT_FIRST_URL = "https://github.com/openclaw/nix-openclaw#quick-start"; +export const OPENCLAW_NIX_OVERVIEW_URL = "https://docs.openclaw.ai/install/nix"; + +export class NixModeConfigMutationError extends Error { + readonly code = "OPENCLAW_NIX_MODE_CONFIG_IMMUTABLE"; + + constructor(params: { configPath?: string } = {}) { + super(formatNixModeConfigMutationMessage(params)); + this.name = "NixModeConfigMutationError"; + } +} + +export function formatNixModeConfigMutationMessage(params: { configPath?: string } = {}): string { + return [ + "Config is managed by Nix (`OPENCLAW_NIX_MODE=1`), so OpenClaw treats openclaw.json as immutable.", + "This usually means nix-openclaw, the first-party Nix distribution, or another Nix-managed package set this mode.", + ...(params.configPath ? [`Config path: ${params.configPath}`] : []), + "Do not run setup, onboarding, openclaw update, plugin install/update/uninstall/enable, doctor repair/token-generation, or config set against this file.", + "Edit the Nix source for this install instead. For nix-openclaw, edit `programs.openclaw.config` or `instances..config`, then rebuild with Home Manager or NixOS.", + `Agent-first Nix setup: ${NIX_OPENCLAW_AGENT_FIRST_URL}`, + `OpenClaw Nix overview: ${OPENCLAW_NIX_OVERVIEW_URL}`, + ].join("\n"); +} + +export function assertConfigWriteAllowedInCurrentMode( + params: { + configPath?: string; + env?: NodeJS.ProcessEnv; + } = {}, +): void { + if (!resolveIsNixMode(params.env)) { + return; + } + throw new NixModeConfigMutationError({ configPath: params.configPath }); +} diff --git a/src/gateway/server-startup-config.recovery.test.ts b/src/gateway/server-startup-config.recovery.test.ts index 5db0695bbf0..f5034fa3e7b 100644 --- a/src/gateway/server-startup-config.recovery.test.ts +++ b/src/gateway/server-startup-config.recovery.test.ts @@ -277,7 +277,7 @@ describe("gateway startup config validation", () => { }); }); - it("preserves empty model allowlist entries through startup auto-enable writes", async () => { + it("preserves empty model allowlist entries through runtime-only startup auto-enable", async () => { const sourceConfig = { agents: { defaults: { @@ -323,31 +323,10 @@ describe("gateway startup config validation", () => { runtimeConfig: sourceConfig, config: sourceConfig, } satisfies ConfigFileSnapshot; - const postWriteSnapshot = { - ...buildTestConfigSnapshot({ - path: configPath, - exists: true, - raw: `${JSON.stringify(autoEnabledConfig)}\n`, - parsed: autoEnabledConfig, - valid: true, - config: autoEnabledConfig, - issues: [], - legacyIssues: [], - }), - sourceConfig: autoEnabledConfig, - resolved: autoEnabledConfig, - runtimeConfig: autoEnabledConfig, - config: autoEnabledConfig, - } satisfies ConfigFileSnapshot; - vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata) - .mockResolvedValueOnce({ - snapshot: initialSnapshot, - pluginMetadataSnapshot, - }) - .mockResolvedValueOnce({ - snapshot: postWriteSnapshot, - pluginMetadataSnapshot, - }); + vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata).mockResolvedValueOnce({ + snapshot: initialSnapshot, + pluginMetadataSnapshot, + }); applyPluginAutoEnable.mockReturnValueOnce({ config: autoEnabledConfig, changes: ["Telegram configured, enabled automatically."], @@ -361,8 +340,12 @@ describe("gateway startup config validation", () => { log, }), ).resolves.toEqual({ - snapshot: postWriteSnapshot, - wroteConfig: true, + snapshot: { + ...initialSnapshot, + runtimeConfig: autoEnabledConfig, + config: autoEnabledConfig, + }, + wroteConfig: false, pluginMetadataSnapshot, }); @@ -371,27 +354,90 @@ describe("gateway startup config validation", () => { env: process.env, manifestRegistry: pluginManifestRegistry, }); - expect(configMutate.replaceConfigFile).toHaveBeenCalledWith({ - nextConfig: expect.objectContaining({ - agents: expect.objectContaining({ - defaults: expect.objectContaining({ - models: { - "dos-ai/dos-ai": {}, - "dos-ai/dos-auto": {}, - }, - }), - }), + expect(configMutate.replaceConfigFile).not.toHaveBeenCalled(); + expect(configIo.readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledTimes(1); + expect(initialSnapshot.sourceConfig.agents?.defaults?.models).toEqual({ + "dos-ai/dos-ai": {}, + "dos-ai/dos-auto": {}, + }); + expect(initialSnapshot.sourceConfig.channels?.telegram).toBeUndefined(); + expect(autoEnabledConfig.agents?.defaults?.models).toEqual({ + "dos-ai/dos-ai": {}, + "dos-ai/dos-auto": {}, + }); + expect(autoEnabledConfig.channels?.telegram).toEqual({ + enabled: true, + }); + expect(log.info).toHaveBeenCalledWith( + "gateway: auto-enabled plugins for this runtime without writing config:\n- Telegram configured, enabled automatically.", + ); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it("keeps plugin auto-enable runtime-only in Nix mode", async () => { + const sourceConfig = { + channels: { + telegram: { + botToken: "test-token", + }, + }, + gateway: { mode: "local" }, + } as unknown as OpenClawConfig; + const autoEnabledConfig = { + ...sourceConfig, + plugins: { + allow: ["telegram"], + }, + } as unknown as OpenClawConfig; + const snapshot = { + ...buildTestConfigSnapshot({ + path: configPath, + exists: true, + raw: `${JSON.stringify(sourceConfig)}\n`, + parsed: sourceConfig, + valid: true, + config: sourceConfig, + issues: [], + legacyIssues: [], }), - afterWrite: { mode: "auto" }, + sourceConfig, + resolved: sourceConfig, + runtimeConfig: sourceConfig, + config: sourceConfig, + } satisfies ConfigFileSnapshot; + vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata).mockResolvedValueOnce({ + snapshot, + pluginMetadataSnapshot, }); - expect(postWriteSnapshot.sourceConfig.agents?.defaults?.models).toEqual({ - "dos-ai/dos-ai": {}, - "dos-ai/dos-auto": {}, + applyPluginAutoEnable.mockReturnValueOnce({ + config: autoEnabledConfig, + changes: ["Telegram configured, enabled automatically."], + autoEnabledReasons: {}, }); - expect(postWriteSnapshot.config.agents?.defaults?.models).toEqual({ - "dos-ai/dos-ai": {}, - "dos-ai/dos-auto": {}, + configMocks.isNixMode.value = true; + const log = { info: vi.fn(), warn: vi.fn() }; + + await expect( + loadGatewayStartupConfigSnapshot({ + minimalTestGateway: false, + log, + }), + ).resolves.toEqual({ + snapshot: { + ...snapshot, + runtimeConfig: autoEnabledConfig, + config: autoEnabledConfig, + }, + wroteConfig: false, + pluginMetadataSnapshot, }); + + expect(configMutate.replaceConfigFile).not.toHaveBeenCalled(); + expect(configIo.readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledTimes(1); + expect(log.info).toHaveBeenCalledWith( + "gateway: auto-enabled plugins for this runtime without writing config:\n- Telegram configured, enabled automatically.", + ); + expect(log.warn).not.toHaveBeenCalled(); }); it("rejects invalid config before startup without automatic recovery", async () => { diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index e3f1565a006..fbec08b3322 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -63,14 +63,14 @@ export async function loadGatewayStartupConfigSnapshot(params: { initialSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult; }): Promise { const measure = params.measure ?? (async (_name, run) => await run()); - let snapshotRead = + const snapshotRead = params.initialSnapshotRead ?? (await measure("config.snapshot.read", () => readConfigFileSnapshotWithPluginMetadata({ measure }), )); - let configSnapshot = snapshotRead.snapshot; - let pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot; - let wroteConfig = false; + const configSnapshot = snapshotRead.snapshot; + const pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot; + const wroteConfig = false; if (configSnapshot.legacyIssues.length > 0 && isNixMode) { throw new Error( "Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.", @@ -99,33 +99,27 @@ export async function loadGatewayStartupConfigSnapshot(params: { }; } - try { - const { replaceConfigFile } = await import("../config/mutate.js"); - await replaceConfigFile({ - nextConfig: autoEnable.config, - afterWrite: { mode: "auto" }, - }); - wroteConfig = true; - snapshotRead = await measure("config.snapshot.auto-enable-read", () => - readConfigFileSnapshotWithPluginMetadata({ measure }), - ); - configSnapshot = snapshotRead.snapshot; - pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot; - assertValidGatewayStartupConfigSnapshot(configSnapshot); - params.log.info( - `gateway: auto-enabled plugins:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`, - ); - } catch (err) { - params.log.warn(`gateway: failed to persist plugin auto-enable changes: ${String(err)}`); - } - + params.log.info( + `gateway: auto-enabled plugins for this runtime without writing config:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`, + ); return { - snapshot: configSnapshot, + snapshot: withRuntimeConfig(configSnapshot, autoEnable.config), wroteConfig, ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), }; } +function withRuntimeConfig( + snapshot: ConfigFileSnapshot, + runtimeConfig: OpenClawConfig, +): ConfigFileSnapshot { + return { + ...snapshot, + runtimeConfig, + config: runtimeConfig, + }; +} + export function createRuntimeSecretsActivator(params: { logSecrets: GatewayStartupLog; emitStateEvent: ( @@ -278,7 +272,7 @@ export async function prepareGatewayStartupConfig(params: { env: process.env, authOverride: preflightAuthOverride, tailscaleOverride: params.tailscaleOverride, - persist: params.persistStartupAuth ?? true, + persist: params.persistStartupAuth ?? false, baseHash: params.configSnapshot.hash, }); const runtimeStartupConfig = applyGatewayAuthOverridesForStartupPreflight(authBootstrap.cfg, { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 0c7758653b3..ebc481b110e 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -397,6 +397,19 @@ function createGatewayStartupTrace() { }; } +function formatRuntimeGatewayAuthTokenWarning(): string { + const base = + "Gateway auth token was missing. Generated a runtime token for this startup without changing config; restart will generate a different token."; + if (!isNixMode) { + return `${base} Persist one with \`openclaw config set gateway.auth.mode token\` and \`openclaw config set gateway.auth.token \`.`; + } + return [ + base, + "In Nix mode, set gateway.auth.token in your Nix-managed OpenClaw config and rebuild.", + "For the first-party Nix flow, see https://github.com/openclaw/nix-openclaw#quick-start and https://docs.openclaw.ai/install/nix.", + ].join(" "); +} + function collectProcessMemoryUsageMb(): ReadonlyArray { const usage = process.memoryUsage(); const toMb = (bytes: number) => bytes / 1024 / 1024; @@ -577,21 +590,12 @@ export async function startGatewayServer( authOverride: opts.auth, tailscaleOverride: opts.tailscale, activateRuntimeSecrets, - persistStartupAuth: true, }), ); cfgAtStart = authBootstrap.cfg; startupTrace.setConfig(cfgAtStart); if (authBootstrap.generatedToken) { - if (authBootstrap.persistedGeneratedToken) { - log.info( - "Gateway auth token was missing. Generated a new token and saved it to config (gateway.auth.token).", - ); - } else { - log.warn( - "Gateway auth token was missing. Generated a runtime token for this startup without changing config; restart will generate a different token. Persist one with `openclaw config set gateway.auth.mode token` and `openclaw config set gateway.auth.token `.", - ); - } + log.warn(formatRuntimeGatewayAuthTokenWarning()); } const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart); setDiagnosticsEnabledForProcess(diagnosticsEnabled); @@ -610,31 +614,19 @@ export async function startGatewayServer( // Unconditional startup migration: seed gateway.controlUi.allowedOrigins for existing // non-loopback installs that upgraded to v2026.2.26+ without required origins. const controlUiSeed = minimalTestGateway - ? { config: cfgAtStart, persistedAllowedOriginsSeed: false } + ? { config: cfgAtStart, seededAllowedOrigins: false } : await startupTrace.measure("control-ui.seed", () => maybeSeedControlUiAllowedOriginsAtStartup({ config: cfgAtStart, - writeConfig: async (nextConfig) => { - const { replaceConfigFile } = await import("../config/mutate.js"); - await replaceConfigFile({ - nextConfig, - afterWrite: { mode: "auto" }, - }); - }, log, runtimeBind: opts.bind, runtimePort: port, }), ); cfgAtStart = controlUiSeed.config; - // Capture the final config hash only after startup writes (plugin auto-enable, - // auth token generation, control-UI origin seeding) so the config reloader can - // suppress its own persistence events without rereading config on every boot. - if ( - startupConfigLoad.wroteConfig || - authBootstrap.persistedGeneratedToken || - controlUiSeed.persistedAllowedOriginsSeed - ) { + // Keep the old startup-write suppression path intact for compatibility with + // callers that may still report a write, but startup itself no longer mutates config. + if (startupConfigLoad.wroteConfig || authBootstrap.persistedGeneratedToken) { const startupSnapshot = await startupTrace.measure("config.final-snapshot", () => readConfigFileSnapshot(), ); diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index 0432e6a773b..2b40af7fe41 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { expectGeneratedTokenPersistedToGatewayAuth } from "../test-utils/auth-token-assertions.js"; import { KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS } from "./known-weak-gateway-secrets.js"; import { assertGatewayAuthNotKnownWeak, @@ -96,7 +95,7 @@ describe("ensureGatewayStartupAuth", () => { }; } - it("generates and persists a token when startup auth is missing", async () => { + it("generates a runtime token without persisting when startup auth is missing", async () => { const result = await ensureGatewayStartupAuth({ cfg: {}, env: {} as NodeJS.ProcessEnv, @@ -104,17 +103,11 @@ describe("ensureGatewayStartupAuth", () => { }); expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); - expect(result.persistedGeneratedToken).toBe(true); + expect(result.persistedGeneratedToken).toBe(false); expect(result.auth.mode).toBe("token"); - expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(1); - const persistedParams = mocks.replaceConfigFile.mock.calls[0]?.[0] as - | { nextConfig: OpenClawConfig } - | undefined; - expectGeneratedTokenPersistedToGatewayAuth({ - generatedToken: result.generatedToken, - authToken: result.auth.token, - persistedConfig: persistedParams?.nextConfig, - }); + expect(result.auth.token).toBe(result.generatedToken); + expect(result.cfg.gateway?.auth?.token).toBe(result.generatedToken); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); }); it("does not generate when token already exists", async () => { diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts index 20fa35b67f2..46ee8556f92 100644 --- a/src/gateway/startup-auth.ts +++ b/src/gateway/startup-auth.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import { replaceConfigFile } from "../config/mutate.js"; import type { GatewayAuthConfig, GatewayTailscaleConfig } from "../config/types.gateway.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; @@ -83,23 +82,6 @@ function resolveGatewayAuthFromConfig(params: { }); } -function shouldPersistGeneratedToken(params: { - persistRequested: boolean; - resolvedAuth: ResolvedGatewayAuth; -}): boolean { - if (!params.persistRequested) { - return false; - } - - // Keep CLI/runtime mode overrides ephemeral: startup should not silently - // mutate durable auth policy when mode was chosen by an override flag. - if (params.resolvedAuth.modeSource === "override") { - return false; - } - - return true; -} - function hasGatewayTokenCandidate(params: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv; @@ -142,6 +124,10 @@ export async function ensureGatewayStartupAuth(params: { env?: NodeJS.ProcessEnv; authOverride?: GatewayAuthConfig; tailscaleOverride?: GatewayTailscaleConfig; + /** + * Legacy startup option retained for external callers. Startup-generated auth + * is runtime-only; durable auth changes must go through explicit config tools. + */ persist?: boolean; baseHash?: string; }): Promise<{ @@ -152,7 +138,6 @@ export async function ensureGatewayStartupAuth(params: { }> { assertExplicitGatewayAuthModeWhenBothConfigured(params.cfg); const env = params.env ?? process.env; - const persistRequested = params.persist === true; const explicitMode = params.authOverride?.mode ?? params.cfg.gateway?.auth?.mode; const [resolvedTokenRefValue, resolvedPasswordRefValue] = await Promise.all([ resolveGatewayTokenSecretRefValue({ @@ -213,17 +198,6 @@ export async function ensureGatewayStartupAuth(params: { }, }, }; - const persist = shouldPersistGeneratedToken({ - persistRequested, - resolvedAuth: resolved, - }); - if (persist) { - await replaceConfigFile({ - nextConfig: nextCfg, - baseHash: params.baseHash, - }); - } - const nextAuth = resolveGatewayAuthFromConfig({ cfg: nextCfg, env, @@ -240,7 +214,7 @@ export async function ensureGatewayStartupAuth(params: { cfg: nextCfg, auth: nextAuth, generatedToken, - persistedGeneratedToken: persist, + persistedGeneratedToken: false, }; } diff --git a/src/gateway/startup-control-ui-origins.test.ts b/src/gateway/startup-control-ui-origins.test.ts index 4e8e7d6bfe8..52712809787 100644 --- a/src/gateway/startup-control-ui-origins.test.ts +++ b/src/gateway/startup-control-ui-origins.test.ts @@ -3,25 +3,19 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { maybeSeedControlUiAllowedOriginsAtStartup } from "./startup-control-ui-origins.js"; describe("maybeSeedControlUiAllowedOriginsAtStartup", () => { - it("persists origins seeded from runtime bind and port", async () => { - const written: OpenClawConfig[] = []; + it("applies origins seeded from runtime bind and port without persisting config", async () => { const log = { info: vi.fn(), warn: vi.fn() }; const result = await maybeSeedControlUiAllowedOriginsAtStartup({ config: { gateway: {} }, - writeConfig: async (config) => { - written.push(config); - }, log, runtimeBind: "lan", runtimePort: 3000, }); const expectedOrigins = ["http://localhost:3000", "http://127.0.0.1:3000"]; - expect(result.persistedAllowedOriginsSeed).toBe(true); + expect(result.seededAllowedOrigins).toBe(true); expect(result.config.gateway?.controlUi?.allowedOrigins).toEqual(expectedOrigins); - expect(written).toHaveLength(1); - expect(written[0]?.gateway?.controlUi?.allowedOrigins).toEqual(expectedOrigins); expect(log.info).toHaveBeenCalledWith(expect.stringContaining("for bind=lan")); expect(log.warn).not.toHaveBeenCalled(); }); @@ -32,19 +26,16 @@ describe("maybeSeedControlUiAllowedOriginsAtStartup", () => { controlUi: { allowedOrigins: ["https://control.example.com"] }, }, }; - const writeConfig = vi.fn<() => Promise>(); const log = { info: vi.fn(), warn: vi.fn() }; const result = await maybeSeedControlUiAllowedOriginsAtStartup({ config, - writeConfig, log, runtimeBind: "lan", runtimePort: 3000, }); - expect(result).toEqual({ config, persistedAllowedOriginsSeed: false }); - expect(writeConfig).not.toHaveBeenCalled(); + expect(result).toEqual({ config, seededAllowedOrigins: false }); expect(log.info).not.toHaveBeenCalled(); expect(log.warn).not.toHaveBeenCalled(); }); diff --git a/src/gateway/startup-control-ui-origins.ts b/src/gateway/startup-control-ui-origins.ts index fc030f0a1cc..d4dfe9b65e8 100644 --- a/src/gateway/startup-control-ui-origins.ts +++ b/src/gateway/startup-control-ui-origins.ts @@ -7,35 +7,26 @@ import { isContainerEnvironment } from "./net.js"; export async function maybeSeedControlUiAllowedOriginsAtStartup(params: { config: OpenClawConfig; - writeConfig: (config: OpenClawConfig) => Promise; log: { info: (msg: string) => void; warn: (msg: string) => void }; runtimeBind?: unknown; runtimePort?: unknown; -}): Promise<{ config: OpenClawConfig; persistedAllowedOriginsSeed: boolean }> { +}): Promise<{ config: OpenClawConfig; seededAllowedOrigins: boolean }> { const seeded = ensureControlUiAllowedOriginsForNonLoopbackBind(params.config, { isContainerEnvironment, runtimeBind: params.runtimeBind, runtimePort: params.runtimePort, }); if (!seeded.seededOrigins || !seeded.bind) { - return { config: params.config, persistedAllowedOriginsSeed: false }; + return { config: params.config, seededAllowedOrigins: false }; } - try { - await params.writeConfig(seeded.config); - params.log.info(buildSeededOriginsInfoLog(seeded.seededOrigins, seeded.bind)); - return { config: seeded.config, persistedAllowedOriginsSeed: true }; - } catch (err) { - params.log.warn( - `gateway: failed to persist gateway.controlUi.allowedOrigins seed: ${String(err)}. The gateway will start with the in-memory value but config was not saved.`, - ); - } - return { config: seeded.config, persistedAllowedOriginsSeed: false }; + params.log.info(buildSeededOriginsInfoLog(seeded.seededOrigins, seeded.bind)); + return { config: seeded.config, seededAllowedOrigins: true }; } function buildSeededOriginsInfoLog(origins: string[], bind: GatewayNonLoopbackBindMode): string { return ( `gateway: seeded gateway.controlUi.allowedOrigins ${JSON.stringify(origins)} ` + `for bind=${bind} (required since v2026.2.26; see issue #29385). ` + - "Add other origins to gateway.controlUi.allowedOrigins if needed." + "Applied for this runtime without writing config; add other origins to gateway.controlUi.allowedOrigins if needed." ); }