refactor: rebase runtime config writes

This commit is contained in:
Peter Steinberger
2026-05-13 10:24:29 +01:00
parent fb3aa155be
commit 2fe39ce949
19 changed files with 376 additions and 230 deletions

View File

@@ -2957,19 +2957,23 @@ export default definePluginEntry({
}; };
} }
if (action === "on" || action === "enable" || action === "enabled") { if (action === "on" || action === "enable" || action === "enabled") {
const nextConfig = updateActiveMemoryGlobalEnabledInConfig(currentConfig, true); await api.runtime.config.mutateConfigFile({
await api.runtime.config.replaceConfigFile({
nextConfig,
afterWrite: { mode: "auto" }, afterWrite: { mode: "auto" },
mutate: (draft) => {
const nextConfig = updateActiveMemoryGlobalEnabledInConfig(draft, true);
Object.assign(draft, nextConfig);
},
}); });
refreshLiveConfigFromRuntime(); refreshLiveConfigFromRuntime();
return { text: "Active Memory: on globally." }; return { text: "Active Memory: on globally." };
} }
if (action === "off" || action === "disable" || action === "disabled") { if (action === "off" || action === "disable" || action === "disabled") {
const nextConfig = updateActiveMemoryGlobalEnabledInConfig(currentConfig, false); await api.runtime.config.mutateConfigFile({
await api.runtime.config.replaceConfigFile({
nextConfig,
afterWrite: { mode: "auto" }, afterWrite: { mode: "auto" },
mutate: (draft) => {
const nextConfig = updateActiveMemoryGlobalEnabledInConfig(draft, false);
Object.assign(draft, nextConfig);
},
}); });
refreshLiveConfigFromRuntime(); refreshLiveConfigFromRuntime();
return { text: "Active Memory: off globally." }; return { text: "Active Memory: off globally." };

View File

@@ -3,7 +3,7 @@ import {
normalizeLowercaseStringOrEmpty, normalizeLowercaseStringOrEmpty,
normalizeOptionalString, normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime"; } 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 type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayAuth } from "../gateway/auth.js"; import { resolveGatewayAuth } from "../gateway/auth.js";
import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js"; import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js";
@@ -77,19 +77,17 @@ async function generateAndPersistBrowserControlToken(params: {
generatedToken?: string; generatedToken?: string;
}> { }> {
const token = generateBrowserControlToken(); const token = generateBrowserControlToken();
const nextCfg: OpenClawConfig = { await mutateConfigFile({
...params.cfg, afterWrite: { mode: "auto" },
gateway: { mutate: (draft) => {
...params.cfg.gateway, draft.gateway = {
...draft.gateway,
auth: { auth: {
...params.cfg.gateway?.auth, ...draft.gateway?.auth,
token, token,
}, },
},
}; };
await replaceConfigFile({ },
nextConfig: nextCfg,
afterWrite: { mode: "auto" },
}); });
// Re-read to stay consistent with any concurrent config writer. // Re-read to stay consistent with any concurrent config writer.
@@ -112,19 +110,17 @@ async function generateAndPersistBrowserControlPassword(params: {
generatedToken?: string; generatedToken?: string;
}> { }> {
const password = generateBrowserControlToken(); const password = generateBrowserControlToken();
const nextCfg: OpenClawConfig = { await mutateConfigFile({
...params.cfg, afterWrite: { mode: "auto" },
gateway: { mutate: (draft) => {
...params.cfg.gateway, draft.gateway = {
...draft.gateway,
auth: { auth: {
...params.cfg.gateway?.auth, ...draft.gateway?.auth,
password, password,
}, },
},
}; };
await replaceConfigFile({ },
nextConfig: nextCfg,
afterWrite: { mode: "auto" },
}); });
// Re-read to stay consistent with any concurrent config writer. // Re-read to stay consistent with any concurrent config writer.

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js"; 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 { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
@@ -165,20 +165,17 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
} }
} }
const nextConfig: OpenClawConfig = { await mutateConfigFile({
...cfg, afterWrite: { mode: "auto" },
browser: { mutate: (draft) => {
...cfg.browser, draft.browser = {
...draft.browser,
profiles: { profiles: {
...rawProfiles, ...(draft.browser?.profiles ?? {}),
[name]: profileConfig, [name]: profileConfig,
}, },
},
}; };
},
await replaceConfigFile({
nextConfig,
afterWrite: { mode: "auto" },
}); });
state.resolved.profiles[name] = profileConfig; state.resolved.profiles[name] = profileConfig;
@@ -240,18 +237,15 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
} }
} }
const { [name]: _removed, ...remainingProfiles } = profiles; await mutateConfigFile({
const nextConfig: OpenClawConfig = {
...cfg,
browser: {
...cfg.browser,
profiles: remainingProfiles,
},
};
await replaceConfigFile({
nextConfig,
afterWrite: { mode: "auto" }, afterWrite: { mode: "auto" },
mutate: (draft) => {
const { [name]: _removed, ...remainingProfiles } = draft.browser?.profiles ?? {};
draft.browser = {
...draft.browser,
profiles: remainingProfiles,
};
},
}); });
delete state.resolved.profiles[name]; delete state.resolved.profiles[name];

