config: stop automatic writes and guard Nix mutators (#78047)

Keep startup-derived plugin enablement, gateway auth tokens, control UI origins, and owner-display secrets runtime-only instead of persisting them into openclaw.json.

Refuse config writers, mutating update/plugin lifecycle commands, and doctor repair/token generation in Nix mode with agent-first nix-openclaw guidance.

Verification:
- pnpm check
- pnpm build
- pnpm test -- src/config/io.write-config.test.ts src/config/mutate.test.ts src/config/io.owner-display-secret.test.ts src/gateway/server-startup-config.recovery.test.ts src/gateway/startup-auth.test.ts src/gateway/startup-control-ui-origins.test.ts src/cli/plugins-cli.install.test.ts src/cli/plugins-cli.policy.test.ts src/cli/plugins-cli.uninstall.test.ts src/cli/plugins-cli.update.test.ts src/cli/update-cli.test.ts src/auto-reply/reply/commands-plugins.install.test.ts src/auto-reply/reply/commands-plugins.test.ts src/commands/onboarding-plugin-install.test.ts src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.e2e.test.ts src/commands/doctor/shared/codex-route-warnings.test.ts src/commands/doctor/repair-sequencing.test.ts src/agents/auth-profile-runtime-contract.test.ts src/auto-reply/reply/agent-runner-execution.test.ts
- GitHub CI green on 05a2c71b90

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
the sun gif man
2026-05-06 14:43:32 +02:00
committed by GitHub
parent 3317b79e5f
commit d4b4660026
52 changed files with 847 additions and 364 deletions
+1
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
+1 -1
View File
@@ -662,7 +662,7 @@ Default slash command settings:
</Accordion>
<Accordion title="Live stream preview">
OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` (default) | `partial` | `block` | `progress`. `progress` keeps one editable status draft and updates it with tool progress until final delivery; `streamMode` is a legacy alias and is auto-migrated.
OpenClaw can stream draft replies by sending a temporary message and editing it as text arrives. `channels.discord.streaming` takes `off` (default) | `partial` | `block` | `progress`. `progress` keeps one editable status draft and updates it with tool progress until final delivery; `streamMode` is a legacy runtime alias. Run `openclaw doctor --fix` to rewrite persisted config to the canonical key.
Default stays `off` because Discord preview edits hit rate limits quickly when multiple bots or gateways share an account.
+4 -3
View File
@@ -1020,9 +1020,10 @@ Use draft preview instead of Slack native text streaming:
Legacy keys:
- `channels.slack.streamMode` (`replace | status_final | append`) is auto-migrated to `channels.slack.streaming.mode`.
- boolean `channels.slack.streaming` is auto-migrated to `channels.slack.streaming.mode` and `channels.slack.streaming.nativeTransport`.
- legacy `channels.slack.nativeStreaming` is auto-migrated to `channels.slack.streaming.nativeTransport`.
- `channels.slack.streamMode` (`replace | status_final | append`) is a legacy runtime alias for `channels.slack.streaming.mode`.
- boolean `channels.slack.streaming` is a legacy runtime alias for `channels.slack.streaming.mode` and `channels.slack.streaming.nativeTransport`.
- legacy `channels.slack.nativeStreaming` is a runtime alias for `channels.slack.streaming.nativeTransport`.
- Run `openclaw doctor --fix` to rewrite persisted Slack streaming config to the canonical keys.
## Typing reaction fallback
+4
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">
+1
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.
+5 -1
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
+4
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)
+4
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>
+2 -2
View File
@@ -152,8 +152,8 @@ Slack-only:
Legacy key migration:
- Telegram: legacy `streamMode` and scalar/boolean `streaming` values are detected and migrated by doctor/config compatibility paths to `streaming.mode`.
- Discord: `streamMode` + boolean `streaming` auto-migrate to `streaming` enum.
- Slack: `streamMode` auto-migrates to `streaming.mode`; boolean `streaming` auto-migrates to `streaming.mode` plus `streaming.nativeTransport`; legacy `nativeStreaming` auto-migrates to `streaming.nativeTransport`.
- Discord: `streamMode` + boolean `streaming` remain runtime aliases for the `streaming` enum; run `openclaw doctor --fix` to rewrite persisted config.
- Slack: `streamMode` remains a runtime alias for `streaming.mode`; boolean `streaming` remains a runtime alias for `streaming.mode` plus `streaming.nativeTransport`; legacy `nativeStreaming` remains a runtime alias for `streaming.nativeTransport`. Run `openclaw doctor --fix` to rewrite persisted config.
### Runtime behavior
+1 -1
View File
@@ -1401,7 +1401,7 @@ Defaults for Talk mode (macOS/iOS/Android).
```
- `talk.provider` must match a key in `talk.providers` when multiple Talk providers are configured.
- Legacy flat Talk keys (`talk.voiceId`, `talk.voiceAliases`, `talk.modelId`, `talk.outputFormat`, `talk.apiKey`) are compatibility-only and are auto-migrated into `talk.providers.<provider>`.
- Legacy flat Talk keys (`talk.voiceId`, `talk.voiceAliases`, `talk.modelId`, `talk.outputFormat`, `talk.apiKey`) are compatibility-only. Run `openclaw doctor --fix` to rewrite persisted config into `talk.providers.<provider>`.
- Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`.
- `providers.*.apiKey` accepts plaintext strings or SecretRef objects.
- `ELEVENLABS_API_KEY` fallback applies only when no Talk API key is configured.
+2 -2
View File
@@ -345,7 +345,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- `channels.discord.voice.connectTimeoutMs` controls the initial `@discordjs/voice` Ready wait for `/vc join` and auto-join attempts (`30000` by default).
- `channels.discord.voice.reconnectGraceMs` controls how long a disconnected voice session may take to enter reconnect signalling before OpenClaw destroys it (`15000` by default).
- OpenClaw additionally attempts voice receive recovery by leaving/rejoining a voice session after repeated decrypt failures.
- `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are auto-migrated.
- `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values remain runtime aliases; run `openclaw doctor --fix` to rewrite persisted config.
- `channels.discord.autoPresence` maps runtime availability to bot presence (healthy => online, degraded => idle, exhausted => dnd) and allows optional status text overrides.
- `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode).
- `channels.discord.execApprovals`: Discord-native exec approval delivery and approver authorization.
@@ -475,7 +475,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
resolve the secret value.
- `configWrites: false` blocks Slack-initiated config writes.
- Optional `channels.slack.defaultAccount` overrides default account selection when it matches a configured account id.
- `channels.slack.streaming.mode` is the canonical Slack stream mode key. `channels.slack.streaming.nativeTransport` controls Slack's native streaming transport. Legacy `streamMode`, boolean `streaming`, and `nativeStreaming` values are auto-migrated.
- `channels.slack.streaming.mode` is the canonical Slack stream mode key. `channels.slack.streaming.nativeTransport` controls Slack's native streaming transport. Legacy `streamMode`, boolean `streaming`, and `nativeStreaming` values remain runtime aliases; run `openclaw doctor --fix` to rewrite persisted config.
- Use `user:<id>` (DM) or `channel:<id>` for delivery targets.
**Reaction notification modes:** `off`, `own` (default), `all`, `allowlist` (from `reactionAllowlist`).
+1 -1
View File
@@ -185,7 +185,7 @@ That stages grounded durable candidates into the short-term dreaming store while
- Show the migration it applied.
- Rewrite `~/.openclaw/openclaw.json` with the updated schema.
The Gateway also auto-runs doctor migrations on startup when it detects a legacy config format, so stale configs are repaired without manual intervention. Cron job store migrations are handled by `openclaw doctor --fix`.
Gateway startup refuses legacy config formats and asks you to run `openclaw doctor --fix`; it does not rewrite `openclaw.json` on startup. Cron job store migrations are also handled by `openclaw doctor --fix`.
Current migrations:
+1 -1
View File
@@ -695,7 +695,7 @@ lives on the [First-run FAQ](/help/faq-first-run).
</Accordion>
<Accordion title="Why do I need a token on localhost now?">
OpenClaw enforces gateway auth by default, including loopback. In the normal default path that means token auth: if no explicit auth path is configured, gateway startup resolves to token mode and auto-generates one, saving it to `gateway.auth.token`, so **local WS clients must authenticate**. This blocks other local processes from calling the Gateway.
OpenClaw enforces gateway auth by default, including loopback. In the normal default path that means token auth: if no explicit auth path is configured, gateway startup resolves to token mode and generates a runtime-only token for that startup, so **local WS clients must authenticate**. Configure `gateway.auth.token`, `gateway.auth.password`, `OPENCLAW_GATEWAY_TOKEN`, or `OPENCLAW_GATEWAY_PASSWORD` explicitly when clients need a stable secret across restarts. This blocks other local processes from calling the Gateway.
If you prefer a different auth path, you can explicitly choose password mode (or, for identity-aware reverse proxies, `trusted-proxy`). If you **really** want open loopback, set `gateway.auth.mode: "none"` explicitly in your config. Doctor can generate a token for you any time: `openclaw doctor --generate-gateway-token`.
+5 -4
View File
@@ -220,10 +220,11 @@ For the generic Docker flow, see [Docker](/install/docker).
XDG_CONFIG_HOME=/home/node/.openclaw
```
Leave `OPENCLAW_GATEWAY_TOKEN` blank unless you explicitly want to
manage it through `.env`; OpenClaw writes a random gateway token to
config on first start. Generate a keyring password and paste it into
`GOG_KEYRING_PASSWORD`:
Set `OPENCLAW_GATEWAY_TOKEN` when you want to manage the stable gateway
token through `.env`; otherwise configure `gateway.auth.token` before
relying on clients across restarts. If neither source exists, OpenClaw uses
a runtime-only token for that startup. Generate a keyring password and paste
it into `GOG_KEYRING_PASSWORD`:
```bash
openssl rand -hex 32
+5 -4
View File
@@ -145,10 +145,11 @@ For the generic Docker flow, see [Docker](/install/docker).
XDG_CONFIG_HOME=/home/node/.openclaw
```
Leave `OPENCLAW_GATEWAY_TOKEN` blank unless you explicitly want to
manage it through `.env`; OpenClaw writes a random gateway token to
config on first start. Generate a keyring password and paste it into
`GOG_KEYRING_PASSWORD`:
Set `OPENCLAW_GATEWAY_TOKEN` when you want to manage the stable gateway
token through `.env`; otherwise configure `gateway.auth.token` before
relying on clients across restarts. If neither source exists, OpenClaw uses
a runtime-only token for that startup. Generate a keyring password and paste
it into `GOG_KEYRING_PASSWORD`:
```bash
openssl rand -hex 32
+4 -2
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
+5
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
+4 -1
View File
@@ -488,7 +488,10 @@ Key ideas:
- Tailscale Serve identity headers and `gateway.auth.mode: "trusted-proxy"` do
**not** authenticate this standalone loopback browser API.
- If browser control is enabled and no shared-secret auth is configured, OpenClaw
auto-generates `gateway.auth.token` on startup and persists it to config.
generates a runtime-only gateway token for that startup. Configure
`gateway.auth.token`, `gateway.auth.password`, `OPENCLAW_GATEWAY_TOKEN`, or
`OPENCLAW_GATEWAY_PASSWORD` explicitly if clients need a stable secret across
restarts.
- OpenClaw does **not** auto-generate that token when `gateway.auth.mode` is
already `password`, `none`, or `trusted-proxy`.
- Keep the Gateway and any node hosts on a private network (Tailscale); avoid public exposure.
+5
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.
@@ -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",
+27
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();
+12
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: ((
+19
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"]),
+30 -1
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: {
+10 -1
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");
+30 -1
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: {
+31 -1
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: {
+3 -1
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(
+3 -1
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,
+8 -1
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();
+22
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);
+4
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();
@@ -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({
+4
View File
@@ -44,6 +44,10 @@ export type {
ReadConfigFileSnapshotWithPluginMetadataResult,
} from "./io.js";
export { ConfigMutationConflictError, mutateConfigFile, replaceConfigFile } from "./mutate.js";
export {
assertConfigWriteAllowedInCurrentMode,
NixModeConfigMutationError,
} from "./nix-mode-write-guard.js";
export * from "./paths.js";
export * from "./recovery-policy.js";
export * from "./runtime-overrides.js";
+8 -84
View File
@@ -1,29 +1,18 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
type OwnerDisplaySecretPersistState,
persistGeneratedOwnerDisplaySecret,
type OwnerDisplaySecretRuntimeState,
retainGeneratedOwnerDisplaySecret,
} from "./io.owner-display-secret.js";
import type { OpenClawConfig } from "./types.openclaw.js";
function createState(): OwnerDisplaySecretPersistState {
function createState(): OwnerDisplaySecretRuntimeState {
return {
pendingByPath: new Map<string, string>(),
persistInFlight: new Set<string>(),
persistWarned: new Set<string>(),
};
}
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("persistGeneratedOwnerDisplaySecret", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("persists generated owner display secrets once and clears state on success", async () => {
describe("retainGeneratedOwnerDisplaySecret", () => {
it("keeps generated owner display secrets in runtime state without persisting config", () => {
const state = createState();
const configPath = "/tmp/openclaw.json";
const config = {
@@ -32,84 +21,22 @@ describe("persistGeneratedOwnerDisplaySecret", () => {
ownerDisplaySecret: "generated-owner-secret",
},
} as OpenClawConfig;
const persistConfig = vi.fn(async () => undefined);
const result = persistGeneratedOwnerDisplaySecret({
const result = retainGeneratedOwnerDisplaySecret({
config,
configPath,
generatedSecret: "generated-owner-secret",
logger: { warn: vi.fn() },
state,
persistConfig,
});
expect(result).toBe(config);
expect(state.pendingByPath.get(configPath)).toBe("generated-owner-secret");
expect(state.persistInFlight.has(configPath)).toBe(true);
expect(persistConfig).toHaveBeenCalledTimes(1);
expect(persistConfig).toHaveBeenCalledWith(config, {
expectedConfigPath: configPath,
});
await flushAsyncWork();
expect(state.pendingByPath.has(configPath)).toBe(false);
expect(state.persistInFlight.has(configPath)).toBe(false);
expect(state.persistWarned.has(configPath)).toBe(false);
});
it("warns once and keeps the generated secret pending when persistence fails", async () => {
const state = createState();
const configPath = "/tmp/openclaw.json";
const config = {
commands: {
ownerDisplay: "hash",
ownerDisplaySecret: "generated-owner-secret",
},
} as OpenClawConfig;
const warn = vi.fn();
const persistConfig = vi.fn(async () => {
throw new Error("disk full");
});
persistGeneratedOwnerDisplaySecret({
config,
configPath,
generatedSecret: "generated-owner-secret",
logger: { warn },
state,
persistConfig,
});
await flushAsyncWork();
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Failed to persist auto-generated commands.ownerDisplaySecret"),
);
expect(state.pendingByPath.get(configPath)).toBe("generated-owner-secret");
expect(state.persistInFlight.has(configPath)).toBe(false);
expect(state.persistWarned.has(configPath)).toBe(true);
persistGeneratedOwnerDisplaySecret({
config,
configPath,
generatedSecret: "generated-owner-secret",
logger: { warn },
state,
persistConfig,
});
await flushAsyncWork();
expect(warn).toHaveBeenCalledTimes(1);
});
it("clears pending state when no generated secret is present", () => {
const state = createState();
const configPath = "/tmp/openclaw.json";
state.pendingByPath.set(configPath, "stale-secret");
state.persistWarned.add(configPath);
const config = {
commands: {
ownerDisplay: "hash",
@@ -117,16 +44,13 @@ describe("persistGeneratedOwnerDisplaySecret", () => {
},
} as OpenClawConfig;
const result = persistGeneratedOwnerDisplaySecret({
const result = retainGeneratedOwnerDisplaySecret({
config,
configPath,
logger: { warn: vi.fn() },
state,
persistConfig: vi.fn(async () => undefined),
});
expect(result).toBe(config);
expect(state.pendingByPath.has(configPath)).toBe(false);
expect(state.persistWarned.has(configPath)).toBe(false);
});
});
+4 -32
View File
@@ -1,49 +1,21 @@
import type { OpenClawConfig } from "./types.openclaw.js";
export type OwnerDisplaySecretPersistState = {
export type OwnerDisplaySecretRuntimeState = {
pendingByPath: Map<string, string>;
persistInFlight: Set<string>;
persistWarned: Set<string>;
};
export function persistGeneratedOwnerDisplaySecret(params: {
export function retainGeneratedOwnerDisplaySecret(params: {
config: OpenClawConfig;
configPath: string;
generatedSecret?: string;
logger: Pick<typeof console, "warn">;
state: OwnerDisplaySecretPersistState;
persistConfig: (
config: OpenClawConfig,
options: { expectedConfigPath: string },
) => Promise<unknown>;
state: OwnerDisplaySecretRuntimeState;
}): OpenClawConfig {
const { config, configPath, generatedSecret, logger, state, persistConfig } = params;
const { config, configPath, generatedSecret, state } = params;
if (!generatedSecret) {
state.pendingByPath.delete(configPath);
state.persistWarned.delete(configPath);
return config;
}
state.pendingByPath.set(configPath, generatedSecret);
if (!state.persistInFlight.has(configPath)) {
state.persistInFlight.add(configPath);
void persistConfig(config, { expectedConfigPath: configPath })
.then(() => {
state.pendingByPath.delete(configPath);
state.persistWarned.delete(configPath);
})
.catch((err) => {
if (!state.persistWarned.has(configPath)) {
state.persistWarned.add(configPath);
logger.warn(
`Failed to persist auto-generated commands.ownerDisplaySecret at ${configPath}: ${String(err)}`,
);
}
})
.finally(() => {
state.persistInFlight.delete(configPath);
});
}
return config;
}
+68 -45
View File
@@ -57,7 +57,7 @@ import {
promoteConfigSnapshotToLastKnownGood as promoteConfigSnapshotToLastKnownGoodWithDeps,
recoverConfigFromLastKnownGood as recoverConfigFromLastKnownGoodWithDeps,
} from "./io.observe-recovery.js";
import { persistGeneratedOwnerDisplaySecret } from "./io.owner-display-secret.js";
import { retainGeneratedOwnerDisplaySecret } from "./io.owner-display-secret.js";
import {
collectChangedPaths,
createMergePatch,
@@ -75,6 +75,7 @@ import {
materializeRuntimeConfig,
} from "./materialize.js";
import { applyMergePatch } from "./merge-patch.js";
import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js";
import { resolveConfigPath, resolveIncludeRoots, resolveStateDir } from "./paths.js";
import {
extractShippedPluginInstallConfigRecords,
@@ -144,6 +145,7 @@ type ShippedPluginInstallConfigWriteMigration =
type ShippedPluginInstallConfigReadMigration = {
config: unknown;
validationConfig?: unknown;
persistedRootParsed?: unknown;
persistedRootRaw?: string;
};
@@ -1254,17 +1256,13 @@ export function createConfigIO(
cfg,
() => pendingSecret ?? crypto.randomBytes(32).toString("hex"),
);
const cfgWithOwnerDisplaySecret = persistGeneratedOwnerDisplaySecret({
const cfgWithOwnerDisplaySecret = retainGeneratedOwnerDisplaySecret({
config: ownerDisplaySecretResolution.config,
configPath,
generatedSecret: ownerDisplaySecretResolution.generatedSecret,
logger: deps.logger,
state: {
pendingByPath: AUTO_OWNER_DISPLAY_SECRET_BY_PATH,
persistInFlight: AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT,
persistWarned: AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED,
},
persistConfig: (nextConfig, options) => writeConfigFile(nextConfig, options),
});
return applyConfigOverrides(cfgWithOwnerDisplaySecret);
@@ -1335,7 +1333,7 @@ export function createConfigIO(
return { config: stripped };
}
if (options.persist === false) {
return { config: stripped };
return { config: configRaw, validationConfig: stripped };
}
try {
@@ -1389,6 +1387,23 @@ export function createConfigIO(
return { config: stripped };
}
function retainRuntimeOnlyShippedPluginInstallConfigRecords(
config: OpenClawConfig,
sourceRaw: unknown,
): OpenClawConfig {
const installRecords = extractShippedPluginInstallConfigRecords(sourceRaw);
if (Object.keys(installRecords).length === 0) {
return config;
}
return {
...config,
plugins: {
...config.plugins,
installs: installRecords,
},
};
}
function ensureShippedPluginInstallConfigRecordsMigratedForWrite(
snapshot: ConfigFileSnapshot,
): ShippedPluginInstallConfigWriteMigration {
@@ -1491,9 +1506,11 @@ export function createConfigIO(
);
const resolvedConfig = readResolution.resolvedConfigRaw;
const installMigration = migrateAndStripShippedPluginInstallConfigRecords(resolvedConfig, {
persist: false,
rootConfigRaw: parsed,
});
const effectiveConfigRaw = installMigration.config;
const validationConfigRaw = installMigration.validationConfig ?? effectiveConfigRaw;
const snapshotRaw = installMigration.persistedRootRaw ?? raw;
const snapshotParsed = installMigration.persistedRootParsed ?? parsed;
const hash = hashConfigRaw(snapshotRaw);
@@ -1502,8 +1519,8 @@ export function createConfigIO(
`Config (${configPath}): missing env var "${w.varName}" at ${w.configPath} - feature using this value will be unavailable`,
);
}
warnOnConfigMiskeys(effectiveConfigRaw, deps.logger);
if (typeof effectiveConfigRaw !== "object" || effectiveConfigRaw === null) {
warnOnConfigMiskeys(validationConfigRaw, deps.logger);
if (typeof validationConfigRaw !== "object" || validationConfigRaw === null) {
observeLoadConfigSnapshot({
...createConfigFileSnapshot({
path: configPath,
@@ -1521,10 +1538,13 @@ export function createConfigIO(
});
return {};
}
const preValidationDuplicates = findDuplicateAgentDirs(effectiveConfigRaw as OpenClawConfig, {
env: deps.env,
homedir: deps.homedir,
});
const preValidationDuplicates = findDuplicateAgentDirs(
validationConfigRaw as OpenClawConfig,
{
env: deps.env,
homedir: deps.homedir,
},
);
if (preValidationDuplicates.length > 0) {
throw new DuplicateAgentDirError(preValidationDuplicates);
}
@@ -1533,15 +1553,19 @@ export function createConfigIO(
if (pluginMetadataSnapshot) {
return pluginMetadataSnapshot;
}
const defaultAgentId = resolveDefaultAgentId(config);
pluginMetadataSnapshot = loadPluginMetadataSnapshot({
const metadataConfig = retainRuntimeOnlyShippedPluginInstallConfigRecords(
config,
workspaceDir: resolveAgentWorkspaceDir(config, defaultAgentId),
effectiveConfigRaw,
);
const defaultAgentId = resolveDefaultAgentId(metadataConfig);
pluginMetadataSnapshot = loadPluginMetadataSnapshot({
config: metadataConfig,
workspaceDir: resolveAgentWorkspaceDir(metadataConfig, defaultAgentId),
env: deps.env,
});
return pluginMetadataSnapshot;
};
const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, {
const validated = validateConfigObjectWithPlugins(validationConfigRaw, {
env: deps.env,
pluginValidation: overrides.pluginValidation,
loadPluginMetadataSnapshot: loadValidationPluginMetadataSnapshot,
@@ -1580,9 +1604,12 @@ export function createConfigIO(
deps.logger.warn(`Config warnings:\n${details}`);
}
warnIfConfigFromFuture(validated.config, deps.logger);
const cfg = materializeRuntimeConfig(validated.config, "load", {
manifestRegistry: pluginMetadataSnapshot?.manifestRegistry,
});
const cfg = retainRuntimeOnlyShippedPluginInstallConfigRecords(
materializeRuntimeConfig(validated.config, "load", {
manifestRegistry: pluginMetadataSnapshot?.manifestRegistry,
}),
effectiveConfigRaw,
);
observeLoadConfigSnapshot({
...createConfigFileSnapshot({
path: configPath,
@@ -1614,11 +1641,7 @@ export function createConfigIO(
}
}
async function readConfigFileSnapshotInternal(
options: {
persistShippedPluginInstallMigration?: boolean;
} = {},
): Promise<ReadConfigFileSnapshotInternalResult> {
async function readConfigFileSnapshotInternal(): Promise<ReadConfigFileSnapshotInternalResult> {
maybeLoadDotEnvForConfig(deps.env);
const exists = deps.fs.existsSync(configPath);
if (!exists) {
@@ -1729,11 +1752,12 @@ export function createConfigIO(
"config.snapshot.read.plugin-install-migration",
() =>
migrateAndStripShippedPluginInstallConfigRecords(resolvedConfigRaw, {
persist: options.persistShippedPluginInstallMigration !== false,
persist: false,
rootConfigRaw: effectiveParsed,
}),
);
const effectiveConfigRaw = installMigration.config;
const validationConfigRaw = installMigration.validationConfig ?? effectiveConfigRaw;
const snapshotRaw = installMigration.persistedRootRaw ?? raw;
const snapshotParsed = installMigration.persistedRootParsed ?? effectiveParsed;
const snapshotHash = installMigration.persistedRootRaw
@@ -1745,16 +1769,20 @@ export function createConfigIO(
if (pluginMetadataSnapshot) {
return pluginMetadataSnapshot;
}
const defaultAgentId = resolveDefaultAgentId(config);
pluginMetadataSnapshot = loadPluginMetadataSnapshot({
const metadataConfig = retainRuntimeOnlyShippedPluginInstallConfigRecords(
config,
workspaceDir: resolveAgentWorkspaceDir(config, defaultAgentId),
effectiveConfigRaw,
);
const defaultAgentId = resolveDefaultAgentId(metadataConfig);
pluginMetadataSnapshot = loadPluginMetadataSnapshot({
config: metadataConfig,
workspaceDir: resolveAgentWorkspaceDir(metadataConfig, defaultAgentId),
env: deps.env,
});
return pluginMetadataSnapshot;
};
const validated = await deps.measure("config.snapshot.read.validate", () =>
validateConfigObjectWithPlugins(effectiveConfigRaw, {
validateConfigObjectWithPlugins(validationConfigRaw, {
env: deps.env,
pluginValidation: overrides.pluginValidation,
loadPluginMetadataSnapshot: loadValidationPluginMetadataSnapshot,
@@ -1784,9 +1812,12 @@ export function createConfigIO(
warnIfConfigFromFuture(validated.config, deps.logger);
const snapshotConfig = await deps.measure("config.snapshot.read.materialize", () =>
materializeRuntimeConfig(validated.config, "snapshot", {
manifestRegistry: pluginMetadataSnapshot?.manifestRegistry,
}),
retainRuntimeOnlyShippedPluginInstallConfigRecords(
materializeRuntimeConfig(validated.config, "snapshot", {
manifestRegistry: pluginMetadataSnapshot?.manifestRegistry,
}),
effectiveConfigRaw,
),
);
return await deps.measure("config.snapshot.read.observe", () =>
finalizeReadConfigSnapshotInternalResult(deps, {
@@ -1892,9 +1923,7 @@ export function createConfigIO(
}
async function readConfigFileSnapshotForWrite(): Promise<ReadConfigFileSnapshotForWriteResult> {
const result = await readConfigFileSnapshotInternal({
persistShippedPluginInstallMigration: false,
});
const result = await readConfigFileSnapshotInternal();
return {
snapshot: result.snapshot,
writeOptions: {
@@ -1949,16 +1978,11 @@ export function createConfigIO(
cfg: OpenClawConfig,
options: ConfigWriteOptions = {},
): Promise<{ persistedHash: string; persistedConfig: OpenClawConfig }> {
assertConfigWriteAllowedInCurrentMode({ configPath, env: deps.env });
clearConfigCache();
const unsetPaths = resolveManagedUnsetPathsForWrite(options.unsetPaths);
let persistCandidate: unknown = cfg;
const snapshot =
options.baseSnapshot ??
(
await readConfigFileSnapshotInternal({
persistShippedPluginInstallMigration: false,
})
).snapshot;
const snapshot = options.baseSnapshot ?? (await readConfigFileSnapshotInternal()).snapshot;
let envRefMap: Map<string, string> | null = null;
let changedPaths: Set<string> | null = null;
if (snapshot.valid && snapshot.exists) {
@@ -2241,8 +2265,6 @@ export function createConfigIO(
// module scope. `OPENCLAW_CONFIG_PATH` (and friends) are expected to work even
// when set after the module has been imported (tests, one-off scripts, etc.).
const AUTO_OWNER_DISPLAY_SECRET_BY_PATH = new Map<string, string>();
const AUTO_OWNER_DISPLAY_SECRET_PERSIST_IN_FLIGHT = new Set<string>();
const AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED = new Set<string>();
export function clearConfigCache(): void {
// Compat shim: runtime snapshot is the only in-process cache now.
}
@@ -2381,6 +2403,7 @@ export async function writeConfigFile(
options: ConfigWriteOptions = {},
): Promise<void> {
const io = createConfigIO(options.skipPluginValidation ? { pluginValidation: "skip" } : {});
assertConfigWriteAllowedInCurrentMode({ configPath: io.configPath });
let nextCfg = cfg;
const runtimeConfigSnapshot = getRuntimeConfigSnapshotState();
const runtimeConfigSourceSnapshot = getRuntimeConfigSourceSnapshotState();
+136 -7
View File
@@ -172,7 +172,31 @@ describe("config io write", () => {
});
});
it("migrates shipped plugin install config records into the plugin index", async () => {
it("refuses direct config writes in Nix mode without changing the file", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
const initialRaw = `${JSON.stringify({ gateway: { mode: "local" } }, null, 2)}\n`;
await fs.writeFile(configPath, initialRaw, "utf-8");
const io = createConfigIO({
configPath,
env: {
OPENCLAW_NIX_MODE: "1",
OPENCLAW_TEST_FAST: "1",
} as NodeJS.ProcessEnv,
homedir: () => home,
logger: silentLogger,
});
await expect(io.writeConfigFile({ gateway: { mode: "local", port: 19001 } })).rejects.toThrow(
"Agent-first Nix setup: https://github.com/openclaw/nix-openclaw#quick-start",
);
await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(initialRaw);
});
});
it("loads shipped plugin install config records without mutating config or plugin index", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const pluginDir = path.join(home, ".openclaw", "plugins", "demo");
@@ -229,9 +253,103 @@ describe("config io write", () => {
const io = createFastConfigIO(home);
try {
const initialRaw = await fs.readFile(configPath, "utf-8");
const cfg = io.loadConfig();
expect(cfg.plugins?.installs).toBeUndefined();
expect(cfg.plugins?.installs?.demo).toMatchObject({
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
});
const snapshot = await io.readConfigFileSnapshot();
expect(snapshot.sourceConfig.plugins?.installs?.demo).toMatchObject({
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
});
expect(snapshot.runtimeConfig.plugins?.installs?.demo).toMatchObject({
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
});
await expect(
readPersistedInstalledPluginIndex({
stateDir: path.join(home, ".openclaw"),
}),
).resolves.toBeNull();
await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(initialRaw);
} finally {
mockLoadPluginManifestRegistry.mockReturnValue({
diagnostics: [],
plugins: [],
} satisfies PluginManifestRegistry);
}
});
});
it("migrates shipped plugin install config records into the plugin index during explicit writes", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const pluginDir = path.join(home, ".openclaw", "plugins", "demo");
const manifestPath = path.join(pluginDir, "openclaw.plugin.json");
const source = path.join(pluginDir, "index.ts");
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(source, "export function register() {}\n", "utf-8");
await fs.writeFile(
manifestPath,
`${JSON.stringify({ id: "demo", configSchema: { type: "object" } }, null, 2)}\n`,
"utf-8",
);
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
plugins: {
entries: { demo: { enabled: true } },
installs: {
demo: {
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
},
},
},
},
null,
2,
)}\n`,
"utf-8",
);
mockLoadPluginManifestRegistry.mockReturnValue({
diagnostics: [],
plugins: [
{
id: "demo",
origin: "global",
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
rootDir: pluginDir,
source,
manifestPath,
configSchema: {
type: "object",
},
},
],
} satisfies PluginManifestRegistry);
const io = createFastConfigIO(home);
try {
await io.writeConfigFile({
plugins: {
entries: { demo: { enabled: true } },
},
});
await expect(
readPersistedInstalledPluginIndex({
stateDir: path.join(home, ".openclaw"),
@@ -264,7 +382,7 @@ describe("config io write", () => {
});
});
it("migrates shipped plugin install config records even when the manifest is missing", async () => {
it("migrates shipped plugin install config records during explicit writes even when the manifest is missing", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const pluginDir = path.join(home, ".openclaw", "plugins", "missing");
@@ -291,9 +409,12 @@ describe("config io write", () => {
);
const io = createFastConfigIO(home);
const cfg = io.loadConfig();
await io.writeConfigFile({
plugins: {
entries: { missing: { enabled: true } },
},
});
expect(cfg.plugins?.installs).toBeUndefined();
await expect(
readPersistedInstalledPluginIndex({
stateDir: path.join(home, ".openclaw"),
@@ -342,8 +463,16 @@ describe("config io write", () => {
});
await fs.writeFile(path.join(unwritableStatePath, "plugins"), "not a directory", "utf-8");
expect(() => io.loadConfig()).toThrow('Unrecognized key: "installs"');
expect(warn).toHaveBeenCalledWith(
let loadedConfig: ReturnType<typeof io.loadConfig> | undefined;
expect(() => {
loadedConfig = io.loadConfig();
}).not.toThrow();
expect(loadedConfig?.plugins?.installs?.demo).toMatchObject({
source: "npm",
spec: "demo@1.0.0",
installPath: pluginDir,
});
expect(warn).not.toHaveBeenCalledWith(
expect.stringContaining("could not migrate shipped plugins.installs records"),
);
+51
View File
@@ -43,12 +43,18 @@ function createSnapshot(params: {
describe("config mutate helpers", () => {
const suiteRootTracker = createSuiteTempRootTracker({ prefix: "openclaw-config-mutate-" });
const originalNixMode = process.env.OPENCLAW_NIX_MODE;
beforeAll(async () => {
await suiteRootTracker.setup();
});
afterAll(async () => {
if (originalNixMode === undefined) {
delete process.env.OPENCLAW_NIX_MODE;
} else {
process.env.OPENCLAW_NIX_MODE = originalNixMode;
}
await suiteRootTracker.cleanup();
});
@@ -58,6 +64,7 @@ describe("config mutate helpers", () => {
ioMocks.resolveConfigSnapshotHash.mockImplementation(
(snapshot: { hash?: string }) => snapshot.hash ?? null,
);
delete process.env.OPENCLAW_NIX_MODE;
});
it("mutates source config with optimistic hash protection", async () => {
@@ -118,6 +125,50 @@ describe("config mutate helpers", () => {
expect(ioMocks.writeConfigFile).not.toHaveBeenCalled();
});
it("refuses replace writes in Nix mode before touching disk", async () => {
process.env.OPENCLAW_NIX_MODE = "1";
const snapshot = createSnapshot({
hash: "hash-1",
sourceConfig: { gateway: { port: 18789 } },
});
ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot,
writeOptions: { expectedConfigPath: snapshot.path },
});
await expect(
replaceConfigFile({
nextConfig: { gateway: { port: 19001 } },
}),
).rejects.toThrow(
"Agent-first Nix setup: https://github.com/openclaw/nix-openclaw#quick-start",
);
expect(ioMocks.writeConfigFile).not.toHaveBeenCalled();
});
it("refuses mutate writes in Nix mode before touching disk", async () => {
process.env.OPENCLAW_NIX_MODE = "1";
const snapshot = createSnapshot({
hash: "hash-1",
sourceConfig: { gateway: { port: 18789 } },
});
ioMocks.readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot,
writeOptions: { expectedConfigPath: snapshot.path },
});
await expect(
mutateConfigFile({
mutate(draft) {
draft.gateway = { ...draft.gateway, port: 19001 };
},
}),
).rejects.toThrow("OpenClaw Nix overview: https://docs.openclaw.ai/install/nix");
expect(ioMocks.writeConfigFile).not.toHaveBeenCalled();
});
it("reuses a provided snapshot and write options for replace", async () => {
const snapshot = createSnapshot({
hash: "hash-1",
+3
View File
@@ -15,6 +15,7 @@ import {
type ConfigWriteOptions,
} from "./io.js";
import { applyUnsetPathsForWrite, resolveManagedUnsetPathsForWrite } from "./io.write-prepare.js";
import { assertConfigWriteAllowedInCurrentMode } from "./nix-mode-write-guard.js";
import {
createRuntimeConfigWriteNotification,
finalizeRuntimeSnapshotWrite,
@@ -228,6 +229,7 @@ export async function replaceConfigFile(params: {
? { snapshot: params.snapshot, writeOptions: params.writeOptions }
: await (params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite)();
const { snapshot, writeOptions } = prepared;
assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path });
const previousHash = assertBaseHashMatches(snapshot, params.baseHash);
const afterWrite = resolveConfigWriteAfterWrite(
params.afterWrite ?? params.writeOptions?.afterWrite,
@@ -271,6 +273,7 @@ export async function mutateConfigFile<T = void>(params: {
const { snapshot, writeOptions } = await (
params.io?.readConfigFileSnapshotForWrite ?? readConfigFileSnapshotForWrite
)();
assertConfigWriteAllowedInCurrentMode({ configPath: snapshot.path });
const previousHash = assertBaseHashMatches(snapshot, params.baseHash);
const baseConfig = params.base === "runtime" ? snapshot.runtimeConfig : snapshot.sourceConfig;
const draft = structuredClone(baseConfig) as OpenClawConfig;
+37
View File
@@ -0,0 +1,37 @@
import { resolveIsNixMode } from "./paths.js";
export const NIX_OPENCLAW_AGENT_FIRST_URL = "https://github.com/openclaw/nix-openclaw#quick-start";
export const OPENCLAW_NIX_OVERVIEW_URL = "https://docs.openclaw.ai/install/nix";
export class NixModeConfigMutationError extends Error {
readonly code = "OPENCLAW_NIX_MODE_CONFIG_IMMUTABLE";
constructor(params: { configPath?: string } = {}) {
super(formatNixModeConfigMutationMessage(params));
this.name = "NixModeConfigMutationError";
}
}
export function formatNixModeConfigMutationMessage(params: { configPath?: string } = {}): string {
return [
"Config is managed by Nix (`OPENCLAW_NIX_MODE=1`), so OpenClaw treats openclaw.json as immutable.",
"This usually means nix-openclaw, the first-party Nix distribution, or another Nix-managed package set this mode.",
...(params.configPath ? [`Config path: ${params.configPath}`] : []),
"Do not run setup, onboarding, openclaw update, plugin install/update/uninstall/enable, doctor repair/token-generation, or config set against this file.",
"Edit the Nix source for this install instead. For nix-openclaw, edit `programs.openclaw.config` or `instances.<name>.config`, then rebuild with Home Manager or NixOS.",
`Agent-first Nix setup: ${NIX_OPENCLAW_AGENT_FIRST_URL}`,
`OpenClaw Nix overview: ${OPENCLAW_NIX_OVERVIEW_URL}`,
].join("\n");
}
export function assertConfigWriteAllowedInCurrentMode(
params: {
configPath?: string;
env?: NodeJS.ProcessEnv;
} = {},
): void {
if (!resolveIsNixMode(params.env)) {
return;
}
throw new NixModeConfigMutationError({ configPath: params.configPath });
}
+5
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 });
@@ -277,7 +277,7 @@ describe("gateway startup config validation", () => {
});
});
it("preserves empty model allowlist entries through startup auto-enable writes", async () => {
it("preserves empty model allowlist entries through runtime-only startup auto-enable", async () => {
const sourceConfig = {
agents: {
defaults: {
@@ -323,31 +323,10 @@ describe("gateway startup config validation", () => {
runtimeConfig: sourceConfig,
config: sourceConfig,
} satisfies ConfigFileSnapshot;
const postWriteSnapshot = {
...buildTestConfigSnapshot({
path: configPath,
exists: true,
raw: `${JSON.stringify(autoEnabledConfig)}\n`,
parsed: autoEnabledConfig,
valid: true,
config: autoEnabledConfig,
issues: [],
legacyIssues: [],
}),
sourceConfig: autoEnabledConfig,
resolved: autoEnabledConfig,
runtimeConfig: autoEnabledConfig,
config: autoEnabledConfig,
} satisfies ConfigFileSnapshot;
vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata)
.mockResolvedValueOnce({
snapshot: initialSnapshot,
pluginMetadataSnapshot,
})
.mockResolvedValueOnce({
snapshot: postWriteSnapshot,
pluginMetadataSnapshot,
});
vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata).mockResolvedValueOnce({
snapshot: initialSnapshot,
pluginMetadataSnapshot,
});
applyPluginAutoEnable.mockReturnValueOnce({
config: autoEnabledConfig,
changes: ["Telegram configured, enabled automatically."],
@@ -361,8 +340,12 @@ describe("gateway startup config validation", () => {
log,
}),
).resolves.toEqual({
snapshot: postWriteSnapshot,
wroteConfig: true,
snapshot: {
...initialSnapshot,
runtimeConfig: autoEnabledConfig,
config: autoEnabledConfig,
},
wroteConfig: false,
pluginMetadataSnapshot,
});
@@ -371,27 +354,90 @@ describe("gateway startup config validation", () => {
env: process.env,
manifestRegistry: pluginManifestRegistry,
});
expect(configMutate.replaceConfigFile).toHaveBeenCalledWith({
nextConfig: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({
models: {
"dos-ai/dos-ai": {},
"dos-ai/dos-auto": {},
},
}),
}),
expect(configMutate.replaceConfigFile).not.toHaveBeenCalled();
expect(configIo.readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledTimes(1);
expect(initialSnapshot.sourceConfig.agents?.defaults?.models).toEqual({
"dos-ai/dos-ai": {},
"dos-ai/dos-auto": {},
});
expect(initialSnapshot.sourceConfig.channels?.telegram).toBeUndefined();
expect(autoEnabledConfig.agents?.defaults?.models).toEqual({
"dos-ai/dos-ai": {},
"dos-ai/dos-auto": {},
});
expect(autoEnabledConfig.channels?.telegram).toEqual({
enabled: true,
});
expect(log.info).toHaveBeenCalledWith(
"gateway: auto-enabled plugins for this runtime without writing config:\n- Telegram configured, enabled automatically.",
);
expect(log.warn).not.toHaveBeenCalled();
});
it("keeps plugin auto-enable runtime-only in Nix mode", async () => {
const sourceConfig = {
channels: {
telegram: {
botToken: "test-token",
},
},
gateway: { mode: "local" },
} as unknown as OpenClawConfig;
const autoEnabledConfig = {
...sourceConfig,
plugins: {
allow: ["telegram"],
},
} as unknown as OpenClawConfig;
const snapshot = {
...buildTestConfigSnapshot({
path: configPath,
exists: true,
raw: `${JSON.stringify(sourceConfig)}\n`,
parsed: sourceConfig,
valid: true,
config: sourceConfig,
issues: [],
legacyIssues: [],
}),
afterWrite: { mode: "auto" },
sourceConfig,
resolved: sourceConfig,
runtimeConfig: sourceConfig,
config: sourceConfig,
} satisfies ConfigFileSnapshot;
vi.mocked(configIo.readConfigFileSnapshotWithPluginMetadata).mockResolvedValueOnce({
snapshot,
pluginMetadataSnapshot,
});
expect(postWriteSnapshot.sourceConfig.agents?.defaults?.models).toEqual({
"dos-ai/dos-ai": {},
"dos-ai/dos-auto": {},
applyPluginAutoEnable.mockReturnValueOnce({
config: autoEnabledConfig,
changes: ["Telegram configured, enabled automatically."],
autoEnabledReasons: {},
});
expect(postWriteSnapshot.config.agents?.defaults?.models).toEqual({
"dos-ai/dos-ai": {},
"dos-ai/dos-auto": {},
configMocks.isNixMode.value = true;
const log = { info: vi.fn(), warn: vi.fn() };
await expect(
loadGatewayStartupConfigSnapshot({
minimalTestGateway: false,
log,
}),
).resolves.toEqual({
snapshot: {
...snapshot,
runtimeConfig: autoEnabledConfig,
config: autoEnabledConfig,
},
wroteConfig: false,
pluginMetadataSnapshot,
});
expect(configMutate.replaceConfigFile).not.toHaveBeenCalled();
expect(configIo.readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledTimes(1);
expect(log.info).toHaveBeenCalledWith(
"gateway: auto-enabled plugins for this runtime without writing config:\n- Telegram configured, enabled automatically.",
);
expect(log.warn).not.toHaveBeenCalled();
});
it("rejects invalid config before startup without automatic recovery", async () => {
+20 -26
View File
@@ -63,14 +63,14 @@ export async function loadGatewayStartupConfigSnapshot(params: {
initialSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult;
}): Promise<GatewayStartupConfigSnapshotLoadResult> {
const measure = params.measure ?? (async (_name, run) => await run());
let snapshotRead =
const snapshotRead =
params.initialSnapshotRead ??
(await measure("config.snapshot.read", () =>
readConfigFileSnapshotWithPluginMetadata({ measure }),
));
let configSnapshot = snapshotRead.snapshot;
let pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot;
let wroteConfig = false;
const configSnapshot = snapshotRead.snapshot;
const pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot;
const wroteConfig = false;
if (configSnapshot.legacyIssues.length > 0 && isNixMode) {
throw new Error(
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
@@ -99,33 +99,27 @@ export async function loadGatewayStartupConfigSnapshot(params: {
};
}
try {
const { replaceConfigFile } = await import("../config/mutate.js");
await replaceConfigFile({
nextConfig: autoEnable.config,
afterWrite: { mode: "auto" },
});
wroteConfig = true;
snapshotRead = await measure("config.snapshot.auto-enable-read", () =>
readConfigFileSnapshotWithPluginMetadata({ measure }),
);
configSnapshot = snapshotRead.snapshot;
pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot;
assertValidGatewayStartupConfigSnapshot(configSnapshot);
params.log.info(
`gateway: auto-enabled plugins:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`,
);
} catch (err) {
params.log.warn(`gateway: failed to persist plugin auto-enable changes: ${String(err)}`);
}
params.log.info(
`gateway: auto-enabled plugins for this runtime without writing config:\n${autoEnable.changes.map((entry) => `- ${entry}`).join("\n")}`,
);
return {
snapshot: configSnapshot,
snapshot: withRuntimeConfig(configSnapshot, autoEnable.config),
wroteConfig,
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
};
}
function withRuntimeConfig(
snapshot: ConfigFileSnapshot,
runtimeConfig: OpenClawConfig,
): ConfigFileSnapshot {
return {
...snapshot,
runtimeConfig,
config: runtimeConfig,
};
}
export function createRuntimeSecretsActivator(params: {
logSecrets: GatewayStartupLog;
emitStateEvent: (
@@ -278,7 +272,7 @@ export async function prepareGatewayStartupConfig(params: {
env: process.env,
authOverride: preflightAuthOverride,
tailscaleOverride: params.tailscaleOverride,
persist: params.persistStartupAuth ?? true,
persist: params.persistStartupAuth ?? false,
baseHash: params.configSnapshot.hash,
});
const runtimeStartupConfig = applyGatewayAuthOverridesForStartupPreflight(authBootstrap.cfg, {
+18 -26
View File
@@ -397,6 +397,19 @@ function createGatewayStartupTrace() {
};
}
function formatRuntimeGatewayAuthTokenWarning(): string {
const base =
"Gateway auth token was missing. Generated a runtime token for this startup without changing config; restart will generate a different token.";
if (!isNixMode) {
return `${base} Persist one with \`openclaw config set gateway.auth.mode token\` and \`openclaw config set gateway.auth.token <token>\`.`;
}
return [
base,
"In Nix mode, set gateway.auth.token in your Nix-managed OpenClaw config and rebuild.",
"For the first-party Nix flow, see https://github.com/openclaw/nix-openclaw#quick-start and https://docs.openclaw.ai/install/nix.",
].join(" ");
}
function collectProcessMemoryUsageMb(): ReadonlyArray<readonly [string, number]> {
const usage = process.memoryUsage();
const toMb = (bytes: number) => bytes / 1024 / 1024;
@@ -577,21 +590,12 @@ export async function startGatewayServer(
authOverride: opts.auth,
tailscaleOverride: opts.tailscale,
activateRuntimeSecrets,
persistStartupAuth: true,
}),
);
cfgAtStart = authBootstrap.cfg;
startupTrace.setConfig(cfgAtStart);
if (authBootstrap.generatedToken) {
if (authBootstrap.persistedGeneratedToken) {
log.info(
"Gateway auth token was missing. Generated a new token and saved it to config (gateway.auth.token).",
);
} else {
log.warn(
"Gateway auth token was missing. Generated a runtime token for this startup without changing config; restart will generate a different token. Persist one with `openclaw config set gateway.auth.mode token` and `openclaw config set gateway.auth.token <token>`.",
);
}
log.warn(formatRuntimeGatewayAuthTokenWarning());
}
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
setDiagnosticsEnabledForProcess(diagnosticsEnabled);
@@ -610,31 +614,19 @@ export async function startGatewayServer(
// Unconditional startup migration: seed gateway.controlUi.allowedOrigins for existing
// non-loopback installs that upgraded to v2026.2.26+ without required origins.
const controlUiSeed = minimalTestGateway
? { config: cfgAtStart, persistedAllowedOriginsSeed: false }
? { config: cfgAtStart, seededAllowedOrigins: false }
: await startupTrace.measure("control-ui.seed", () =>
maybeSeedControlUiAllowedOriginsAtStartup({
config: cfgAtStart,
writeConfig: async (nextConfig) => {
const { replaceConfigFile } = await import("../config/mutate.js");
await replaceConfigFile({
nextConfig,
afterWrite: { mode: "auto" },
});
},
log,
runtimeBind: opts.bind,
runtimePort: port,
}),
);
cfgAtStart = controlUiSeed.config;
// Capture the final config hash only after startup writes (plugin auto-enable,
// auth token generation, control-UI origin seeding) so the config reloader can
// suppress its own persistence events without rereading config on every boot.
if (
startupConfigLoad.wroteConfig ||
authBootstrap.persistedGeneratedToken ||
controlUiSeed.persistedAllowedOriginsSeed
) {
// Keep the old startup-write suppression path intact for compatibility with
// callers that may still report a write, but startup itself no longer mutates config.
if (startupConfigLoad.wroteConfig || authBootstrap.persistedGeneratedToken) {
const startupSnapshot = await startupTrace.measure("config.final-snapshot", () =>
readConfigFileSnapshot(),
);
+5 -12
View File
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { expectGeneratedTokenPersistedToGatewayAuth } from "../test-utils/auth-token-assertions.js";
import { KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS } from "./known-weak-gateway-secrets.js";
import {
assertGatewayAuthNotKnownWeak,
@@ -96,7 +95,7 @@ describe("ensureGatewayStartupAuth", () => {
};
}
it("generates and persists a token when startup auth is missing", async () => {
it("generates a runtime token without persisting when startup auth is missing", async () => {
const result = await ensureGatewayStartupAuth({
cfg: {},
env: {} as NodeJS.ProcessEnv,
@@ -104,17 +103,11 @@ describe("ensureGatewayStartupAuth", () => {
});
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.persistedGeneratedToken).toBe(true);
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(mocks.replaceConfigFile).toHaveBeenCalledTimes(1);
const persistedParams = mocks.replaceConfigFile.mock.calls[0]?.[0] as
| { nextConfig: OpenClawConfig }
| undefined;
expectGeneratedTokenPersistedToGatewayAuth({
generatedToken: result.generatedToken,
authToken: result.auth.token,
persistedConfig: persistedParams?.nextConfig,
});
expect(result.auth.token).toBe(result.generatedToken);
expect(result.cfg.gateway?.auth?.token).toBe(result.generatedToken);
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
});
it("does not generate when token already exists", async () => {
+5 -31
View File
@@ -1,5 +1,4 @@
import crypto from "node:crypto";
import { replaceConfigFile } from "../config/mutate.js";
import type { GatewayAuthConfig, GatewayTailscaleConfig } from "../config/types.gateway.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
@@ -83,23 +82,6 @@ function resolveGatewayAuthFromConfig(params: {
});
}
function shouldPersistGeneratedToken(params: {
persistRequested: boolean;
resolvedAuth: ResolvedGatewayAuth;
}): boolean {
if (!params.persistRequested) {
return false;
}
// Keep CLI/runtime mode overrides ephemeral: startup should not silently
// mutate durable auth policy when mode was chosen by an override flag.
if (params.resolvedAuth.modeSource === "override") {
return false;
}
return true;
}
function hasGatewayTokenCandidate(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
@@ -142,6 +124,10 @@ export async function ensureGatewayStartupAuth(params: {
env?: NodeJS.ProcessEnv;
authOverride?: GatewayAuthConfig;
tailscaleOverride?: GatewayTailscaleConfig;
/**
* Legacy startup option retained for external callers. Startup-generated auth
* is runtime-only; durable auth changes must go through explicit config tools.
*/
persist?: boolean;
baseHash?: string;
}): Promise<{
@@ -152,7 +138,6 @@ export async function ensureGatewayStartupAuth(params: {
}> {
assertExplicitGatewayAuthModeWhenBothConfigured(params.cfg);
const env = params.env ?? process.env;
const persistRequested = params.persist === true;
const explicitMode = params.authOverride?.mode ?? params.cfg.gateway?.auth?.mode;
const [resolvedTokenRefValue, resolvedPasswordRefValue] = await Promise.all([
resolveGatewayTokenSecretRefValue({
@@ -213,17 +198,6 @@ export async function ensureGatewayStartupAuth(params: {
},
},
};
const persist = shouldPersistGeneratedToken({
persistRequested,
resolvedAuth: resolved,
});
if (persist) {
await replaceConfigFile({
nextConfig: nextCfg,
baseHash: params.baseHash,
});
}
const nextAuth = resolveGatewayAuthFromConfig({
cfg: nextCfg,
env,
@@ -240,7 +214,7 @@ export async function ensureGatewayStartupAuth(params: {
cfg: nextCfg,
auth: nextAuth,
generatedToken,
persistedGeneratedToken: persist,
persistedGeneratedToken: false,
};
}
+3 -12
View File
@@ -3,25 +3,19 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { maybeSeedControlUiAllowedOriginsAtStartup } from "./startup-control-ui-origins.js";
describe("maybeSeedControlUiAllowedOriginsAtStartup", () => {
it("persists origins seeded from runtime bind and port", async () => {
const written: OpenClawConfig[] = [];
it("applies origins seeded from runtime bind and port without persisting config", async () => {
const log = { info: vi.fn(), warn: vi.fn() };
const result = await maybeSeedControlUiAllowedOriginsAtStartup({
config: { gateway: {} },
writeConfig: async (config) => {
written.push(config);
},
log,
runtimeBind: "lan",
runtimePort: 3000,
});
const expectedOrigins = ["http://localhost:3000", "http://127.0.0.1:3000"];
expect(result.persistedAllowedOriginsSeed).toBe(true);
expect(result.seededAllowedOrigins).toBe(true);
expect(result.config.gateway?.controlUi?.allowedOrigins).toEqual(expectedOrigins);
expect(written).toHaveLength(1);
expect(written[0]?.gateway?.controlUi?.allowedOrigins).toEqual(expectedOrigins);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining("for bind=lan"));
expect(log.warn).not.toHaveBeenCalled();
});
@@ -32,19 +26,16 @@ describe("maybeSeedControlUiAllowedOriginsAtStartup", () => {
controlUi: { allowedOrigins: ["https://control.example.com"] },
},
};
const writeConfig = vi.fn<() => Promise<void>>();
const log = { info: vi.fn(), warn: vi.fn() };
const result = await maybeSeedControlUiAllowedOriginsAtStartup({
config,
writeConfig,
log,
runtimeBind: "lan",
runtimePort: 3000,
});
expect(result).toEqual({ config, persistedAllowedOriginsSeed: false });
expect(writeConfig).not.toHaveBeenCalled();
expect(result).toEqual({ config, seededAllowedOrigins: false });
expect(log.info).not.toHaveBeenCalled();
expect(log.warn).not.toHaveBeenCalled();
});
+5 -14
View File
@@ -7,35 +7,26 @@ import { isContainerEnvironment } from "./net.js";
export async function maybeSeedControlUiAllowedOriginsAtStartup(params: {
config: OpenClawConfig;
writeConfig: (config: OpenClawConfig) => Promise<void>;
log: { info: (msg: string) => void; warn: (msg: string) => void };
runtimeBind?: unknown;
runtimePort?: unknown;
}): Promise<{ config: OpenClawConfig; persistedAllowedOriginsSeed: boolean }> {
}): Promise<{ config: OpenClawConfig; seededAllowedOrigins: boolean }> {
const seeded = ensureControlUiAllowedOriginsForNonLoopbackBind(params.config, {
isContainerEnvironment,
runtimeBind: params.runtimeBind,
runtimePort: params.runtimePort,
});
if (!seeded.seededOrigins || !seeded.bind) {
return { config: params.config, persistedAllowedOriginsSeed: false };
return { config: params.config, seededAllowedOrigins: false };
}
try {
await params.writeConfig(seeded.config);
params.log.info(buildSeededOriginsInfoLog(seeded.seededOrigins, seeded.bind));
return { config: seeded.config, persistedAllowedOriginsSeed: true };
} catch (err) {
params.log.warn(
`gateway: failed to persist gateway.controlUi.allowedOrigins seed: ${String(err)}. The gateway will start with the in-memory value but config was not saved.`,
);
}
return { config: seeded.config, persistedAllowedOriginsSeed: false };
params.log.info(buildSeededOriginsInfoLog(seeded.seededOrigins, seeded.bind));
return { config: seeded.config, seededAllowedOrigins: true };
}
function buildSeededOriginsInfoLog(origins: string[], bind: GatewayNonLoopbackBindMode): string {
return (
`gateway: seeded gateway.controlUi.allowedOrigins ${JSON.stringify(origins)} ` +
`for bind=${bind} (required since v2026.2.26; see issue #29385). ` +
"Add other origins to gateway.controlUi.allowedOrigins if needed."
"Applied for this runtime without writing config; add other origins to gateway.controlUi.allowedOrigins if needed."
);
}