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") {
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." };

View File

@@ -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.

View File

@@ -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];

View File

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

View File

@@ -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,

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." };
}
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"),
};
}

View File

@@ -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<string, unknown>;
const nostrConfig = (channels.nostr ?? {}) 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>;
await runtime.config.replaceConfigFile({
nextConfig: {
...cfg,
channels: {
draft.channels = {
...channels,
nostr: {
...nostrConfig,
profile,
},
},
};
},
afterWrite: { mode: "auto" },
});
},
getAccountInfo: (accountId: string) => {

View File

@@ -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, {

View File

@@ -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) {

View File

@@ -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)";

View File

@@ -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) {

View File

@@ -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<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) {
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) {

View File

@@ -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<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) {
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<string, unknown>;
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}"`

View File

@@ -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) => {

View File

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

View File

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

View File

@@ -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<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,
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)) {

View File

@@ -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<string, string>;
};
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,
);
},