View File

@@ -2,6 +2,7 @@ export {
getRuntimeConfig, getRuntimeConfig,
getRuntimeConfigSnapshot, getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot, getRuntimeConfigSourceSnapshot,
mutateConfigFile,
replaceConfigFile, replaceConfigFile,
type BrowserConfig, type BrowserConfig,
type BrowserProfileConfig, type BrowserProfileConfig,

View File

@@ -5,7 +5,7 @@ export {
getRuntimeConfigSnapshot, getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot, getRuntimeConfigSourceSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot"; } 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 { export {
type BrowserConfig, type BrowserConfig,
type BrowserProfileConfig, type BrowserProfileConfig,

View File

@@ -101,15 +101,19 @@ export async function handleDreamingCommand(api: OpenClawPluginApi, ctx: PluginC
return { text: "⚠️ /dreaming on|off requires operator.admin for gateway clients." }; return { text: "⚠️ /dreaming on|off requires operator.admin for gateway clients." };
} }
const enabled = firstToken === "on"; const enabled = firstToken === "on";
const nextConfig = updateDreamingEnabledInConfig(currentConfig, enabled); const committed = await api.runtime.config.mutateConfigFile({
await api.runtime.config.replaceConfigFile({
nextConfig,
afterWrite: { mode: "auto" }, afterWrite: { mode: "auto" },
mutate: (draft) => {
const nextConfig = updateDreamingEnabledInConfig(draft, enabled);
Object.assign(draft, nextConfig);
},
}); });
return { return {
text: [`Dreaming ${enabled ? "enabled" : "disabled"}.`, "", formatStatus(nextConfig)].join( text: [
"\n", `Dreaming ${enabled ? "enabled" : "disabled"}.`,
), "",
formatStatus(committed.nextConfig),
].join("\n"),
}; };
} }

View File

