mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
refactor: rebase runtime config writes
This commit is contained in:
@@ -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." };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -2,6 +2,7 @@ export {
|
||||
getRuntimeConfig,
|
||||
getRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
mutateConfigFile,
|
||||
replaceConfigFile,
|
||||
type BrowserConfig,
|
||||
type BrowserProfileConfig,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}"`
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -210,6 +210,7 @@ describe("resolveGatewayInstallToken", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
snapshot: baseSnapshot,
|
||||
writeOptions: {
|
||||
baseSnapshot,
|
||||
skipRuntimeSnapshotRefresh: true,
|
||||
|
||||
@@ -76,6 +76,7 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: {
|
||||
},
|
||||
},
|
||||
},
|
||||
snapshot,
|
||||
writeOptions: {
|
||||
baseSnapshot: snapshot,
|
||||
...prepared.writeOptions,
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user