From 05a2c71b90d566ee759123d81602bee0c2960639 Mon Sep 17 00:00:00 2001 From: joshp123 Date: Wed, 6 May 2026 11:10:39 +0200 Subject: [PATCH] cli: refuse config mutators in Nix mode Co-authored-by: Codex --- CHANGELOG.md | 1 + docs/cli/config.md | 4 ++ docs/cli/doctor.md | 1 + docs/cli/plugins.md | 6 ++- docs/cli/setup.md | 4 ++ docs/cli/update.md | 4 ++ docs/install/nix.md | 6 ++- docs/plugins/manage-plugins.md | 5 +++ docs/tools/plugin.md | 5 +++ .../reply/commands-plugins.install.test.ts | 29 ++++++++++++++ src/auto-reply/reply/commands-plugins.test.ts | 22 +++++++++++ src/auto-reply/reply/commands-plugins.ts | 27 +++++++++++++ src/cli/plugins-cli-test-helpers.ts | 12 ++++++ src/cli/plugins-cli.install.test.ts | 19 ++++++++++ src/cli/plugins-cli.policy.test.ts | 31 ++++++++++++++- src/cli/plugins-cli.ts | 11 +++++- src/cli/plugins-cli.uninstall.test.ts | 31 ++++++++++++++- src/cli/plugins-cli.update.test.ts | 32 +++++++++++++++- src/cli/plugins-install-command.ts | 4 +- src/cli/plugins-uninstall-command.ts | 4 +- src/cli/plugins-update-command.ts | 9 ++++- src/cli/update-cli.test.ts | 22 +++++++++++ src/cli/update-cli/update-command.ts | 4 ++ ...te-migrations-yes-mode-without.e2e.test.ts | 38 +++++++++++++++++++ .../onboarding-plugin-install.test.ts | 35 +++++++++++++++++ src/commands/onboarding-plugin-install.ts | 2 + src/flows/doctor-health.ts | 5 +++ 27 files changed, 363 insertions(+), 10 deletions(-) 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 });