cli: refuse config mutators in Nix mode

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
joshp123
2026-05-06 11:10:39 +02:00
parent 53757829da
commit 05a2c71b90
27 changed files with 363 additions and 10 deletions

View File

@@ -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

View File

@@ -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`).
<Note>
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.<name>.config`.
</Note>
## Root options
<ParamField path="--section <section>" type="string">

View File

@@ -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.

View File

@@ -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).
<Note>
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).
</Note>
<Note>
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

View File

@@ -10,6 +10,10 @@ title: "Setup"
Initialize `~/.openclaw/openclaw.json` and the agent workspace.
<Note>
`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.
</Note>
Related:
- Getting started: [Getting started](/start/getting-started)

View File

@@ -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).
<Note>
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.
</Note>
<Warning>
Downgrades require confirmation because older versions can break configuration.
</Warning>

View File

@@ -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.
<Info>
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.<name>.config`.
- Missing dependencies surface Nix-specific remediation messages
- UI surfaces a read-only Nix mode banner

View File

@@ -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

View File

@@ -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 <id-or-npm-spec>` 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.

View File

@@ -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,

View File

@@ -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",

View File

@@ -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();

View File

@@ -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: ((

View File

@@ -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"]),

View File

@@ -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: {

View File

@@ -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("<id>", "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("<id>", "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");

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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(

View File

@@ -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<void> {
assertConfigWriteAllowedInCurrentMode();
const {
loadInstalledPluginIndexInstallRecords,
removePluginInstallRecordFromRecords,

View File

@@ -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();

View File

@@ -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);

View File

@@ -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<void> {
if (timeoutMs === null) {
return;
}
if (opts.dryRun !== true) {
assertConfigWriteAllowedInCurrentMode();
}
const updateStepTimeoutMs = timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS;
const root = await resolveUpdateRoot();

View File

@@ -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();

View File

@@ -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…");

View File

@@ -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({

View File

@@ -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 });