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."
);
}