@@ -52,23 +52,21 @@ export default defineBundledChannelEntry({
}, },
updateConfigProfile: async (_accountId: string, profile: unknown) => { updateConfigProfile: async (_accountId: string, profile: unknown) => {
const runtime = getNostrRuntime(); const runtime = getNostrRuntime();
const cfg = runtime.config.current() as OpenClawConfig;
const channels = (cfg.channels ?? {}) as Record<string, unknown>; await runtime.config.mutateConfigFile({
afterWrite: { mode: "auto" },
mutate: (draft) => {
const channels = (draft.channels ?? {}) as Record<string, unknown>;
const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>; const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
await runtime.config.replaceConfigFile({ draft.channels = {
nextConfig: {
...cfg,
channels: {
...channels, ...channels,
nostr: { nostr: {
...nostrConfig, ...nostrConfig,
profile, profile,
}, },
};
}, },
},
afterWrite: { mode: "auto" },
}); });
}, },
getAccountInfo: (accountId: string) => { getAccountInfo: (accountId: string) => {

View File

@@ -230,13 +230,15 @@ async function disarmNow(params: {
} }
if (removed.length > 0 || restored.length > 0) { if (removed.length > 0 || restored.length > 0) {
const next = patchConfigNodeLists(cfg, { await api.runtime.config.mutateConfigFile({
afterWrite: { mode: "auto" },
mutate: (draft) => {
const next = patchConfigNodeLists(draft, {
allowCommands: uniqSorted([...allow]), allowCommands: uniqSorted([...allow]),
denyCommands: uniqSorted([...deny]), denyCommands: uniqSorted([...deny]),
}); });
await api.runtime.config.replaceConfigFile({ Object.assign(draft, next);
nextConfig: next, },
afterWrite: { mode: "auto" },
}); });
} }
await writeArmState(statePath, null); await writeArmState(statePath, null);
@@ -428,13 +430,15 @@ export default definePluginEntry({
removedFromDeny.push(cmd); removedFromDeny.push(cmd);
} }
} }
const next = patchConfigNodeLists(cfg, { await api.runtime.config.mutateConfigFile({
afterWrite: { mode: "auto" },
mutate: (draft) => {
const next = patchConfigNodeLists(draft, {
allowCommands: uniqSorted([...allowSet]), allowCommands: uniqSorted([...allowSet]),
denyCommands: uniqSorted([...denySet]), denyCommands: uniqSorted([...denySet]),
}); });
await api.runtime.config.replaceConfigFile({ Object.assign(draft, next);
nextConfig: next, },
afterWrite: { mode: "auto" },
}); });
await writeArmState(statePath, { await writeArmState(statePath, {

View File

@@ -1,6 +1,6 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt"; import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes"; 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 { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { danger, warn } from "openclaw/plugin-sdk/runtime-env"; import { danger, warn } from "openclaw/plugin-sdk/runtime-env";
@@ -145,9 +145,16 @@ export function registerSlackChannelEvents(params: {
oldChannelId, oldChannelId,
newChannelId, newChannelId,
}); });
await replaceConfigFile({ await mutateConfigFile({
nextConfig: currentConfig,
afterWrite: { mode: "auto" }, afterWrite: { mode: "auto" },
mutate: (draft) => {
migrateSlackChannelConfig({
cfg: draft,
accountId: ctx.accountId,
oldChannelId,
newChannelId,
});
},
}); });
ctx.runtime.log?.(warn("[slack] Channel config migrated and saved successfully.")); ctx.runtime.log?.(warn("[slack] Channel config migrated and saved successfully."));
} else if (migration.skippedExisting) { } else if (migration.skippedExisting) {

View File

@@ -209,24 +209,26 @@ export default definePluginEntry({
return { text: `No voice found for ${hint}. Try: ${commandLabel} list` }; return { text: `No voice found for ${hint}. Try: ${commandLabel} list` };
} }
await api.runtime.config.mutateConfigFile({
afterWrite: { mode: "auto" },
mutate: (draft) => {
const nextConfig = { const nextConfig = {
...cfg, ...draft,
talk: { talk: {
...cfg.talk, ...draft.talk,
provider: providerId, provider: providerId,
providers: { providers: {
...cfg.talk?.providers, ...draft.talk?.providers,
[providerId]: { [providerId]: {
...cfg.talk?.providers?.[providerId], ...draft.talk?.providers?.[providerId],
voiceId: chosen.id, voiceId: chosen.id,
}, },
}, },
...(providerId === "elevenlabs" ? { voiceId: chosen.id } : {}), ...(providerId === "elevenlabs" ? { voiceId: chosen.id } : {}),
}, },
}; };
await api.runtime.config.replaceConfigFile({ Object.assign(draft, nextConfig);
nextConfig, },
afterWrite: { mode: "auto" },
}); });
const name = (chosen.name ?? "").trim() || "(unnamed)"; const name = (chosen.name ?? "").trim() || "(unnamed)";

View File

@@ -14,7 +14,7 @@ import type {
TelegramGroupConfig, TelegramGroupConfig,
TelegramTopicConfig, TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-contracts"; } from "openclaw/plugin-sdk/config-contracts";
import { replaceConfigFile } from "openclaw/plugin-sdk/config-mutation"; import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation";
import { import {
buildPluginBindingResolvedText, buildPluginBindingResolvedText,
parsePluginBindingApprovalCustomId, parsePluginBindingApprovalCustomId,
@@ -2211,9 +2211,11 @@ export const registerTelegramHandlers = ({
if (migration.migrated) { if (migration.migrated) {
runtime.log?.(warn(`[telegram] Migrating group config from ${oldChatId} to ${newChatId}`)); runtime.log?.(warn(`[telegram] Migrating group config from ${oldChatId} to ${newChatId}`));
migrateTelegramGroupConfig({ cfg, accountId, oldChatId, newChatId }); migrateTelegramGroupConfig({ cfg, accountId, oldChatId, newChatId });
await replaceConfigFile({ await mutateConfigFile({
nextConfig: currentConfig,
afterWrite: { mode: "auto" }, afterWrite: { mode: "auto" },
mutate: (draft) => {
migrateTelegramGroupConfig({ cfg: draft, accountId, oldChatId, newChatId });
},
}); });
runtime.log?.(warn(`[telegram] Group config migrated and saved successfully`)); runtime.log?.(warn(`[telegram] Group config migrated and saved successfully`));
} else if (migration.skippedExisting) { } else if (migration.skippedExisting) {

View File

@@ -3,7 +3,7 @@ import type { ChannelId } from "../../channels/plugins/types.public.js";
import { normalizeChannelId } from "../../channels/registry.js"; import { normalizeChannelId } from "../../channels/registry.js";
import { import {
readConfigFileSnapshot, readConfigFileSnapshot,
replaceConfigFile, transformConfigFileWithRetry,
validateConfigObjectWithPlugins, validateConfigObjectWithPlugins,
} from "../../config/config.js"; } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -36,6 +36,8 @@ type ResolvedAllowlistName = {
name?: string | null; name?: string | null;
}; };
class AllowlistCommandMutationError extends Error {}
type AllowlistCommand = type AllowlistCommand =
| { | {
action: "list"; 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(); const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") { if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") {
@@ -507,18 +511,42 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
const configChanged = editResult.changed; const configChanged = editResult.changed;
if (configChanged) { if (configChanged) {
const validated = validateConfigObjectWithPlugins(parsedConfig); try {
await transformConfigFileWithRetry({
base: "source",
afterWrite: { mode: "auto" },
transform: async (currentConfig) => {
const latestParsedConfig = structuredClone(currentConfig) as Record<string, unknown>;
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) { if (!validated.ok) {
const issue = validated.issues[0]; const issue = validated.issues[0];
return { throw new AllowlistCommandMutationError(
shouldContinue: false, `Config invalid after update (${issue.path}: ${issue.message}).`,
reply: { text: `⚠️ Config invalid after update (${issue.path}: ${issue.message}).` }, );
};
} }
await replaceConfigFile({ return { nextConfig: validated.config };
nextConfig: validated.config, },
afterWrite: { mode: "auto" },
}); });
} catch (error) {
if (error instanceof AllowlistCommandMutationError) {
return { shouldContinue: false, reply: { text: `⚠️ ${error.message}` } };
}
throw error;
}
} }
if (!configChanged && !shouldTouchStore) { if (!configChanged && !shouldTouchStore) {

View File

@@ -8,7 +8,7 @@ import {
} from "../../config/config-paths.js"; } from "../../config/config-paths.js";
import { import {
readConfigFileSnapshot, readConfigFileSnapshot,
replaceConfigFile, transformConfigFileWithRetry,
validateConfigObjectWithPlugins, validateConfigObjectWithPlugins,
} from "../../config/config.js"; } from "../../config/config.js";
import { import {
@@ -31,6 +31,12 @@ import { parseConfigCommand } from "./config-commands.js";
import { resolveConfigWriteDeniedText } from "./config-write-authorization.js"; import { resolveConfigWriteDeniedText } from "./config-write-authorization.js";
import { parseDebugCommand } from "./debug-commands.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) => { export const handleConfigCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) { if (!allowTextCommands) {
return null; return null;
@@ -142,27 +148,42 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
} }
if (configCommand.action === "unset") { 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<string, unknown>;
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) { if (!removed) {
return { return {
shouldContinue: false, shouldContinue: false,
reply: { text: `⚙️ No config value found for ${configCommand.path}.` }, 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 { return {
shouldContinue: false, shouldContinue: false,
reply: { text: `⚙️ Config updated: ${configCommand.path} removed.` }, reply: { text: `⚙️ Config updated: ${configCommand.path} removed.` },
@@ -170,21 +191,31 @@ export const handleConfigCommand: CommandHandler = async (params, allowTextComma
} }
if (configCommand.action === "set") { if (configCommand.action === "set") {
setConfigValueAtPath(parsedBase, parsedWritePath ?? [], configCommand.value); const path = parsedWritePath ?? [];
const validated = validateConfigObjectWithPlugins(parsedBase); try {
await transformConfigFileWithRetry({
base: "source",
afterWrite: { mode: "auto" },
transform: (currentConfig) => {
const next = structuredClone(currentConfig) as Record<string, unknown>;
setConfigValueAtPath(next, path, configCommand.value);
const validated = validateConfigObjectWithPlugins(next);
if (!validated.ok) { if (!validated.ok) {
const issue = validated.issues[0]; const issue = validated.issues[0];
return { throw new ConfigCommandMutationError(
shouldContinue: false, `Config invalid after set (${issue.path}: ${issue.message}).`,
reply: { );
text: `⚠️ Config invalid after set (${issue.path}: ${issue.message}).`,
},
};
} }
await replaceConfigFile({ return { nextConfig: validated.config };
nextConfig: validated.config, },
afterWrite: { mode: "auto" },
}); });
} catch (error) {
const message = formatConfigCommandMutationError(error);
if (message) {
return { shouldContinue: false, reply: { text: `⚠️ ${message}` } };
}
throw error;
}
const valueLabel = const valueLabel =
typeof configCommand.value === "string" typeof configCommand.value === "string"
? `"${configCommand.value}"` ? `"${configCommand.value}"`

View File

@@ -10,7 +10,7 @@ import type { ConfigSnapshotForInstallPersist } from "../../cli/plugins-install-
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
import { import {
readConfigFileSnapshot, readConfigFileSnapshot,
replaceConfigFile, transformConfigFileWithRetry,
validateConfigObjectWithPlugins, validateConfigObjectWithPlugins,
} from "../../config/config.js"; } from "../../config/config.js";
import { assertConfigWriteAllowedInCurrentMode } from "../../config/nix-mode-write-guard.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\`\`\``; return `${label}\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
} }
class PluginsCommandMutationError extends Error {}
function buildPluginInspectJson(params: { function buildPluginInspectJson(params: {
id: string; id: string;
config: OpenClawConfig; config: OpenClawConfig;
@@ -535,28 +537,36 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
}; };
} }
let committedConfig: OpenClawConfig;
try {
const committed = await transformConfigFileWithRetry({
afterWrite: { mode: "auto" },
transform: (currentConfig) => {
const next = setPluginEnabledInConfig( const next = setPluginEnabledInConfig(
structuredClone(loaded.config), structuredClone(currentConfig),
plugin.id, plugin.id,
pluginsCommand.action === "enable", pluginsCommand.action === "enable",
); );
const validated = validateConfigObjectWithPlugins(next); const validated = validateConfigObjectWithPlugins(next);
if (!validated.ok) { if (!validated.ok) {
const issue = validated.issues[0]; const issue = validated.issues[0];
return { throw new PluginsCommandMutationError(
shouldContinue: false, `Config invalid after /plugins ${pluginsCommand.action} (${issue.path}: ${issue.message}).`,
reply: { );
text: `⚠️ Config invalid after /plugins ${pluginsCommand.action} (${issue.path}: ${issue.message}).`,
},
};
} }
await replaceConfigFile({ return { nextConfig: validated.config };
nextConfig: validated.config, },
afterWrite: { mode: "auto" },
}); });
committedConfig = committed.nextConfig;
} catch (error) {
if (error instanceof PluginsCommandMutationError) {
return { shouldContinue: false, reply: { text: `⚠️ ${error.message}` } };
}
throw error;
}
let registryWarning: string | undefined; let registryWarning: string | undefined;
await refreshPluginRegistryAfterConfigMutation({ await refreshPluginRegistryAfterConfigMutation({
config: validated.config, config: committedConfig,
reason: "policy-changed", reason: "policy-changed",
logger: { logger: {
warn: (message) => { warn: (message) => {

View File

@@ -210,6 +210,7 @@ describe("resolveGatewayInstallToken", () => {
}, },
}, },
}, },
snapshot: baseSnapshot,
writeOptions: { writeOptions: {
baseSnapshot, baseSnapshot,
skipRuntimeSnapshotRefresh: true, skipRuntimeSnapshotRefresh: true,

View File

@@ -76,6 +76,7 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: {
}, },
}, },
}, },
snapshot,
writeOptions: { writeOptions: {
baseSnapshot: snapshot, baseSnapshot: snapshot,
...prepared.writeOptions, ...prepared.writeOptions,

View File

@@ -68,6 +68,27 @@ vi.mock("../../config/config.js", async () => {
writeConfigFile: mocks.writeConfigFile, writeConfigFile: mocks.writeConfigFile,
replaceConfigFile: async (params: { nextConfig: unknown }) => replaceConfigFile: async (params: { nextConfig: unknown }) =>
await mocks.writeConfigFile(params.nextConfig), await mocks.writeConfigFile(params.nextConfig),
mutateConfigFileWithRetry: async (params: {
mutate: (draft: Record<string, unknown>, 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" },
};
},
}; };
}); });

View File

@@ -26,7 +26,7 @@ import {
listAgentEntries, listAgentEntries,
pruneAgentConfig, pruneAgentConfig,
} from "../../commands/agents.config.js"; } from "../../commands/agents.config.js";
import { replaceConfigFile } from "../../config/config.js"; import { mutateConfigFileWithRetry } from "../../config/config.js";
import { import {
purgeAgentSessionStoreEntries, purgeAgentSessionStoreEntries,
resolveSessionTranscriptsDirForAgent, resolveSessionTranscriptsDirForAgent,
@@ -546,9 +546,22 @@ export const agentsHandlers: GatewayRequestHandlers = {
return; return;
} }
} }
await replaceConfigFile({ await mutateConfigFileWithRetry({
nextConfig,
afterWrite: { mode: "auto" }, 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); respond(true, { ok: true, agentId, name: safeName, workspace: workspaceDir, model }, undefined);
@@ -638,9 +651,21 @@ export const agentsHandlers: GatewayRequestHandlers = {
} }
} }
await replaceConfigFile({ await mutateConfigFileWithRetry({
nextConfig,
afterWrite: { mode: "auto" }, 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); respond(true, { ok: true, agentId }, undefined);
@@ -667,30 +692,49 @@ export const agentsHandlers: GatewayRequestHandlers = {
} }
const deleteFiles = typeof params.deleteFiles === "boolean" ? params.deleteFiles : true; const deleteFiles = typeof params.deleteFiles === "boolean" ? params.deleteFiles : true;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const committed = await mutateConfigFileWithRetry({
const agentDir = resolveAgentDir(cfg, agentId);
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
const result = pruneAgentConfig(cfg, agentId);
await replaceConfigFile({
nextConfig: result.config,
afterWrite: { mode: "auto" }, 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). // Purge session store entries so orphaned sessions cannot be targeted (#65524).
await purgeAgentSessionStoreEntries(cfg, agentId); await purgeAgentSessionStoreEntries(cfg, agentId);
if (deleteFiles) { if (deleteFiles) {
const workspaceSharedWith = findOverlappingWorkspaceAgentIds(cfg, agentId, workspaceDir); const workspaceSharedWith = findOverlappingWorkspaceAgentIds(
committed.nextConfig,
agentId,
deleteResult.workspaceDir,
);
const deleteWorkspace = workspaceSharedWith.length === 0; const deleteWorkspace = workspaceSharedWith.length === 0;
await Promise.all([ await Promise.all([
...(deleteWorkspace ? [moveToTrashBestEffort(workspaceDir)] : []), ...(deleteWorkspace ? [moveToTrashBestEffort(deleteResult.workspaceDir)] : []),
moveToTrashBestEffort(agentDir), moveToTrashBestEffort(deleteResult.agentDir),
moveToTrashBestEffort(sessionsDir), 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 }) => { "agents.files.list": async ({ params, respond, context }) => {
if (!validateAgentsFilesListParams(params)) { if (!validateAgentsFilesListParams(params)) {

View File

@@ -13,7 +13,7 @@ import { installSkill } from "../../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js"; import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js"; import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js";
import { listAgentWorkspaceDirs } from "../../agents/workspace-dirs.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 { redactConfigObject, REDACTED_SENTINEL } from "../../config/redact-snapshot.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { fetchClawHubSkillDetail } from "../../infra/clawhub.js"; import { fetchClawHubSkillDetail } from "../../infra/clawhub.js";
@@ -331,8 +331,10 @@ export const skillsHandlers: GatewayRequestHandlers = {
apiKey?: string; apiKey?: string;
env?: Record<string, string>; env?: Record<string, string>;
}; };
const cfg = context.getRuntimeConfig(); const committed = await mutateConfigFileWithRetry({
const skills = cfg.skills ? { ...cfg.skills } : {}; afterWrite: { mode: "auto" },
mutate: (draft) => {
const skills = draft.skills ? { ...draft.skills } : {};
const entries = skills.entries ? { ...skills.entries } : {}; const entries = skills.entries ? { ...skills.entries } : {};
const current = entries[p.skillKey] ? { ...entries[p.skillKey] } : {}; const current = entries[p.skillKey] ? { ...entries[p.skillKey] } : {};
if (typeof p.enabled === "boolean") { if (typeof p.enabled === "boolean") {
@@ -369,17 +371,13 @@ export const skillsHandlers: GatewayRequestHandlers = {
} }
entries[p.skillKey] = current; entries[p.skillKey] = current;
skills.entries = entries; skills.entries = entries;
const nextConfig: OpenClawConfig = { draft.skills = skills;
...cfg, return current;
skills, },
};
await replaceConfigFile({
nextConfig,
afterWrite: { mode: "auto" },
}); });
respond( respond(
true, true,
{ ok: true, skillKey: p.skillKey, config: redactConfigObject(current) }, { ok: true, skillKey: p.skillKey, config: redactConfigObject(committed.result ?? {}) },
undefined, undefined,
); );
}, },