mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:10:49 +00:00
cli: refuse config mutators in Nix mode
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: ((
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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…");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user