mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
-1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+10
-1
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user