diff --git a/CHANGELOG.md b/CHANGELOG.md
index e657f46c2e9..38edf2eab50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -110,6 +110,7 @@ Docs: https://docs.openclaw.ai
- Managed proxy: add `proxy.loopbackMode` for Gateway loopback control-plane traffic, allowing operators to keep the default Gateway loopback bypass, force loopback Gateway traffic through the proxy, or block it. (#77018) Thanks @jesse-merhi.
- Telegram/native commands: show the current thinking level above the `/think` level picker so users can see the active setting before changing it. (#78278) Thanks @obviyus.
- Plugins/hooks: add a `before_agent_run` pass/block gate that can stop a user prompt before model submission while preserving a redacted transcript entry for the user, and clarify that raw conversation hooks require `hooks.allowConversationAccess=true`. (#75035) Thanks @jesse-merhi.
+- Config/Nix: keep startup-derived plugin enablement, gateway auth tokens, control UI origins, and owner-display secrets runtime-only instead of rewriting `openclaw.json`; in Nix mode, config writers, mutating `openclaw update`, plugin lifecycle mutators, and doctor repair/token-generation now refuse with agent-first nix-openclaw guidance. (#78047) Thanks @joshp123.
### Fixes
diff --git a/docs/cli/config.md b/docs/cli/config.md
index 1147776dcc7..2ead3b799aa 100644
--- a/docs/cli/config.md
+++ b/docs/cli/config.md
@@ -8,6 +8,10 @@ sidebarTitle: "Config"
Config helpers for non-interactive edits in `openclaw.json`: get/set/patch/unset/file/schema/validate values by path and print the active config file. Run without a subcommand to open the configure wizard (same as `openclaw configure`).
+
+When `OPENCLAW_NIX_MODE=1`, OpenClaw treats `openclaw.json` as immutable. Read-only commands such as `config get`, `config file`, `config schema`, and `config validate` still work, but config writers refuse. Agents should edit the Nix source for the install instead; for the first-party nix-openclaw distribution, use [nix-openclaw Quick Start](https://github.com/openclaw/nix-openclaw#quick-start) and set values under `programs.openclaw.config` or `instances..config`.
+
+
## Root options
diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md
index ff37fd4f0aa..b9dd0c01bc6 100644
--- a/docs/cli/doctor.md
+++ b/docs/cli/doctor.md
@@ -38,6 +38,7 @@ openclaw doctor --generate-gateway-token
Notes:
+- In Nix mode (`OPENCLAW_NIX_MODE=1`), read-only doctor checks still work, but `doctor --fix`, `doctor --repair`, `doctor --yes`, and `doctor --generate-gateway-token` are disabled because `openclaw.json` is immutable. Edit the Nix source for this install instead; for nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
- Performance: non-interactive `doctor` runs skip eager plugin loading so headless health checks stay fast. Interactive sessions still fully load plugins when a check needs their contribution.
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md
index 8d79f2ac78b..bea958a76a5 100644
--- a/docs/cli/plugins.md
+++ b/docs/cli/plugins.md
@@ -59,6 +59,10 @@ For slow install, inspect, uninstall, or registry-refresh investigation, run the
command with `OPENCLAW_PLUGIN_LIFECYCLE_TRACE=1`. The trace writes phase timings
to stderr and keeps JSON output parseable. See [Debugging](/help/debugging#plugin-lifecycle-trace).
+
+In Nix mode (`OPENCLAW_NIX_MODE=1`), plugin lifecycle mutators are disabled. Use the Nix source for this install instead of `plugins install`, `plugins update`, `plugins uninstall`, `plugins enable`, or `plugins disable`; for nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).
+
+
Bundled plugins ship with OpenClaw. Some are enabled by default (for example bundled model providers, bundled speech providers, and the bundled browser plugin); others require `plugins enable`.
@@ -293,7 +297,7 @@ Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in
Plugin install metadata is machine-managed state, not user config. Installs and updates write it to `plugins/installs.json` under the active OpenClaw state directory. Its top-level `installRecords` map is the durable source of install metadata, including records for broken or missing plugin manifests. The `plugins` array is the manifest-derived cold registry cache. The file includes a do-not-edit warning and is used by `openclaw plugins update`, uninstall, diagnostics, and the cold plugin registry.
-When OpenClaw sees shipped legacy `plugins.installs` records in config, it moves them into the plugin index and removes the config key; if either write fails, the config records are kept so the install metadata is not lost.
+When OpenClaw sees shipped legacy `plugins.installs` records in config, runtime reads treat them as compatibility input without rewriting `openclaw.json`. Explicit plugin writes and `openclaw doctor --fix` move those records into the plugin index and remove the config key when config writes are allowed; if either write fails, the config records are kept so the install metadata is not lost.
### Uninstall
diff --git a/docs/cli/setup.md b/docs/cli/setup.md
index 74c7c8854ac..ad0474dcd50 100644
--- a/docs/cli/setup.md
+++ b/docs/cli/setup.md
@@ -10,6 +10,10 @@ title: "Setup"
Initialize `~/.openclaw/openclaw.json` and the agent workspace.
+
+`openclaw setup` is for mutable config installs. In Nix mode (`OPENCLAW_NIX_MODE=1`), OpenClaw refuses setup writes because the config file is managed by Nix. Agents should use the first-party [nix-openclaw Quick Start](https://github.com/openclaw/nix-openclaw#quick-start) or the equivalent source config for another Nix package.
+
+
Related:
- Getting started: [Getting started](/start/getting-started)
diff --git a/docs/cli/update.md b/docs/cli/update.md
index 592f1fb0291..47780f80cb2 100644
--- a/docs/cli/update.md
+++ b/docs/cli/update.md
@@ -52,6 +52,10 @@ console verbosity and file log level are separate: Gateway `--verbose` affects
terminal/WebSocket output, while file logs require `logging.level: "debug"` or
`"trace"` in config. See [Gateway logging](/gateway/logging).
+
+In Nix mode (`OPENCLAW_NIX_MODE=1`), mutating `openclaw update` runs are disabled. Update the Nix source or flake input for this install instead; for nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start). `openclaw update status` and `openclaw update --dry-run` remain read-only.
+
+
Downgrades require confirmation because older versions can break configuration.
diff --git a/docs/install/nix.md b/docs/install/nix.md
index 75c78da8048..5aa98b0973d 100644
--- a/docs/install/nix.md
+++ b/docs/install/nix.md
@@ -7,7 +7,7 @@ read_when:
title: "Nix"
---
-Install OpenClaw declaratively with **[nix-openclaw](https://github.com/openclaw/nix-openclaw)** - a batteries-included Home Manager module.
+Install OpenClaw declaratively with **[nix-openclaw](https://github.com/openclaw/nix-openclaw)** - the first-party, batteries-included Home Manager module.
The [nix-openclaw](https://github.com/openclaw/nix-openclaw) repo is the source of truth for Nix installation. This page is a quick overview.
@@ -50,7 +50,7 @@ See the [nix-openclaw README](https://github.com/openclaw/nix-openclaw) for full
## Nix-mode runtime behavior
-When `OPENCLAW_NIX_MODE=1` is set (automatic with nix-openclaw), OpenClaw enters a deterministic mode that disables auto-install flows.
+When `OPENCLAW_NIX_MODE=1` is set (automatic with nix-openclaw), OpenClaw enters a deterministic mode for Nix-managed installs. Other Nix packages can set the same mode; nix-openclaw is the first-party reference.
You can also set it manually:
@@ -67,6 +67,8 @@ defaults write ai.openclaw.mac openclaw.nixMode -bool true
### What changes in Nix mode
- Auto-install and self-mutation flows are disabled
+- `openclaw.json` is treated as immutable. Startup-derived defaults stay runtime-only, and config writers such as setup, onboarding, mutating `openclaw update`, plugin install/update/uninstall/enable, `doctor --fix`, `doctor --generate-gateway-token`, and `openclaw config set` refuse to edit the file.
+- Agents should edit the Nix source instead. For nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start) and set config under `programs.openclaw.config` or `instances..config`.
- Missing dependencies surface Nix-specific remediation messages
- UI surfaces a read-only Nix mode banner
diff --git a/docs/plugins/manage-plugins.md b/docs/plugins/manage-plugins.md
index 1384983e2af..5d882a76ef2 100644
--- a/docs/plugins/manage-plugins.md
+++ b/docs/plugins/manage-plugins.md
@@ -109,6 +109,11 @@ Uninstall removes the plugin's config entry, plugin index record, allow/deny lis
entries, and linked load paths when applicable. Managed install directories are
removed unless you pass `--keep-files`.
+In Nix mode (`OPENCLAW_NIX_MODE=1`), plugin install, update, uninstall, enable,
+and disable commands are disabled. Manage those choices in the Nix source for
+the install instead; for nix-openclaw, use the agent-first
+[Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).
+
## Publish plugins
You can publish external plugins to [ClawHub](https://clawhub.ai), npmjs.com, or
diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md
index b0144d51b7f..c13a43c5dd4 100644
--- a/docs/tools/plugin.md
+++ b/docs/tools/plugin.md
@@ -578,6 +578,11 @@ top-level `installRecords` and rebuildable manifest metadata in `plugins`. If
the registry is missing, stale, or invalid, `openclaw plugins registry
--refresh` rebuilds its manifest view from install records, config policy, and
manifest/package metadata without loading plugin runtime modules.
+
+In Nix mode (`OPENCLAW_NIX_MODE=1`), plugin lifecycle mutators are disabled.
+Manage plugin package selection and config through the Nix source for the
+install instead; for nix-openclaw, start with the agent-first
+[Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).
`openclaw plugins update ` applies to tracked installs. Passing
an npm package spec with a dist-tag or exact version resolves the package name
back to the tracked plugin record and records the new spec for future updates.
diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts
index e979176293e..81055b9146b 100644
--- a/src/auto-reply/reply/commands-plugins.install.test.ts
+++ b/src/auto-reply/reply/commands-plugins.install.test.ts
@@ -169,6 +169,35 @@ describe("handleCommands /plugins install", () => {
});
});
+ it("refuses plugin installs in Nix mode before package installer side effects", async () => {
+ const previousNixMode = process.env.OPENCLAW_NIX_MODE;
+ process.env.OPENCLAW_NIX_MODE = "1";
+ try {
+ await withTempHome("openclaw-command-plugins-home-", async () => {
+ const workspaceDir = await workspaceHarness.createWorkspace();
+ const params = buildPluginsParams("/plugins install @acme/demo", workspaceDir);
+ const result = await handlePluginsCommand(params, true);
+ if (result === null) {
+ throw new Error("expected plugin install result");
+ }
+
+ expect(result.reply?.text).toContain("OPENCLAW_NIX_MODE=1");
+ expect(result.reply?.text).toContain("nix-openclaw#quick-start");
+ expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
+ expect(installPluginFromPathMock).not.toHaveBeenCalled();
+ expect(installPluginFromClawHubMock).not.toHaveBeenCalled();
+ expect(installPluginFromGitSpecMock).not.toHaveBeenCalled();
+ expect(persistPluginInstallMock).not.toHaveBeenCalled();
+ });
+ } finally {
+ if (previousNixMode === undefined) {
+ delete process.env.OPENCLAW_NIX_MODE;
+ } else {
+ process.env.OPENCLAW_NIX_MODE = previousNixMode;
+ }
+ }
+ });
+
it("installs from an explicit git: spec", async () => {
installPluginFromGitSpecMock.mockResolvedValue({
ok: true,
diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts
index 2d4bfbffe05..b40defa59dd 100644
--- a/src/auto-reply/reply/commands-plugins.test.ts
+++ b/src/auto-reply/reply/commands-plugins.test.ts
@@ -262,6 +262,28 @@ describe("handlePluginsCommand", () => {
);
});
+ it("refuses plugin enablement in Nix mode before reading or replacing config", async () => {
+ const previousNixMode = process.env.OPENCLAW_NIX_MODE;
+ process.env.OPENCLAW_NIX_MODE = "1";
+ try {
+ const params = buildPluginsParams("/plugins enable superpowers", buildCfg());
+ params.command.senderIsOwner = true;
+
+ const result = await handlePluginsCommand(params, true);
+ expect(result?.reply?.text).toContain("OPENCLAW_NIX_MODE=1");
+ expect(result?.reply?.text).toContain("nix-openclaw#quick-start");
+ expect(readConfigFileSnapshotMock).not.toHaveBeenCalled();
+ expect(replaceConfigFileMock).not.toHaveBeenCalled();
+ expect(refreshPluginRegistryAfterConfigMutationMock).not.toHaveBeenCalled();
+ } finally {
+ if (previousNixMode === undefined) {
+ delete process.env.OPENCLAW_NIX_MODE;
+ } else {
+ process.env.OPENCLAW_NIX_MODE = previousNixMode;
+ }
+ }
+ });
+
it("resolves write targets by indexed plugin name without loading diagnostics", async () => {
buildPluginRegistrySnapshotReportMock.mockReturnValue({
workspaceDir: "/tmp/plugins-workspace",
diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts
index 05036afe148..4d71d151998 100644
--- a/src/auto-reply/reply/commands-plugins.ts
+++ b/src/auto-reply/reply/commands-plugins.ts
@@ -13,10 +13,12 @@ import {
replaceConfigFile,
validateConfigObjectWithPlugins,
} from "../../config/config.js";
+import { assertConfigWriteAllowedInCurrentMode } from "../../config/nix-mode-write-guard.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import { resolveArchiveKind } from "../../infra/archive.js";
import { parseClawHubPluginSpec } from "../../infra/clawhub.js";
+import { formatErrorMessage } from "../../infra/errors.js";
import { installPluginFromClawHub } from "../../plugins/clawhub.js";
import { installPluginFromGitSpec, parseGitPluginSpec } from "../../plugins/git-install.js";
import { installPluginFromNpmSpec, installPluginFromPath } from "../../plugins/install.js";
@@ -137,6 +139,25 @@ function formatPluginsList(report: PluginStatusReport): string {
return lines.join("\n");
}
+function isPluginsWriteAction(action: string): boolean {
+ return action === "install" || action === "enable" || action === "disable";
+}
+
+function rejectNixModePluginWrite(): {
+ shouldContinue: false;
+ reply: { text: string };
+} | null {
+ try {
+ assertConfigWriteAllowedInCurrentMode();
+ return null;
+ } catch (error) {
+ return {
+ shouldContinue: false,
+ reply: { text: `⚠️ ${formatErrorMessage(error)}` },
+ };
+ }
+}
+
function findPlugin(report: PluginStatusReport, rawName: string): PluginRecord | undefined {
const target = normalizeOptionalLowercaseString(rawName);
if (!target) {
@@ -412,6 +433,12 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
if (missingAdminScope) {
return missingAdminScope;
}
+ if (isPluginsWriteAction(pluginsCommand.action)) {
+ const nixModeWrite = rejectNixModePluginWrite();
+ if (nixModeWrite) {
+ return nixModeWrite;
+ }
+ }
if (pluginsCommand.action === "install") {
const loadedConfig = await loadPluginCommandConfig();
diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts
index 27196ca0023..f7226cf3237 100644
--- a/src/cli/plugins-cli-test-helpers.ts
+++ b/src/cli/plugins-cli-test-helpers.ts
@@ -165,6 +165,18 @@ vi.mock("../runtime.js", () => ({
}));
vi.mock("../config/config.js", () => ({
+ assertConfigWriteAllowedInCurrentMode: () => {
+ if (process.env.OPENCLAW_NIX_MODE === "1") {
+ throw new Error(
+ [
+ "Config is managed by Nix (`OPENCLAW_NIX_MODE=1`), so OpenClaw treats openclaw.json as immutable.",
+ "Do not run setup, onboarding, openclaw update, plugin install/update/uninstall/enable, doctor repair/token-generation, or config set against this file.",
+ "Agent-first Nix setup: https://github.com/openclaw/nix-openclaw#quick-start",
+ "OpenClaw Nix overview: https://docs.openclaw.ai/install/nix",
+ ].join("\n"),
+ );
+ }
+ },
getRuntimeConfig: () => loadConfig(),
loadConfig: () => loadConfig(),
readConfigFileSnapshot: ((
diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts
index 7e097605ed8..527322bcdf4 100644
--- a/src/cli/plugins-cli.install.test.ts
+++ b/src/cli/plugins-cli.install.test.ts
@@ -39,6 +39,7 @@ import {
const CLI_STATE_ROOT = "/tmp/openclaw-state";
const ORIGINAL_OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR;
+const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE;
const PROFILE_STATE_ROOT = "/tmp/openclaw-ledger-profile";
const OFFICIAL_EXTERNAL_NPM_INSTALLS_WITHOUT_INTEGRITY = listOfficialExternalPluginCatalogEntries()
@@ -305,6 +306,11 @@ describe("plugins cli install", () => {
} else {
process.env.OPENCLAW_STATE_DIR = ORIGINAL_OPENCLAW_STATE_DIR;
}
+ if (ORIGINAL_OPENCLAW_NIX_MODE === undefined) {
+ delete process.env.OPENCLAW_NIX_MODE;
+ } else {
+ process.env.OPENCLAW_NIX_MODE = ORIGINAL_OPENCLAW_NIX_MODE;
+ }
});
it("shows the force overwrite option in install help", async () => {
@@ -322,6 +328,19 @@ describe("plugins cli install", () => {
expect(helpText).toContain("hook pack");
});
+ it("refuses plugin installs in Nix mode before installer side effects", async () => {
+ process.env.OPENCLAW_NIX_MODE = "1";
+
+ await expect(runPluginsCommand(["plugins", "install", "@acme/demo"])).rejects.toThrow(
+ "OPENCLAW_NIX_MODE=1",
+ );
+
+ expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
+ expect(installPluginFromPath).not.toHaveBeenCalled();
+ expect(installPluginFromMarketplace).not.toHaveBeenCalled();
+ expect(writeConfigFile).not.toHaveBeenCalled();
+ });
+
it("exits when --marketplace is combined with --link", async () => {
await expect(
runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]),
diff --git a/src/cli/plugins-cli.policy.test.ts b/src/cli/plugins-cli.policy.test.ts
index 64afd5591a5..a2b86bc3e38 100644
--- a/src/cli/plugins-cli.policy.test.ts
+++ b/src/cli/plugins-cli.policy.test.ts
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
buildPluginRegistrySnapshotReport,
@@ -11,6 +11,8 @@ import {
writeConfigFile,
} from "./plugins-cli-test-helpers.js";
+const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE;
+
describe("plugins cli policy mutations", () => {
const compatibilityPluginIds = [
{ alias: "openai-codex", pluginId: "openai" },
@@ -22,6 +24,14 @@ describe("plugins cli policy mutations", () => {
resetPluginsCliTestState();
});
+ afterEach(() => {
+ if (ORIGINAL_OPENCLAW_NIX_MODE === undefined) {
+ delete process.env.OPENCLAW_NIX_MODE;
+ } else {
+ process.env.OPENCLAW_NIX_MODE = ORIGINAL_OPENCLAW_NIX_MODE;
+ }
+ });
+
function mockPluginRegistry(ids: string[]) {
buildPluginRegistrySnapshotReport.mockReturnValue({
plugins: ids.map((id) => ({ id })),
@@ -62,6 +72,25 @@ describe("plugins cli policy mutations", () => {
});
});
+ it("refuses plugin enablement in Nix mode before config mutation", async () => {
+ const previous = process.env.OPENCLAW_NIX_MODE;
+ process.env.OPENCLAW_NIX_MODE = "1";
+ try {
+ await expect(runPluginsCommand(["plugins", "enable", "alpha"])).rejects.toThrow(
+ "OPENCLAW_NIX_MODE=1",
+ );
+ } finally {
+ if (previous === undefined) {
+ delete process.env.OPENCLAW_NIX_MODE;
+ } else {
+ process.env.OPENCLAW_NIX_MODE = previous;
+ }
+ }
+
+ expect(enablePluginInConfig).not.toHaveBeenCalled();
+ expect(writeConfigFile).not.toHaveBeenCalled();
+ });
+
it("refreshes the persisted plugin registry after disabling a plugin", async () => {
loadConfig.mockReturnValue({
plugins: {
diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts
index 9c254c44c23..6918faafb96 100644
--- a/src/cli/plugins-cli.ts
+++ b/src/cli/plugins-cli.ts
@@ -1,5 +1,10 @@
import type { Command } from "commander";
-import { getRuntimeConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js";
+import {
+ assertConfigWriteAllowedInCurrentMode,
+ getRuntimeConfig,
+ readConfigFileSnapshot,
+ replaceConfigFile,
+} from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js";
import { defaultRuntime } from "../runtime.js";
@@ -136,6 +141,8 @@ export function registerPluginsCli(program: Command) {
.description("Enable a plugin in config")
.argument("", "Plugin id")
.action(async (id: string) => {
+ assertConfigWriteAllowedInCurrentMode();
+
const { enablePluginInConfig } = await import("../plugins/enable.js");
const { normalizePluginId } = await import("../plugins/config-state.js");
const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js");
@@ -185,6 +192,8 @@ export function registerPluginsCli(program: Command) {
.description("Disable a plugin in config")
.argument("", "Plugin id")
.action(async (id: string) => {
+ assertConfigWriteAllowedInCurrentMode();
+
const { normalizePluginId } = await import("../plugins/config-state.js");
const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js");
const { setPluginEnabledInConfig } = await import("./plugins-config.js");
diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts
index 01a0fe89da8..54bd569a420 100644
--- a/src/cli/plugins-cli.uninstall.test.ts
+++ b/src/cli/plugins-cli.uninstall.test.ts
@@ -1,5 +1,5 @@
import { installedPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
-import { beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
applyPluginUninstallDirectoryRemoval,
@@ -22,12 +22,41 @@ import {
const CLI_STATE_ROOT = "/tmp/openclaw-state";
const ALPHA_INSTALL_PATH = installedPluginRoot(CLI_STATE_ROOT, "alpha");
+const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE;
describe("plugins cli uninstall", () => {
beforeEach(() => {
resetPluginsCliTestState();
});
+ afterEach(() => {
+ if (ORIGINAL_OPENCLAW_NIX_MODE === undefined) {
+ delete process.env.OPENCLAW_NIX_MODE;
+ } else {
+ process.env.OPENCLAW_NIX_MODE = ORIGINAL_OPENCLAW_NIX_MODE;
+ }
+ });
+
+ it("refuses plugin uninstalls in Nix mode before planning file removal", async () => {
+ const previous = process.env.OPENCLAW_NIX_MODE;
+ process.env.OPENCLAW_NIX_MODE = "1";
+ try {
+ await expect(runPluginsCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow(
+ "OPENCLAW_NIX_MODE=1",
+ );
+ } finally {
+ if (previous === undefined) {
+ delete process.env.OPENCLAW_NIX_MODE;
+ } else {
+ process.env.OPENCLAW_NIX_MODE = previous;
+ }
+ }
+
+ expect(planPluginUninstall).not.toHaveBeenCalled();
+ expect(applyPluginUninstallDirectoryRemoval).not.toHaveBeenCalled();
+ expect(writeConfigFile).not.toHaveBeenCalled();
+ });
+
it("shows uninstall dry-run preview without mutating config", async () => {
loadConfig.mockReturnValue({
plugins: {
diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts
index e9e4469ab4d..17725c892b1 100644
--- a/src/cli/plugins-cli.update.test.ts
+++ b/src/cli/plugins-cli.update.test.ts
@@ -1,5 +1,5 @@
import { Command } from "commander";
-import { beforeEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
loadConfig,
@@ -16,6 +16,8 @@ import {
writePersistedInstalledPluginIndexInstallRecords,
} from "./plugins-cli-test-helpers.js";
+const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE;
+
function createTrackedPluginConfig(params: {
pluginId: string;
spec: string;
@@ -40,6 +42,14 @@ describe("plugins cli update", () => {
resetPluginsCliTestState();
});
+ afterEach(() => {
+ if (ORIGINAL_OPENCLAW_NIX_MODE === undefined) {
+ delete process.env.OPENCLAW_NIX_MODE;
+ } else {
+ process.env.OPENCLAW_NIX_MODE = ORIGINAL_OPENCLAW_NIX_MODE;
+ }
+ });
+
it("shows the dangerous unsafe install override in update help", () => {
const program = new Command();
registerPluginsCli(program);
@@ -53,6 +63,26 @@ describe("plugins cli update", () => {
expect(helpText).toContain("blocking for plugins");
});
+ it("refuses plugin updates in Nix mode before package-manager work", async () => {
+ const previous = process.env.OPENCLAW_NIX_MODE;
+ process.env.OPENCLAW_NIX_MODE = "1";
+ try {
+ await expect(runPluginsCommand(["plugins", "update", "--all"])).rejects.toThrow(
+ "OPENCLAW_NIX_MODE=1",
+ );
+ } finally {
+ if (previous === undefined) {
+ delete process.env.OPENCLAW_NIX_MODE;
+ } else {
+ process.env.OPENCLAW_NIX_MODE = previous;
+ }
+ }
+
+ expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
+ expect(updateNpmInstalledHookPacks).not.toHaveBeenCalled();
+ expect(writeConfigFile).not.toHaveBeenCalled();
+ });
+
it("updates tracked hook packs through plugins update", async () => {
const cfg = {
hooks: {
diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts
index 8b1af67e06e..f589a45e091 100644
--- a/src/cli/plugins-install-command.ts
+++ b/src/cli/plugins-install-command.ts
@@ -1,6 +1,6 @@
import fs from "node:fs";
import { collectChannelDoctorStaleConfigMutations } from "../commands/doctor/shared/channel-doctor.js";
-import { readConfigFileSnapshot } from "../config/config.js";
+import { assertConfigWriteAllowedInCurrentMode, readConfigFileSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { installHooksFromNpmSpec, installHooksFromPath } from "../hooks/install.js";
import { resolveArchiveKind } from "../infra/archive.js";
@@ -564,6 +564,8 @@ export async function runPluginInstallCommand(params: {
};
runtime?: RuntimeEnv;
}) {
+ assertConfigWriteAllowedInCurrentMode();
+
const runtime = params.runtime ?? defaultRuntime;
const shorthand = !params.opts.marketplace
? await tracePluginLifecyclePhaseAsync(
diff --git a/src/cli/plugins-uninstall-command.ts b/src/cli/plugins-uninstall-command.ts
index a9f7d251ad3..0efa3126aa2 100644
--- a/src/cli/plugins-uninstall-command.ts
+++ b/src/cli/plugins-uninstall-command.ts
@@ -1,6 +1,6 @@
import os from "node:os";
import path from "node:path";
-import { readConfigFileSnapshot } from "../config/config.js";
+import { assertConfigWriteAllowedInCurrentMode, readConfigFileSnapshot } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
@@ -31,6 +31,8 @@ export async function runPluginUninstallCommand(
opts: PluginUninstallOptions = {},
runtime: RuntimeEnv = defaultRuntime,
): Promise {
+ assertConfigWriteAllowedInCurrentMode();
+
const {
loadInstalledPluginIndexInstallRecords,
removePluginInstallRecordFromRecords,
diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts
index ce149013b10..4e3baae4ed1 100644
--- a/src/cli/plugins-update-command.ts
+++ b/src/cli/plugins-update-command.ts
@@ -1,4 +1,9 @@
-import { getRuntimeConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js";
+import {
+ assertConfigWriteAllowedInCurrentMode,
+ getRuntimeConfig,
+ readConfigFileSnapshot,
+ replaceConfigFile,
+} from "../config/config.js";
import { updateNpmInstalledHookPacks } from "../hooks/update.js";
import {
loadInstalledPluginIndexInstallRecords,
@@ -21,6 +26,8 @@ export async function runPluginUpdateCommand(params: {
id?: string;
opts: { all?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean };
}) {
+ assertConfigWriteAllowedInCurrentMode();
+
const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null);
const cfg = getRuntimeConfig();
const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords();
diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts
index 39d6e391c62..4d5ee95a818 100644
--- a/src/cli/update-cli.test.ts
+++ b/src/cli/update-cli.test.ts
@@ -64,6 +64,18 @@ vi.mock("../infra/openclaw-root.js", () => ({
}));
vi.mock("../config/config.js", () => ({
+ assertConfigWriteAllowedInCurrentMode: () => {
+ if (process.env.OPENCLAW_NIX_MODE === "1") {
+ throw new Error(
+ [
+ "Config is managed by Nix (`OPENCLAW_NIX_MODE=1`), so OpenClaw treats openclaw.json as immutable.",
+ "Do not run setup, onboarding, openclaw update, plugin install/update/uninstall/enable, doctor repair/token-generation, or config set against this file.",
+ "Agent-first Nix setup: https://github.com/openclaw/nix-openclaw#quick-start",
+ "OpenClaw Nix overview: https://docs.openclaw.ai/install/nix",
+ ].join("\n"),
+ );
+ }
+ },
ConfigMutationConflictError: class ConfigMutationConflictError extends Error {
readonly currentHash: string | null;
@@ -597,6 +609,16 @@ describe("update-cli", () => {
);
});
+ it("refuses mutating updates in Nix mode before update side effects", async () => {
+ await withEnvAsync({ OPENCLAW_NIX_MODE: "1" }, async () => {
+ await expect(updateCommand({ yes: true })).rejects.toThrow("OPENCLAW_NIX_MODE=1");
+ });
+
+ expect(runGatewayUpdate).not.toHaveBeenCalled();
+ expect(replaceConfigFile).not.toHaveBeenCalled();
+ expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
+ });
+
it("logs friendly hint with manual refresh command when completion cache write times out", async () => {
const root = createCaseDir("openclaw-completion-timeout-msg");
pathExists.mockResolvedValue(true);
diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts
index 9126f01f780..9f899218beb 100644
--- a/src/cli/update-cli/update-command.ts
+++ b/src/cli/update-cli/update-command.ts
@@ -10,6 +10,7 @@ import {
import { doctorCommand } from "../../commands/doctor.js";
import {
ConfigMutationConflictError,
+ assertConfigWriteAllowedInCurrentMode,
readConfigFileSnapshot,
replaceConfigFile,
resolveGatewayPort,
@@ -1945,6 +1946,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise {
if (timeoutMs === null) {
return;
}
+ if (opts.dryRun !== true) {
+ assertConfigWriteAllowedInCurrentMode();
+ }
const updateStepTimeoutMs = timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS;
const root = await resolveUpdateRoot();
diff --git a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts
index 5a896425fd1..ce0ca3f9eef 100644
--- a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts
+++ b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts
@@ -64,6 +64,44 @@ describe("doctor command", () => {
expect(confirm).not.toHaveBeenCalled();
}, 30_000);
+ it("refuses doctor repair mode in Nix before repair side effects", async () => {
+ const previous = process.env.OPENCLAW_NIX_MODE;
+ process.env.OPENCLAW_NIX_MODE = "1";
+ try {
+ mockDoctorConfigSnapshot();
+ await expect(doctorCommand(createDoctorRuntime(), { repair: true })).rejects.toThrow(
+ "OPENCLAW_NIX_MODE=1",
+ );
+ } finally {
+ if (previous === undefined) {
+ delete process.env.OPENCLAW_NIX_MODE;
+ } else {
+ process.env.OPENCLAW_NIX_MODE = previous;
+ }
+ }
+
+ expect(writeConfigFile).not.toHaveBeenCalled();
+ });
+
+ it("refuses doctor gateway token generation in Nix before config writes", async () => {
+ const previous = process.env.OPENCLAW_NIX_MODE;
+ process.env.OPENCLAW_NIX_MODE = "1";
+ try {
+ mockDoctorConfigSnapshot();
+ await expect(
+ doctorCommand(createDoctorRuntime(), { generateGatewayToken: true }),
+ ).rejects.toThrow("OPENCLAW_NIX_MODE=1");
+ } finally {
+ if (previous === undefined) {
+ delete process.env.OPENCLAW_NIX_MODE;
+ } else {
+ process.env.OPENCLAW_NIX_MODE = previous;
+ }
+ }
+
+ expect(writeConfigFile).not.toHaveBeenCalled();
+ });
+
it("skips gateway restarts in non-interactive mode", async () => {
mockDoctorConfigSnapshot();
diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts
index 5459d44f608..08d3fd23d88 100644
--- a/src/commands/onboarding-plugin-install.test.ts
+++ b/src/commands/onboarding-plugin-install.test.ts
@@ -83,6 +83,41 @@ describe("ensureOnboardingPluginInstalled", () => {
refreshPluginRegistryAfterConfigMutation.mockResolvedValue(undefined);
});
+ it("refuses non-skipped installs in Nix mode before package work", async () => {
+ const previous = process.env.OPENCLAW_NIX_MODE;
+ process.env.OPENCLAW_NIX_MODE = "1";
+ try {
+ await expect(
+ ensureOnboardingPluginInstalled({
+ cfg: {},
+ entry: {
+ pluginId: "demo-plugin",
+ label: "Demo Provider",
+ install: {
+ npmSpec: "@openclaw/demo-plugin@1.2.3",
+ },
+ },
+ promptInstall: false,
+ prompter: {
+ select: vi.fn(async () => "npm"),
+ progress: vi.fn(),
+ } as never,
+ runtime: {} as never,
+ }),
+ ).rejects.toThrow("OPENCLAW_NIX_MODE=1");
+ } finally {
+ if (previous === undefined) {
+ delete process.env.OPENCLAW_NIX_MODE;
+ } else {
+ process.env.OPENCLAW_NIX_MODE = previous;
+ }
+ }
+
+ expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
+ expect(installPluginFromClawHub).not.toHaveBeenCalled();
+ expect(enablePluginInConfig).not.toHaveBeenCalled();
+ });
+
it("installs and records ClawHub provider plugins with source facts", async () => {
installPluginFromClawHub.mockImplementation(async (params) => {
params.logger?.info?.("Downloading demo-plugin from ClawHub…");
diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts
index 3d5263e51cf..d82667bb25b 100644
--- a/src/commands/onboarding-plugin-install.ts
+++ b/src/commands/onboarding-plugin-install.ts
@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { resolveBundledInstallPlanForCatalogEntry } from "../cli/plugin-install-plan.js";
+import { assertConfigWriteAllowedInCurrentMode } from "../config/nix-mode-write-guard.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
@@ -787,6 +788,7 @@ export async function ensureOnboardingPluginInstalled(params: {
status: "skipped",
};
}
+ assertConfigWriteAllowedInCurrentMode();
if (choice === "local" && localPath) {
const enableResult = await applyPluginEnablement({
diff --git a/src/flows/doctor-health.ts b/src/flows/doctor-health.ts
index b07f1e08984..57ae357d5e1 100644
--- a/src/flows/doctor-health.ts
+++ b/src/flows/doctor-health.ts
@@ -8,6 +8,11 @@ const outro = (message: string) => clackOutro(stylePromptTitle(message) ?? messa
export async function doctorCommand(runtime?: RuntimeEnv, options: DoctorOptions = {}) {
const effectiveRuntime = runtime ?? (await import("../runtime.js")).defaultRuntime;
+ if (options.repair === true || options.yes === true || options.generateGatewayToken === true) {
+ const { assertConfigWriteAllowedInCurrentMode } = await import("../config/config.js");
+ assertConfigWriteAllowedInCurrentMode();
+ }
+
const { createDoctorPrompter } = await import("../commands/doctor-prompter.js");
const { printWizardHeader } = await import("../commands/onboard-helpers.js");
const prompter = createDoctorPrompter({ runtime: effectiveRuntime, options });