From 2fe39ce94957da2680a9718aac6bb9d05c8dc834 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 13 May 2026 10:24:29 +0100 Subject: [PATCH] refactor: rebase runtime config writes --- extensions/active-memory/index.ts | 16 ++-- .../browser/src/browser/control-auth.ts | 46 +++++----- .../browser/src/browser/profiles-service.ts | 44 ++++----- extensions/browser/src/config/config.ts | 1 + extensions/browser/src/sdk-config.ts | 2 +- .../memory-core/src/dreaming-command.ts | 16 ++-- extensions/nostr/index.ts | 16 ++-- extensions/phone-control/index.ts | 28 +++--- .../slack/src/monitor/events/channels.ts | 13 ++- extensions/talk-voice/index.ts | 36 ++++---- .../telegram/src/bot-handlers.runtime.ts | 8 +- src/auto-reply/reply/commands-allowlist.ts | 52 ++++++++--- src/auto-reply/reply/commands-config.ts | 89 ++++++++++++------ src/auto-reply/reply/commands-plugins.ts | 48 ++++++---- src/commands/gateway-install-token.test.ts | 1 + src/commands/gateway-install-token.ts | 1 + .../server-methods/agents-mutate.test.ts | 21 +++++ src/gateway/server-methods/agents.ts | 78 ++++++++++++---- src/gateway/server-methods/skills.ts | 90 +++++++++---------- 19 files changed, 376 insertions(+), 230 deletions(-) diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index e01359e3f31..4cdc0ac0254 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -2957,19 +2957,23 @@ export default definePluginEntry({ }; } if (action === "on" || action === "enable" || action === "enabled") { - const nextConfig = updateActiveMemoryGlobalEnabledInConfig(currentConfig, true); - await api.runtime.config.replaceConfigFile({ - nextConfig, + await api.runtime.config.mutateConfigFile({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + const nextConfig = updateActiveMemoryGlobalEnabledInConfig(draft, true); + Object.assign(draft, nextConfig); + }, }); refreshLiveConfigFromRuntime(); return { text: "Active Memory: on globally." }; } if (action === "off" || action === "disable" || action === "disabled") { - const nextConfig = updateActiveMemoryGlobalEnabledInConfig(currentConfig, false); - await api.runtime.config.replaceConfigFile({ - nextConfig, + await api.runtime.config.mutateConfigFile({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + const nextConfig = updateActiveMemoryGlobalEnabledInConfig(draft, false); + Object.assign(draft, nextConfig); + }, }); refreshLiveConfigFromRuntime(); return { text: "Active Memory: off globally." }; diff --git a/extensions/browser/src/browser/control-auth.ts b/extensions/browser/src/browser/control-auth.ts index 1ed0f87644e..866b9f29a4d 100644 --- a/extensions/browser/src/browser/control-auth.ts +++ b/extensions/browser/src/browser/control-auth.ts @@ -3,7 +3,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { getRuntimeConfig, replaceConfigFile } from "../config/config.js"; +import { getRuntimeConfig, mutateConfigFile } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js"; @@ -77,19 +77,17 @@ async function generateAndPersistBrowserControlToken(params: { generatedToken?: string; }> { const token = generateBrowserControlToken(); - const nextCfg: OpenClawConfig = { - ...params.cfg, - gateway: { - ...params.cfg.gateway, - auth: { - ...params.cfg.gateway?.auth, - token, - }, - }, - }; - await replaceConfigFile({ - nextConfig: nextCfg, + await mutateConfigFile({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + draft.gateway = { + ...draft.gateway, + auth: { + ...draft.gateway?.auth, + token, + }, + }; + }, }); // Re-read to stay consistent with any concurrent config writer. @@ -112,19 +110,17 @@ async function generateAndPersistBrowserControlPassword(params: { generatedToken?: string; }> { const password = generateBrowserControlToken(); - const nextCfg: OpenClawConfig = { - ...params.cfg, - gateway: { - ...params.cfg.gateway, - auth: { - ...params.cfg.gateway?.auth, - password, - }, - }, - }; - await replaceConfigFile({ - nextConfig: nextCfg, + await mutateConfigFile({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + draft.gateway = { + ...draft.gateway, + auth: { + ...draft.gateway?.auth, + password, + }, + }; + }, }); // Re-read to stay consistent with any concurrent config writer. diff --git a/extensions/browser/src/browser/profiles-service.ts b/extensions/browser/src/browser/profiles-service.ts index d9577d4302e..6374c4e7dc8 100644 --- a/extensions/browser/src/browser/profiles-service.ts +++ b/extensions/browser/src/browser/profiles-service.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; -import { getRuntimeConfig, replaceConfigFile } from "../config/config.js"; +import { getRuntimeConfig, mutateConfigFile } from "../config/config.js"; import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js"; import { formatErrorMessage } from "../infra/errors.js"; import { resolveUserPath } from "../utils.js"; @@ -165,20 +165,17 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { } } - const nextConfig: OpenClawConfig = { - ...cfg, - browser: { - ...cfg.browser, - profiles: { - ...rawProfiles, - [name]: profileConfig, - }, - }, - }; - - await replaceConfigFile({ - nextConfig, + await mutateConfigFile({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + draft.browser = { + ...draft.browser, + profiles: { + ...(draft.browser?.profiles ?? {}), + [name]: profileConfig, + }, + }; + }, }); state.resolved.profiles[name] = profileConfig; @@ -240,18 +237,15 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) { } } - const { [name]: _removed, ...remainingProfiles } = profiles; - const nextConfig: OpenClawConfig = { - ...cfg, - browser: { - ...cfg.browser, - profiles: remainingProfiles, - }, - }; - - await replaceConfigFile({ - nextConfig, + await mutateConfigFile({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + const { [name]: _removed, ...remainingProfiles } = draft.browser?.profiles ?? {}; + draft.browser = { + ...draft.browser, + profiles: remainingProfiles, + }; + }, }); delete state.resolved.profiles[name]; diff --git a/extensions/browser/src/config/config.ts b/extensions/browser/src/config/config.ts index 839417b31b0..029fcaa8c46 100644 --- a/extensions/browser/src/config/config.ts +++ b/extensions/browser/src/config/config.ts @@ -2,6 +2,7 @@ export { getRuntimeConfig, getRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, + mutateConfigFile, replaceConfigFile, type BrowserConfig, type BrowserProfileConfig, diff --git a/extensions/browser/src/sdk-config.ts b/extensions/browser/src/sdk-config.ts index d4e9d00927d..7facb35d948 100644 --- a/extensions/browser/src/sdk-config.ts +++ b/extensions/browser/src/sdk-config.ts @@ -5,7 +5,7 @@ export { getRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, } from "openclaw/plugin-sdk/runtime-config-snapshot"; -export { replaceConfigFile } from "openclaw/plugin-sdk/config-mutation"; +export { mutateConfigFile, replaceConfigFile } from "openclaw/plugin-sdk/config-mutation"; export { type BrowserConfig, type BrowserProfileConfig, diff --git a/extensions/memory-core/src/dreaming-command.ts b/extensions/memory-core/src/dreaming-command.ts index 0521bb8b483..193c356b0f8 100644 --- a/extensions/memory-core/src/dreaming-command.ts +++ b/extensions/memory-core/src/dreaming-command.ts @@ -101,15 +101,19 @@ export async function handleDreamingCommand(api: OpenClawPluginApi, ctx: PluginC return { text: "⚠️ /dreaming on|off requires operator.admin for gateway clients." }; } const enabled = firstToken === "on"; - const nextConfig = updateDreamingEnabledInConfig(currentConfig, enabled); - await api.runtime.config.replaceConfigFile({ - nextConfig, + const committed = await api.runtime.config.mutateConfigFile({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + const nextConfig = updateDreamingEnabledInConfig(draft, enabled); + Object.assign(draft, nextConfig); + }, }); return { - text: [`Dreaming ${enabled ? "enabled" : "disabled"}.`, "", formatStatus(nextConfig)].join( - "\n", - ), + text: [ + `Dreaming ${enabled ? "enabled" : "disabled"}.`, + "", + formatStatus(committed.nextConfig), + ].join("\n"), }; } diff --git a/extensions/nostr/index.ts b/extensions/nostr/index.ts index c96817e56b2..3a665750d0e 100644 --- a/extensions/nostr/index.ts +++ b/extensions/nostr/index.ts @@ -52,23 +52,21 @@ export default defineBundledChannelEntry({ }, updateConfigProfile: async (_accountId: string, profile: unknown) => { const runtime = getNostrRuntime(); - const cfg = runtime.config.current() as OpenClawConfig; - const channels = (cfg.channels ?? {}) as Record; - const nostrConfig = (channels.nostr ?? {}) as Record; + await runtime.config.mutateConfigFile({ + afterWrite: { mode: "auto" }, + mutate: (draft) => { + const channels = (draft.channels ?? {}) as Record; + const nostrConfig = (channels.nostr ?? {}) as Record; - await runtime.config.replaceConfigFile({ - nextConfig: { - ...cfg, - channels: { + draft.channels = { ...channels, nostr: { ...nostrConfig, profile, }, - }, + }; }, - afterWrite: { mode: "auto" }, }); }, getAccountInfo: (accountId: string) => { diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts index 7e33cbd5807..5b1a528bb14 100644 --- a/extensions/phone-control/index.ts +++ b/extensions/phone-control/index.ts @@ -230,13 +230,15 @@ async function disarmNow(params: { } if (removed.length > 0 || restored.length > 0) { - const next = patchConfigNodeLists(cfg, { - allowCommands: uniqSorted([...allow]), - denyCommands: uniqSorted([...deny]), - }); - await api.runtime.config.replaceConfigFile({ - nextConfig: next, + await api.runtime.config.mutateConfigFile({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + const next = patchConfigNodeLists(draft, { + allowCommands: uniqSorted([...allow]), + denyCommands: uniqSorted([...deny]), + }); + Object.assign(draft, next); + }, }); } await writeArmState(statePath, null); @@ -428,13 +430,15 @@ export default definePluginEntry({ removedFromDeny.push(cmd); } } - const next = patchConfigNodeLists(cfg, { - allowCommands: uniqSorted([...allowSet]), - denyCommands: uniqSorted([...denySet]), - }); - await api.runtime.config.replaceConfigFile({ - nextConfig: next, + await api.runtime.config.mutateConfigFile({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + const next = patchConfigNodeLists(draft, { + allowCommands: uniqSorted([...allowSet]), + denyCommands: uniqSorted([...denySet]), + }); + Object.assign(draft, next); + }, }); await writeArmState(statePath, { diff --git a/extensions/slack/src/monitor/events/channels.ts b/extensions/slack/src/monitor/events/channels.ts index ce719164202..07eb482584d 100644 --- a/extensions/slack/src/monitor/events/channels.ts +++ b/extensions/slack/src/monitor/events/channels.ts @@ -1,6 +1,6 @@ import type { SlackEventMiddlewareArgs } from "@slack/bolt"; import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes"; -import { replaceConfigFile } from "openclaw/plugin-sdk/config-mutation"; +import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { danger, warn } from "openclaw/plugin-sdk/runtime-env"; @@ -145,9 +145,16 @@ export function registerSlackChannelEvents(params: { oldChannelId, newChannelId, }); - await replaceConfigFile({ - nextConfig: currentConfig, + await mutateConfigFile({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + migrateSlackChannelConfig({ + cfg: draft, + accountId: ctx.accountId, + oldChannelId, + newChannelId, + }); + }, }); ctx.runtime.log?.(warn("[slack] Channel config migrated and saved successfully.")); } else if (migration.skippedExisting) { diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index 7c31895f360..bac992b4477 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -209,24 +209,26 @@ export default definePluginEntry({ return { text: `No voice found for ${hint}. Try: ${commandLabel} list` }; } - const nextConfig = { - ...cfg, - talk: { - ...cfg.talk, - provider: providerId, - providers: { - ...cfg.talk?.providers, - [providerId]: { - ...cfg.talk?.providers?.[providerId], - voiceId: chosen.id, - }, - }, - ...(providerId === "elevenlabs" ? { voiceId: chosen.id } : {}), - }, - }; - await api.runtime.config.replaceConfigFile({ - nextConfig, + await api.runtime.config.mutateConfigFile({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + const nextConfig = { + ...draft, + talk: { + ...draft.talk, + provider: providerId, + providers: { + ...draft.talk?.providers, + [providerId]: { + ...draft.talk?.providers?.[providerId], + voiceId: chosen.id, + }, + }, + ...(providerId === "elevenlabs" ? { voiceId: chosen.id } : {}), + }, + }; + Object.assign(draft, nextConfig); + }, }); const name = (chosen.name ?? "").trim() || "(unnamed)"; diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 06e0565e42c..28d02930909 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -14,7 +14,7 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "openclaw/plugin-sdk/config-contracts"; -import { replaceConfigFile } from "openclaw/plugin-sdk/config-mutation"; +import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation"; import { buildPluginBindingResolvedText, parsePluginBindingApprovalCustomId, @@ -2211,9 +2211,11 @@ export const registerTelegramHandlers = ({ if (migration.migrated) { runtime.log?.(warn(`[telegram] Migrating group config from ${oldChatId} to ${newChatId}`)); migrateTelegramGroupConfig({ cfg, accountId, oldChatId, newChatId }); - await replaceConfigFile({ - nextConfig: currentConfig, + await mutateConfigFile({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + migrateTelegramGroupConfig({ cfg: draft, accountId, oldChatId, newChatId }); + }, }); runtime.log?.(warn(`[telegram] Group config migrated and saved successfully`)); } else if (migration.skippedExisting) { diff --git a/src/auto-reply/reply/commands-allowlist.ts b/src/auto-reply/reply/commands-allowlist.ts index f1ac915a082..fb4a06d245d 100644 --- a/src/auto-reply/reply/commands-allowlist.ts +++ b/src/auto-reply/reply/commands-allowlist.ts @@ -3,7 +3,7 @@ import type { ChannelId } from "../../channels/plugins/types.public.js"; import { normalizeChannelId } from "../../channels/registry.js"; import { readConfigFileSnapshot, - replaceConfigFile, + transformConfigFileWithRetry, validateConfigObjectWithPlugins, } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -36,6 +36,8 @@ type ResolvedAllowlistName = { name?: string | null; }; +class AllowlistCommandMutationError extends Error {} + type AllowlistCommand = | { action: "list"; @@ -457,6 +459,8 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo }, }; } + const applyConfigEdit = plugin.allowlist.applyConfigEdit; + const editScope = parsed.scope; const snapshot = await readConfigFileSnapshot(); if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") { @@ -507,18 +511,42 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo const configChanged = editResult.changed; if (configChanged) { - const validated = validateConfigObjectWithPlugins(parsedConfig); - if (!validated.ok) { - const issue = validated.issues[0]; - return { - shouldContinue: false, - reply: { text: `⚠️ Config invalid after update (${issue.path}: ${issue.message}).` }, - }; + try { + await transformConfigFileWithRetry({ + base: "source", + afterWrite: { mode: "auto" }, + transform: async (currentConfig) => { + const latestParsedConfig = structuredClone(currentConfig) as Record; + const latestEditResult = await applyConfigEdit({ + cfg: currentConfig, + parsedConfig: latestParsedConfig, + accountId, + scope: editScope, + action: parsed.action, + entry: parsed.entry, + }); + if (!latestEditResult || latestEditResult.kind === "invalid-entry") { + throw new AllowlistCommandMutationError("Invalid allowlist entry."); + } + if (!latestEditResult.changed) { + return { nextConfig: currentConfig }; + } + const validated = validateConfigObjectWithPlugins(latestParsedConfig); + if (!validated.ok) { + const issue = validated.issues[0]; + throw new AllowlistCommandMutationError( + `Config invalid after update (${issue.path}: ${issue.message}).`, + ); + } + return { nextConfig: validated.config }; + }, + }); + } catch (error) { + if (error instanceof AllowlistCommandMutationError) { + return { shouldContinue: false, reply: { text: `⚠️ ${error.message}` } }; + } + throw error; } - await replaceConfigFile({ - nextConfig: validated.config, - afterWrite: { mode: "auto" }, - }); } if (!configChanged && !shouldTouchStore) { diff --git a/src/auto-reply/reply/commands-config.ts b/src/auto-reply/reply/commands-config.ts index 9b1e8bbee35..5f05e4ec564 100644 --- a/src/auto-reply/reply/commands-config.ts +++ b/src/auto-reply/reply/commands-config.ts @@ -8,7 +8,7 @@ import { } from "../../config/config-paths.js"; import { readConfigFileSnapshot, - replaceConfigFile, + transformConfigFileWithRetry, validateConfigObjectWithPlugins, } from "../../config/config.js"; import { @@ -31,6 +31,12 @@ import { parseConfigCommand } from "./config-commands.js"; import { resolveConfigWriteDeniedText } from "./config-write-authorization.js"; import { parseDebugCommand } from "./debug-commands.js"; +class ConfigCommandMutationError extends Error {} + +function formatConfigCommandMutationError(error: unknown): string | null { + return error instanceof ConfigCommandMutationError ? error.message : null; +} + export const handleConfigCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; @@ -142,27 +148,42 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma } if (configCommand.action === "unset") { - const removed = unsetConfigValueAtPath(parsedBase, parsedWritePath ?? []); + const path = parsedWritePath ?? []; + let removed = false; + try { + const result = await transformConfigFileWithRetry<{ removed: boolean }>({ + base: "source", + afterWrite: { mode: "auto" }, + transform: (currentConfig) => { + const next = structuredClone(currentConfig) as Record; + const removed = unsetConfigValueAtPath(next, path); + if (!removed) { + return { nextConfig: currentConfig, result: { removed: false } }; + } + const validated = validateConfigObjectWithPlugins(next); + if (!validated.ok) { + const issue = validated.issues[0]; + throw new ConfigCommandMutationError( + `Config invalid after unset (${issue.path}: ${issue.message}).`, + ); + } + return { nextConfig: validated.config, result: { removed: true } }; + }, + }); + removed = Boolean(result.result?.removed); + } catch (error) { + const message = formatConfigCommandMutationError(error); + if (message) { + return { shouldContinue: false, reply: { text: `⚠️ ${message}` } }; + } + throw error; + } if (!removed) { return { shouldContinue: false, reply: { text: `⚙️ No config value found for ${configCommand.path}.` }, }; } - const validated = validateConfigObjectWithPlugins(parsedBase); - if (!validated.ok) { - const issue = validated.issues[0]; - return { - shouldContinue: false, - reply: { - text: `⚠️ Config invalid after unset (${issue.path}: ${issue.message}).`, - }, - }; - } - await replaceConfigFile({ - nextConfig: validated.config, - afterWrite: { mode: "auto" }, - }); return { shouldContinue: false, reply: { text: `⚙️ Config updated: ${configCommand.path} removed.` }, @@ -170,21 +191,31 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma } if (configCommand.action === "set") { - setConfigValueAtPath(parsedBase, parsedWritePath ?? [], configCommand.value); - const validated = validateConfigObjectWithPlugins(parsedBase); - if (!validated.ok) { - const issue = validated.issues[0]; - return { - shouldContinue: false, - reply: { - text: `⚠️ Config invalid after set (${issue.path}: ${issue.message}).`, + const path = parsedWritePath ?? []; + try { + await transformConfigFileWithRetry({ + base: "source", + afterWrite: { mode: "auto" }, + transform: (currentConfig) => { + const next = structuredClone(currentConfig) as Record; + setConfigValueAtPath(next, path, configCommand.value); + const validated = validateConfigObjectWithPlugins(next); + if (!validated.ok) { + const issue = validated.issues[0]; + throw new ConfigCommandMutationError( + `Config invalid after set (${issue.path}: ${issue.message}).`, + ); + } + return { nextConfig: validated.config }; }, - }; + }); + } catch (error) { + const message = formatConfigCommandMutationError(error); + if (message) { + return { shouldContinue: false, reply: { text: `⚠️ ${message}` } }; + } + throw error; } - await replaceConfigFile({ - nextConfig: validated.config, - afterWrite: { mode: "auto" }, - }); const valueLabel = typeof configCommand.value === "string" ? `"${configCommand.value}"` diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 89e26e5aaea..de132848f04 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -10,7 +10,7 @@ import type { ConfigSnapshotForInstallPersist } from "../../cli/plugins-install- import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { readConfigFileSnapshot, - replaceConfigFile, + transformConfigFileWithRetry, validateConfigObjectWithPlugins, } from "../../config/config.js"; import { assertConfigWriteAllowedInCurrentMode } from "../../config/nix-mode-write-guard.js"; @@ -54,6 +54,8 @@ function renderJsonBlock(label: string, value: unknown): string { return `${label}\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``; } +class PluginsCommandMutationError extends Error {} + function buildPluginInspectJson(params: { id: string; config: OpenClawConfig; @@ -535,28 +537,36 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm }; } - const next = setPluginEnabledInConfig( - structuredClone(loaded.config), - plugin.id, - pluginsCommand.action === "enable", - ); - const validated = validateConfigObjectWithPlugins(next); - if (!validated.ok) { - const issue = validated.issues[0]; - return { - shouldContinue: false, - reply: { - text: `⚠️ Config invalid after /plugins ${pluginsCommand.action} (${issue.path}: ${issue.message}).`, + let committedConfig: OpenClawConfig; + try { + const committed = await transformConfigFileWithRetry({ + afterWrite: { mode: "auto" }, + transform: (currentConfig) => { + const next = setPluginEnabledInConfig( + structuredClone(currentConfig), + plugin.id, + pluginsCommand.action === "enable", + ); + const validated = validateConfigObjectWithPlugins(next); + if (!validated.ok) { + const issue = validated.issues[0]; + throw new PluginsCommandMutationError( + `Config invalid after /plugins ${pluginsCommand.action} (${issue.path}: ${issue.message}).`, + ); + } + return { nextConfig: validated.config }; }, - }; + }); + committedConfig = committed.nextConfig; + } catch (error) { + if (error instanceof PluginsCommandMutationError) { + return { shouldContinue: false, reply: { text: `⚠️ ${error.message}` } }; + } + throw error; } - await replaceConfigFile({ - nextConfig: validated.config, - afterWrite: { mode: "auto" }, - }); let registryWarning: string | undefined; await refreshPluginRegistryAfterConfigMutation({ - config: validated.config, + config: committedConfig, reason: "policy-changed", logger: { warn: (message) => { diff --git a/src/commands/gateway-install-token.test.ts b/src/commands/gateway-install-token.test.ts index 1b06712e2a9..b11dc14d0a4 100644 --- a/src/commands/gateway-install-token.test.ts +++ b/src/commands/gateway-install-token.test.ts @@ -210,6 +210,7 @@ describe("resolveGatewayInstallToken", () => { }, }, }, + snapshot: baseSnapshot, writeOptions: { baseSnapshot, skipRuntimeSnapshotRefresh: true, diff --git a/src/commands/gateway-install-token.ts b/src/commands/gateway-install-token.ts index 7ef22f87930..60e38974fd8 100644 --- a/src/commands/gateway-install-token.ts +++ b/src/commands/gateway-install-token.ts @@ -76,6 +76,7 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: { }, }, }, + snapshot, writeOptions: { baseSnapshot: snapshot, ...prepared.writeOptions, diff --git a/src/gateway/server-methods/agents-mutate.test.ts b/src/gateway/server-methods/agents-mutate.test.ts index 5e34331439e..05936356f49 100644 --- a/src/gateway/server-methods/agents-mutate.test.ts +++ b/src/gateway/server-methods/agents-mutate.test.ts @@ -68,6 +68,27 @@ vi.mock("../../config/config.js", async () => { writeConfigFile: mocks.writeConfigFile, replaceConfigFile: async (params: { nextConfig: unknown }) => await mocks.writeConfigFile(params.nextConfig), + mutateConfigFileWithRetry: async (params: { + mutate: (draft: Record, context: unknown) => unknown; + }) => { + const draft = structuredClone(mocks.loadConfigReturn); + const result = await params.mutate(draft, { + snapshot: { path: "/tmp/openclaw/config.json" }, + previousHash: "test-hash", + attempt: 0, + }); + await mocks.writeConfigFile(draft); + return { + path: "/tmp/openclaw/config.json", + previousHash: "test-hash", + snapshot: { path: "/tmp/openclaw/config.json" }, + nextConfig: draft, + result, + attempts: 1, + afterWrite: { mode: "auto" }, + followUp: { action: "none" }, + }; + }, }; }); diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index 289011097df..f9b6810cb23 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -26,7 +26,7 @@ import { listAgentEntries, pruneAgentConfig, } from "../../commands/agents.config.js"; -import { replaceConfigFile } from "../../config/config.js"; +import { mutateConfigFileWithRetry } from "../../config/config.js"; import { purgeAgentSessionStoreEntries, resolveSessionTranscriptsDirForAgent, @@ -546,9 +546,22 @@ export const agentsHandlers: GatewayRequestHandlers = { return; } } - await replaceConfigFile({ - nextConfig, + await mutateConfigFileWithRetry({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + if (findAgentEntryIndex(listAgentEntries(draft), agentId) >= 0) { + throw new Error(`agent "${agentId}" already exists`); + } + const latestNextConfig = applyAgentConfig(draft, { + agentId, + name: safeName, + workspace: workspaceDir, + model, + identity, + agentDir, + }); + Object.assign(draft, latestNextConfig); + }, }); respond(true, { ok: true, agentId, name: safeName, workspace: workspaceDir, model }, undefined); @@ -638,9 +651,21 @@ export const agentsHandlers: GatewayRequestHandlers = { } } - await replaceConfigFile({ - nextConfig, + await mutateConfigFileWithRetry({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + if (!isConfiguredAgent(draft, agentId)) { + throw new Error(`agent "${agentId}" not found`); + } + const latestNextConfig = applyAgentConfig(draft, { + agentId, + ...(safeName ? { name: safeName } : {}), + ...(workspaceDir ? { workspace: workspaceDir } : {}), + ...(model ? { model } : {}), + ...(identity ? { identity } : {}), + }); + Object.assign(draft, latestNextConfig); + }, }); respond(true, { ok: true, agentId }, undefined); @@ -667,30 +692,49 @@ export const agentsHandlers: GatewayRequestHandlers = { } const deleteFiles = typeof params.deleteFiles === "boolean" ? params.deleteFiles : true; - const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); - const agentDir = resolveAgentDir(cfg, agentId); - const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); - - const result = pruneAgentConfig(cfg, agentId); - await replaceConfigFile({ - nextConfig: result.config, + const committed = await mutateConfigFileWithRetry({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + if (!isConfiguredAgent(draft, agentId)) { + throw new Error(`Agent "${agentId}" not found`); + } + const workspaceDir = resolveAgentWorkspaceDir(draft, agentId); + const agentDir = resolveAgentDir(draft, agentId); + const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); + const result = pruneAgentConfig(draft, agentId); + Object.assign(draft, result.config); + return { + workspaceDir, + agentDir, + sessionsDir, + removedBindings: result.removedBindings, + }; + }, }); + const deleteResult = committed.result; + if (!deleteResult) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "agent delete did not commit")); + return; + } // Purge session store entries so orphaned sessions cannot be targeted (#65524). await purgeAgentSessionStoreEntries(cfg, agentId); if (deleteFiles) { - const workspaceSharedWith = findOverlappingWorkspaceAgentIds(cfg, agentId, workspaceDir); + const workspaceSharedWith = findOverlappingWorkspaceAgentIds( + committed.nextConfig, + agentId, + deleteResult.workspaceDir, + ); const deleteWorkspace = workspaceSharedWith.length === 0; await Promise.all([ - ...(deleteWorkspace ? [moveToTrashBestEffort(workspaceDir)] : []), - moveToTrashBestEffort(agentDir), - moveToTrashBestEffort(sessionsDir), + ...(deleteWorkspace ? [moveToTrashBestEffort(deleteResult.workspaceDir)] : []), + moveToTrashBestEffort(deleteResult.agentDir), + moveToTrashBestEffort(deleteResult.sessionsDir), ]); } - respond(true, { ok: true, agentId, removedBindings: result.removedBindings }, undefined); + respond(true, { ok: true, agentId, removedBindings: deleteResult.removedBindings }, undefined); }, "agents.files.list": async ({ params, respond, context }) => { if (!validateAgentsFilesListParams(params)) { diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index 902f228da89..9cea0f84ef4 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -13,7 +13,7 @@ import { installSkill } from "../../agents/skills-install.js"; import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js"; import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js"; import { listAgentWorkspaceDirs } from "../../agents/workspace-dirs.js"; -import { replaceConfigFile } from "../../config/config.js"; +import { mutateConfigFileWithRetry } from "../../config/config.js"; import { redactConfigObject, REDACTED_SENTINEL } from "../../config/redact-snapshot.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { fetchClawHubSkillDetail } from "../../infra/clawhub.js"; @@ -331,55 +331,53 @@ export const skillsHandlers: GatewayRequestHandlers = { apiKey?: string; env?: Record; }; - const cfg = context.getRuntimeConfig(); - const skills = cfg.skills ? { ...cfg.skills } : {}; - const entries = skills.entries ? { ...skills.entries } : {}; - const current = entries[p.skillKey] ? { ...entries[p.skillKey] } : {}; - if (typeof p.enabled === "boolean") { - current.enabled = p.enabled; - } - if (typeof p.apiKey === "string") { - const trimmed = normalizeSecretInput(p.apiKey); - if (trimmed === REDACTED_SENTINEL) { - // Keep the stored secret when a client round-trips a redacted response value. - } else if (trimmed) { - current.apiKey = trimmed; - } else { - delete current.apiKey; - } - } - if (p.env && typeof p.env === "object") { - const nextEnv = current.env ? { ...current.env } : {}; - for (const [key, value] of Object.entries(p.env)) { - const trimmedKey = key.trim(); - if (!trimmedKey) { - continue; - } - const trimmedVal = value.trim(); - if (trimmedVal === REDACTED_SENTINEL) { - continue; - } - if (!trimmedVal) { - delete nextEnv[trimmedKey]; - } else { - nextEnv[trimmedKey] = trimmedVal; - } - } - current.env = nextEnv; - } - entries[p.skillKey] = current; - skills.entries = entries; - const nextConfig: OpenClawConfig = { - ...cfg, - skills, - }; - await replaceConfigFile({ - nextConfig, + const committed = await mutateConfigFileWithRetry({ afterWrite: { mode: "auto" }, + mutate: (draft) => { + const skills = draft.skills ? { ...draft.skills } : {}; + const entries = skills.entries ? { ...skills.entries } : {}; + const current = entries[p.skillKey] ? { ...entries[p.skillKey] } : {}; + if (typeof p.enabled === "boolean") { + current.enabled = p.enabled; + } + if (typeof p.apiKey === "string") { + const trimmed = normalizeSecretInput(p.apiKey); + if (trimmed === REDACTED_SENTINEL) { + // Keep the stored secret when a client round-trips a redacted response value. + } else if (trimmed) { + current.apiKey = trimmed; + } else { + delete current.apiKey; + } + } + if (p.env && typeof p.env === "object") { + const nextEnv = current.env ? { ...current.env } : {}; + for (const [key, value] of Object.entries(p.env)) { + const trimmedKey = key.trim(); + if (!trimmedKey) { + continue; + } + const trimmedVal = value.trim(); + if (trimmedVal === REDACTED_SENTINEL) { + continue; + } + if (!trimmedVal) { + delete nextEnv[trimmedKey]; + } else { + nextEnv[trimmedKey] = trimmedVal; + } + } + current.env = nextEnv; + } + entries[p.skillKey] = current; + skills.entries = entries; + draft.skills = skills; + return current; + }, }); respond( true, - { ok: true, skillKey: p.skillKey, config: redactConfigObject(current) }, + { ok: true, skillKey: p.skillKey, config: redactConfigObject(committed.result ?? {}) }, undefined, ); },