diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts index 8220db559..5d82f0909 100644 --- a/electron/utils/agent-config.ts +++ b/electron/utils/agent-config.ts @@ -2,6 +2,7 @@ import { access, copyFile, mkdir, readdir, rm } from 'fs/promises'; import { constants } from 'fs'; import { join, normalize } from 'path'; import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config'; +import { withConfigLock } from './config-mutex'; import { expandPath, getOpenClawConfigDir } from './paths'; import * as logger from './logger'; @@ -501,135 +502,147 @@ export async function listConfiguredAgentIds(): Promise { } export async function createAgent(name: string): Promise { - const config = await readOpenClawConfig() as AgentConfigDocument; - const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config); - const normalizedName = normalizeAgentName(name); - const existingIds = new Set(entries.map((entry) => entry.id)); - const diskIds = await listExistingAgentIdsOnDisk(); - let nextId = slugifyAgentId(normalizedName); - let suffix = 2; + return withConfigLock(async () => { + const config = await readOpenClawConfig() as AgentConfigDocument; + const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config); + const normalizedName = normalizeAgentName(name); + const existingIds = new Set(entries.map((entry) => entry.id)); + const diskIds = await listExistingAgentIdsOnDisk(); + let nextId = slugifyAgentId(normalizedName); + let suffix = 2; - while (existingIds.has(nextId) || diskIds.has(nextId)) { - nextId = `${slugifyAgentId(normalizedName)}-${suffix}`; - suffix += 1; - } + while (existingIds.has(nextId) || diskIds.has(nextId)) { + nextId = `${slugifyAgentId(normalizedName)}-${suffix}`; + suffix += 1; + } - const nextEntries = syntheticMain ? [createImplicitMainEntry(config), ...entries.filter((_, index) => index > 0)] : [...entries]; - const newAgent: AgentListEntry = { - id: nextId, - name: normalizedName, - workspace: `~/.openclaw/workspace-${nextId}`, - agentDir: getDefaultAgentDirPath(nextId), - }; + const nextEntries = syntheticMain ? [createImplicitMainEntry(config), ...entries.filter((_, index) => index > 0)] : [...entries]; + const newAgent: AgentListEntry = { + id: nextId, + name: normalizedName, + workspace: `~/.openclaw/workspace-${nextId}`, + agentDir: getDefaultAgentDirPath(nextId), + }; - if (!nextEntries.some((entry) => entry.id === MAIN_AGENT_ID) && syntheticMain) { - nextEntries.unshift(createImplicitMainEntry(config)); - } - nextEntries.push(newAgent); + if (!nextEntries.some((entry) => entry.id === MAIN_AGENT_ID) && syntheticMain) { + nextEntries.unshift(createImplicitMainEntry(config)); + } + nextEntries.push(newAgent); - config.agents = { - ...agentsConfig, - list: nextEntries, - }; + config.agents = { + ...agentsConfig, + list: nextEntries, + }; - await provisionAgentFilesystem(config, newAgent); - await writeOpenClawConfig(config); - logger.info('Created agent config entry', { agentId: nextId }); - return buildSnapshotFromConfig(config); + await provisionAgentFilesystem(config, newAgent); + await writeOpenClawConfig(config); + logger.info('Created agent config entry', { agentId: nextId }); + return buildSnapshotFromConfig(config); + }); } export async function updateAgentName(agentId: string, name: string): Promise { - const config = await readOpenClawConfig() as AgentConfigDocument; - const { agentsConfig, entries } = normalizeAgentsConfig(config); - const normalizedName = normalizeAgentName(name); - const index = entries.findIndex((entry) => entry.id === agentId); - if (index === -1) { - throw new Error(`Agent "${agentId}" not found`); - } + return withConfigLock(async () => { + const config = await readOpenClawConfig() as AgentConfigDocument; + const { agentsConfig, entries } = normalizeAgentsConfig(config); + const normalizedName = normalizeAgentName(name); + const index = entries.findIndex((entry) => entry.id === agentId); + if (index === -1) { + throw new Error(`Agent "${agentId}" not found`); + } - entries[index] = { - ...entries[index], - name: normalizedName, - }; + entries[index] = { + ...entries[index], + name: normalizedName, + }; - config.agents = { - ...agentsConfig, - list: entries, - }; + config.agents = { + ...agentsConfig, + list: entries, + }; - await writeOpenClawConfig(config); - logger.info('Updated agent name', { agentId, name: normalizedName }); - return buildSnapshotFromConfig(config); + await writeOpenClawConfig(config); + logger.info('Updated agent name', { agentId, name: normalizedName }); + return buildSnapshotFromConfig(config); + }); } export async function deleteAgentConfig(agentId: string): Promise { - if (agentId === MAIN_AGENT_ID) { - throw new Error('The main agent cannot be deleted'); - } + return withConfigLock(async () => { + if (agentId === MAIN_AGENT_ID) { + throw new Error('The main agent cannot be deleted'); + } - const config = await readOpenClawConfig() as AgentConfigDocument; - const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config); - const removedEntry = entries.find((entry) => entry.id === agentId); - const nextEntries = entries.filter((entry) => entry.id !== agentId); - if (!removedEntry || nextEntries.length === entries.length) { - throw new Error(`Agent "${agentId}" not found`); - } + const config = await readOpenClawConfig() as AgentConfigDocument; + const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config); + const removedEntry = entries.find((entry) => entry.id === agentId); + const nextEntries = entries.filter((entry) => entry.id !== agentId); + if (!removedEntry || nextEntries.length === entries.length) { + throw new Error(`Agent "${agentId}" not found`); + } - config.agents = { - ...agentsConfig, - list: nextEntries, - }; - config.bindings = Array.isArray(config.bindings) - ? config.bindings.filter((binding) => !(isChannelBinding(binding) && binding.agentId === agentId)) - : undefined; - - if (defaultAgentId === agentId && nextEntries.length > 0) { - nextEntries[0] = { - ...nextEntries[0], - default: true, + config.agents = { + ...agentsConfig, + list: nextEntries, }; - } + config.bindings = Array.isArray(config.bindings) + ? config.bindings.filter((binding) => !(isChannelBinding(binding) && binding.agentId === agentId)) + : undefined; - await writeOpenClawConfig(config); - await deleteAgentChannelAccounts(agentId); - await removeAgentRuntimeDirectory(agentId); - await removeAgentWorkspaceDirectory(removedEntry); - logger.info('Deleted agent config entry', { agentId }); - return buildSnapshotFromConfig(config); + if (defaultAgentId === agentId && nextEntries.length > 0) { + nextEntries[0] = { + ...nextEntries[0], + default: true, + }; + } + + await writeOpenClawConfig(config); + await deleteAgentChannelAccounts(agentId); + await removeAgentRuntimeDirectory(agentId); + await removeAgentWorkspaceDirectory(removedEntry); + logger.info('Deleted agent config entry', { agentId }); + return buildSnapshotFromConfig(config); + }); } export async function assignChannelToAgent(agentId: string, channelType: string): Promise { - const config = await readOpenClawConfig() as AgentConfigDocument; - const { entries } = normalizeAgentsConfig(config); - if (!entries.some((entry) => entry.id === agentId)) { - throw new Error(`Agent "${agentId}" not found`); - } + return withConfigLock(async () => { + const config = await readOpenClawConfig() as AgentConfigDocument; + const { entries } = normalizeAgentsConfig(config); + if (!entries.some((entry) => entry.id === agentId)) { + throw new Error(`Agent "${agentId}" not found`); + } - const accountId = resolveAccountIdForAgent(agentId); - config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId); - await writeOpenClawConfig(config); - logger.info('Assigned channel to agent', { agentId, channelType, accountId }); - return buildSnapshotFromConfig(config); + const accountId = resolveAccountIdForAgent(agentId); + config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId); + await writeOpenClawConfig(config); + logger.info('Assigned channel to agent', { agentId, channelType, accountId }); + return buildSnapshotFromConfig(config); + }); } export async function clearChannelBinding(channelType: string, accountId?: string): Promise { - const config = await readOpenClawConfig() as AgentConfigDocument; - config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId); - await writeOpenClawConfig(config); - logger.info('Cleared channel binding', { channelType, accountId }); - return buildSnapshotFromConfig(config); + return withConfigLock(async () => { + const config = await readOpenClawConfig() as AgentConfigDocument; + config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId); + await writeOpenClawConfig(config); + logger.info('Cleared channel binding', { channelType, accountId }); + return buildSnapshotFromConfig(config); + }); } export async function clearAllBindingsForChannel(channelType: string): Promise { - const config = await readOpenClawConfig() as AgentConfigDocument; - if (!Array.isArray(config.bindings)) return; + return withConfigLock(async () => { + const config = await readOpenClawConfig() as AgentConfigDocument; + if (!Array.isArray(config.bindings)) return; - const nextBindings = config.bindings.filter((binding) => { - if (!isChannelBinding(binding)) return true; - return binding.match?.channel !== channelType; + const nextBindings = config.bindings.filter((binding) => { + if (!isChannelBinding(binding)) return true; + return binding.match?.channel !== channelType; + }); + + config.bindings = nextBindings.length > 0 ? nextBindings : undefined; + await writeOpenClawConfig(config); + logger.info('Cleared all bindings for channel', { channelType }); }); - - config.bindings = nextBindings.length > 0 ? nextBindings : undefined; - await writeOpenClawConfig(config); - logger.info('Cleared all bindings for channel', { channelType }); } diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 0682135f5..635f63189 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -11,6 +11,7 @@ import { homedir } from 'os'; import { getOpenClawResolvedDir } from './paths'; import * as logger from './logger'; import { proxyAwareFetch } from './proxy-fetch'; +import { withConfigLock } from './config-mutex'; const OPENCLAW_DIR = join(homedir(), '.openclaw'); const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); @@ -317,80 +318,82 @@ export async function saveChannelConfig( config: ChannelConfigData, accountId?: string, ): Promise { - const currentConfig = await readOpenClawConfig(); - const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID; + return withConfigLock(async () => { + const currentConfig = await readOpenClawConfig(); + const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID; - ensurePluginAllowlist(currentConfig, channelType); + ensurePluginAllowlist(currentConfig, channelType); - // Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels - if (PLUGIN_CHANNELS.includes(channelType)) { - if (!currentConfig.plugins) { - currentConfig.plugins = {}; + // Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels + if (PLUGIN_CHANNELS.includes(channelType)) { + if (!currentConfig.plugins) { + currentConfig.plugins = {}; + } + if (!currentConfig.plugins.entries) { + currentConfig.plugins.entries = {}; + } + currentConfig.plugins.entries[channelType] = { + ...currentConfig.plugins.entries[channelType], + enabled: config.enabled ?? true, + }; + await writeOpenClawConfig(currentConfig); + logger.info('Plugin channel config saved', { + channelType, + configFile: CONFIG_FILE, + path: `plugins.entries.${channelType}`, + }); + console.log(`Saved plugin channel config for ${channelType}`); + return; } - if (!currentConfig.plugins.entries) { - currentConfig.plugins.entries = {}; + + if (!currentConfig.channels) { + currentConfig.channels = {}; } - currentConfig.plugins.entries[channelType] = { - ...currentConfig.plugins.entries[channelType], - enabled: config.enabled ?? true, + if (!currentConfig.channels[channelType]) { + currentConfig.channels[channelType] = {}; + } + + const channelSection = currentConfig.channels[channelType]; + migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID); + const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId); + const transformedConfig = transformChannelConfig(channelType, config, existingAccountConfig); + + // Write credentials into accounts. + if (!channelSection.accounts || typeof channelSection.accounts !== 'object') { + channelSection.accounts = {}; + } + const accounts = channelSection.accounts as Record; + channelSection.defaultAccount = + typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() + ? channelSection.defaultAccount + : DEFAULT_ACCOUNT_ID; + accounts[resolvedAccountId] = { + ...accounts[resolvedAccountId], + ...transformedConfig, + enabled: transformedConfig.enabled ?? true, }; - await writeOpenClawConfig(currentConfig); - logger.info('Plugin channel config saved', { - channelType, - configFile: CONFIG_FILE, - path: `plugins.entries.${channelType}`, - }); - console.log(`Saved plugin channel config for ${channelType}`); - return; - } - if (!currentConfig.channels) { - currentConfig.channels = {}; - } - if (!currentConfig.channels[channelType]) { - currentConfig.channels[channelType] = {}; - } - - const channelSection = currentConfig.channels[channelType]; - migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID); - const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId); - const transformedConfig = transformChannelConfig(channelType, config, existingAccountConfig); - - // Write credentials into accounts. - if (!channelSection.accounts || typeof channelSection.accounts !== 'object') { - channelSection.accounts = {}; - } - const accounts = channelSection.accounts as Record; - channelSection.defaultAccount = - typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim() - ? channelSection.defaultAccount - : DEFAULT_ACCOUNT_ID; - accounts[resolvedAccountId] = { - ...accounts[resolvedAccountId], - ...transformedConfig, - enabled: transformedConfig.enabled ?? true, - }; - - // Most OpenClaw channel plugins read the default account's credentials - // from the top level of `channels.` (e.g. channels.feishu.appId), - // not from `accounts.default`. Mirror them there so plugins can discover - // the credentials correctly. We use the final account entry (not - // transformedConfig) because `enabled` is only added at the account level. - if (resolvedAccountId === DEFAULT_ACCOUNT_ID) { - for (const [key, value] of Object.entries(accounts[resolvedAccountId])) { - channelSection[key] = value; + // Most OpenClaw channel plugins read the default account's credentials + // from the top level of `channels.` (e.g. channels.feishu.appId), + // not from `accounts.default`. Mirror them there so plugins can discover + // the credentials correctly. We use the final account entry (not + // transformedConfig) because `enabled` is only added at the account level. + if (resolvedAccountId === DEFAULT_ACCOUNT_ID) { + for (const [key, value] of Object.entries(accounts[resolvedAccountId])) { + channelSection[key] = value; + } } - } - await writeOpenClawConfig(currentConfig); - logger.info('Channel config saved', { - channelType, - accountId: resolvedAccountId, - configFile: CONFIG_FILE, - rawKeys: Object.keys(config), - transformedKeys: Object.keys(transformedConfig), + await writeOpenClawConfig(currentConfig); + logger.info('Channel config saved', { + channelType, + accountId: resolvedAccountId, + configFile: CONFIG_FILE, + rawKeys: Object.keys(config), + transformedKeys: Object.keys(transformedConfig), + }); + console.log(`Saved channel config for ${channelType} account ${resolvedAccountId}`); }); - console.log(`Saved channel config for ${channelType} account ${resolvedAccountId}`); } export async function getChannelConfig(channelType: string, accountId?: string): Promise { @@ -463,57 +466,61 @@ export async function getChannelFormValues(channelType: string, accountId?: stri } export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise { - const currentConfig = await readOpenClawConfig(); - const channelSection = currentConfig.channels?.[channelType]; - if (!channelSection) return; + return withConfigLock(async () => { + const currentConfig = await readOpenClawConfig(); + const channelSection = currentConfig.channels?.[channelType]; + if (!channelSection) return; - migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID); - const accounts = channelSection.accounts as Record | undefined; - if (!accounts?.[accountId]) return; + migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID); + const accounts = channelSection.accounts as Record | undefined; + if (!accounts?.[accountId]) return; - delete accounts[accountId]; + delete accounts[accountId]; - if (Object.keys(accounts).length === 0) { - delete currentConfig.channels![channelType]; - } + if (Object.keys(accounts).length === 0) { + delete currentConfig.channels![channelType]; + } - await writeOpenClawConfig(currentConfig); - logger.info('Deleted channel account config', { channelType, accountId }); - console.log(`Deleted channel account config for ${channelType}/${accountId}`); + await writeOpenClawConfig(currentConfig); + logger.info('Deleted channel account config', { channelType, accountId }); + console.log(`Deleted channel account config for ${channelType}/${accountId}`); + }); } export async function deleteChannelConfig(channelType: string): Promise { - const currentConfig = await readOpenClawConfig(); + return withConfigLock(async () => { + const currentConfig = await readOpenClawConfig(); - if (currentConfig.channels?.[channelType]) { - delete currentConfig.channels[channelType]; - await writeOpenClawConfig(currentConfig); - console.log(`Deleted channel config for ${channelType}`); - } else if (PLUGIN_CHANNELS.includes(channelType)) { - if (currentConfig.plugins?.entries?.[channelType]) { - delete currentConfig.plugins.entries[channelType]; - if (Object.keys(currentConfig.plugins.entries).length === 0) { - delete currentConfig.plugins.entries; - } - if (currentConfig.plugins && Object.keys(currentConfig.plugins).length === 0) { - delete currentConfig.plugins; - } + if (currentConfig.channels?.[channelType]) { + delete currentConfig.channels[channelType]; await writeOpenClawConfig(currentConfig); - console.log(`Deleted plugin channel config for ${channelType}`); - } - } - - if (channelType === 'whatsapp') { - try { - const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); - if (await fileExists(whatsappDir)) { - await rm(whatsappDir, { recursive: true, force: true }); - console.log('Deleted WhatsApp credentials directory'); + console.log(`Deleted channel config for ${channelType}`); + } else if (PLUGIN_CHANNELS.includes(channelType)) { + if (currentConfig.plugins?.entries?.[channelType]) { + delete currentConfig.plugins.entries[channelType]; + if (Object.keys(currentConfig.plugins.entries).length === 0) { + delete currentConfig.plugins.entries; + } + if (currentConfig.plugins && Object.keys(currentConfig.plugins).length === 0) { + delete currentConfig.plugins; + } + await writeOpenClawConfig(currentConfig); + console.log(`Deleted plugin channel config for ${channelType}`); } - } catch (error) { - console.error('Failed to delete WhatsApp credentials:', error); } - } + + if (channelType === 'whatsapp') { + try { + const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); + if (await fileExists(whatsappDir)) { + await rm(whatsappDir, { recursive: true, force: true }); + console.log('Deleted WhatsApp credentials directory'); + } + } catch (error) { + console.error('Failed to delete WhatsApp credentials:', error); + } + } + }); } function channelHasAnyAccount(channelSection: ChannelConfigData): boolean { @@ -564,49 +571,53 @@ export async function listConfiguredChannels(): Promise { } export async function deleteAgentChannelAccounts(agentId: string): Promise { - const currentConfig = await readOpenClawConfig(); - if (!currentConfig.channels) return; + return withConfigLock(async () => { + const currentConfig = await readOpenClawConfig(); + if (!currentConfig.channels) return; - const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId; - let modified = false; + const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId; + let modified = false; - for (const channelType of Object.keys(currentConfig.channels)) { - const section = currentConfig.channels[channelType]; - migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID); - const accounts = section.accounts as Record | undefined; - if (!accounts?.[accountId]) continue; + for (const channelType of Object.keys(currentConfig.channels)) { + const section = currentConfig.channels[channelType]; + migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID); + const accounts = section.accounts as Record | undefined; + if (!accounts?.[accountId]) continue; - delete accounts[accountId]; - if (Object.keys(accounts).length === 0) { - delete currentConfig.channels[channelType]; + delete accounts[accountId]; + if (Object.keys(accounts).length === 0) { + delete currentConfig.channels[channelType]; + } + modified = true; } - modified = true; - } - if (modified) { - await writeOpenClawConfig(currentConfig); - logger.info('Deleted all channel accounts for agent', { agentId, accountId }); - } + if (modified) { + await writeOpenClawConfig(currentConfig); + logger.info('Deleted all channel accounts for agent', { agentId, accountId }); + } + }); } export async function setChannelEnabled(channelType: string, enabled: boolean): Promise { - const currentConfig = await readOpenClawConfig(); + return withConfigLock(async () => { + const currentConfig = await readOpenClawConfig(); - if (PLUGIN_CHANNELS.includes(channelType)) { - if (!currentConfig.plugins) currentConfig.plugins = {}; - if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {}; - if (!currentConfig.plugins.entries[channelType]) currentConfig.plugins.entries[channelType] = {}; - currentConfig.plugins.entries[channelType].enabled = enabled; + if (PLUGIN_CHANNELS.includes(channelType)) { + if (!currentConfig.plugins) currentConfig.plugins = {}; + if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {}; + if (!currentConfig.plugins.entries[channelType]) currentConfig.plugins.entries[channelType] = {}; + currentConfig.plugins.entries[channelType].enabled = enabled; + await writeOpenClawConfig(currentConfig); + console.log(`Set plugin channel ${channelType} enabled: ${enabled}`); + return; + } + + if (!currentConfig.channels) currentConfig.channels = {}; + if (!currentConfig.channels[channelType]) currentConfig.channels[channelType] = {}; + currentConfig.channels[channelType].enabled = enabled; await writeOpenClawConfig(currentConfig); - console.log(`Set plugin channel ${channelType} enabled: ${enabled}`); - return; - } - - if (!currentConfig.channels) currentConfig.channels = {}; - if (!currentConfig.channels[channelType]) currentConfig.channels[channelType] = {}; - currentConfig.channels[channelType].enabled = enabled; - await writeOpenClawConfig(currentConfig); - console.log(`Set channel ${channelType} enabled: ${enabled}`); + console.log(`Set channel ${channelType} enabled: ${enabled}`); + }); } // ── Validation ─────────────────────────────────────────────────── diff --git a/electron/utils/config-mutex.ts b/electron/utils/config-mutex.ts new file mode 100644 index 000000000..0bf7bd016 --- /dev/null +++ b/electron/utils/config-mutex.ts @@ -0,0 +1,83 @@ +/** + * Async mutex for serializing read-modify-write operations on + * ~/.openclaw/openclaw.json. + * + * Multiple code paths (channel-config, openclaw-auth, openclaw-proxy, + * skill-config, agent-config) perform async read → modify → write against + * the same JSON file. Without coordination, Node's event-loop can + * interleave two I/O sequences so that the second writer reads stale data + * and overwrites the first writer's changes (classic TOCTOU race). + * + * The mutex is **reentrant**: if a function already holding the lock calls + * another function that also calls `withConfigLock`, the inner call will + * pass through without blocking. This prevents deadlocks when e.g. + * `deleteAgentConfig` (locked) calls `deleteAgentChannelAccounts` (also locked). + * + * Usage: + * import { withConfigLock } from './config-mutex'; + * + * await withConfigLock(async () => { + * const cfg = await readConfig(); + * cfg.foo = 'bar'; + * await writeConfig(cfg); + * }); + */ + +import { AsyncLocalStorage } from 'async_hooks'; + +/** Tracks whether the current async context already holds the config lock. */ +const lockContext = new AsyncLocalStorage(); + +class ConfigMutex { + private queue: Array<() => void> = []; + private locked = false; + + async acquire(): Promise<() => void> { + if (!this.locked) { + this.locked = true; + return this.createRelease(); + } + return new Promise<() => void>((resolve) => { + this.queue.push(() => resolve(this.createRelease())); + }); + } + + private createRelease(): () => void { + let released = false; + return () => { + if (released) return; + released = true; + const next = this.queue.shift(); + if (next) { + next(); + } else { + this.locked = false; + } + }; + } +} + +/** Singleton mutex shared across all openclaw.json writers. */ +const configMutex = new ConfigMutex(); + +/** + * Execute `fn` while holding the config mutex. + * Ensures only one read-modify-write cycle on openclaw.json runs at a time. + * + * **Reentrant**: if the current async context already holds the lock + * (i.e. an outer `withConfigLock` is on the call stack), `fn` runs + * immediately without re-acquiring the lock. + */ +export async function withConfigLock(fn: () => Promise): Promise { + // If we're already inside a withConfigLock call, skip re-acquiring + if (lockContext.getStore()) { + return fn(); + } + + const release = await configMutex.acquire(); + try { + return await lockContext.run(true, fn); + } finally { + release(); + } +} diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 994c85f74..7c08c86f2 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -23,6 +23,7 @@ import { isOAuthProviderType, isOpenClawOAuthPluginProviderKey, } from './provider-keys'; +import { withConfigLock } from './config-mutex'; const AUTH_STORE_VERSION = 1; const AUTH_PROFILE_FILENAME = 'auth-profiles.json'; @@ -348,31 +349,33 @@ export async function removeProviderFromOpenClaw(provider: string): Promise { + const config = await readOpenClawJson(); + let modified = false; - // Disable plugin (for OAuth like qwen-portal-auth) - const plugins = config.plugins as Record | undefined; - const entries = (plugins?.entries ?? {}) as Record>; - const pluginName = `${provider}-auth`; - if (entries[pluginName]) { - entries[pluginName].enabled = false; - modified = true; - console.log(`Disabled OpenClaw plugin: ${pluginName}`); - } + // Disable plugin (for OAuth like qwen-portal-auth) + const plugins = config.plugins as Record | undefined; + const entries = (plugins?.entries ?? {}) as Record>; + const pluginName = `${provider}-auth`; + if (entries[pluginName]) { + entries[pluginName].enabled = false; + modified = true; + console.log(`Disabled OpenClaw plugin: ${pluginName}`); + } - // Remove from models.providers - const models = config.models as Record | undefined; - const providers = (models?.providers ?? {}) as Record; - if (providers[provider]) { - delete providers[provider]; - modified = true; - console.log(`Removed OpenClaw provider config: ${provider}`); - } + // Remove from models.providers + const models = config.models as Record | undefined; + const providers = (models?.providers ?? {}) as Record; + if (providers[provider]) { + delete providers[provider]; + modified = true; + console.log(`Removed OpenClaw provider config: ${provider}`); + } - if (modified) { - await writeOpenClawJson(config); - } + if (modified) { + await writeOpenClawJson(config); + } + }); } catch (err) { console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err); } @@ -402,60 +405,62 @@ export async function setOpenClawDefaultModel( modelOverride?: string, fallbackModels: string[] = [] ): Promise { - const config = await readOpenClawJson(); - ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); + return withConfigLock(async () => { + const config = await readOpenClawJson(); + ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); - const model = normalizeModelRef(provider, modelOverride); - if (!model) { - console.warn(`No default model mapping for provider "${provider}"`); - return; - } - - const modelId = extractModelId(provider, model); - const fallbackModelIds = extractFallbackModelIds(provider, fallbackModels); - - // Set the default model for the agents - const agents = (config.agents || {}) as Record; - const defaults = (agents.defaults || {}) as Record; - defaults.model = { - primary: model, - fallbacks: fallbackModels, - }; - agents.defaults = defaults; - config.agents = agents; - - // Configure models.providers for providers that need explicit registration. - const providerCfg = getProviderConfig(provider); - if (providerCfg) { - upsertOpenClawProviderEntry(config, provider, { - baseUrl: providerCfg.baseUrl, - api: providerCfg.api, - apiKeyEnv: providerCfg.apiKeyEnv, - headers: providerCfg.headers, - modelIds: [modelId, ...fallbackModelIds], - includeRegistryModels: true, - mergeExistingModels: true, - }); - console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`); - } else { - // Built-in provider: remove any stale models.providers entry - const models = (config.models || {}) as Record; - const providers = (models.providers || {}) as Record; - if (providers[provider]) { - delete providers[provider]; - console.log(`Removed stale models.providers.${provider} (built-in provider)`); - models.providers = providers; - config.models = models; + const model = normalizeModelRef(provider, modelOverride); + if (!model) { + console.warn(`No default model mapping for provider "${provider}"`); + return; } - } - // Ensure gateway mode is set - const gateway = (config.gateway || {}) as Record; - if (!gateway.mode) gateway.mode = 'local'; - config.gateway = gateway; + const modelId = extractModelId(provider, model); + const fallbackModelIds = extractFallbackModelIds(provider, fallbackModels); - await writeOpenClawJson(config); - console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`); + // Set the default model for the agents + const agents = (config.agents || {}) as Record; + const defaults = (agents.defaults || {}) as Record; + defaults.model = { + primary: model, + fallbacks: fallbackModels, + }; + agents.defaults = defaults; + config.agents = agents; + + // Configure models.providers for providers that need explicit registration. + const providerCfg = getProviderConfig(provider); + if (providerCfg) { + upsertOpenClawProviderEntry(config, provider, { + baseUrl: providerCfg.baseUrl, + api: providerCfg.api, + apiKeyEnv: providerCfg.apiKeyEnv, + headers: providerCfg.headers, + modelIds: [modelId, ...fallbackModelIds], + includeRegistryModels: true, + mergeExistingModels: true, + }); + console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`); + } else { + // Built-in provider: remove any stale models.providers entry + const models = (config.models || {}) as Record; + const providers = (models.providers || {}) as Record; + if (providers[provider]) { + delete providers[provider]; + console.log(`Removed stale models.providers.${provider} (built-in provider)`); + models.providers = providers; + config.models = models; + } + } + + // Ensure gateway mode is set + const gateway = (config.gateway || {}) as Record; + if (!gateway.mode) gateway.mode = 'local'; + config.gateway = gateway; + + await writeOpenClawJson(config); + console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`); + }); } interface RuntimeProviderConfigOverride { @@ -594,35 +599,37 @@ export async function syncProviderConfigToOpenClaw( modelId: string | undefined, override: RuntimeProviderConfigOverride ): Promise { - const config = await readOpenClawJson(); - ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); + return withConfigLock(async () => { + const config = await readOpenClawJson(); + ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); - if (override.baseUrl && override.api) { - upsertOpenClawProviderEntry(config, provider, { - baseUrl: override.baseUrl, - api: override.api, - apiKeyEnv: override.apiKeyEnv, - headers: override.headers, - modelIds: modelId ? [modelId] : [], - }); - } - - // Ensure extension is enabled for oauth providers to prevent gateway wiping config - if (isOpenClawOAuthPluginProviderKey(provider)) { - const plugins = (config.plugins || {}) as Record; - const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : []; - const pEntries = (plugins.entries || {}) as Record; - const pluginId = getOAuthPluginId(provider); - if (!allow.includes(pluginId)) { - allow.push(pluginId); + if (override.baseUrl && override.api) { + upsertOpenClawProviderEntry(config, provider, { + baseUrl: override.baseUrl, + api: override.api, + apiKeyEnv: override.apiKeyEnv, + headers: override.headers, + modelIds: modelId ? [modelId] : [], + }); } - pEntries[pluginId] = { enabled: true }; - plugins.allow = allow; - plugins.entries = pEntries; - config.plugins = plugins; - } - await writeOpenClawJson(config); + // Ensure extension is enabled for oauth providers to prevent gateway wiping config + if (isOpenClawOAuthPluginProviderKey(provider)) { + const plugins = (config.plugins || {}) as Record; + const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : []; + const pEntries = (plugins.entries || {}) as Record; + const pluginId = getOAuthPluginId(provider); + if (!allow.includes(pluginId)) { + allow.push(pluginId); + } + pEntries[pluginId] = { enabled: true }; + plugins.allow = allow; + plugins.entries = pEntries; + config.plugins = plugins; + } + + await writeOpenClawJson(config); + }); } /** @@ -634,61 +641,63 @@ export async function setOpenClawDefaultModelWithOverride( override: RuntimeProviderConfigOverride, fallbackModels: string[] = [] ): Promise { - const config = await readOpenClawJson(); - ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); + return withConfigLock(async () => { + const config = await readOpenClawJson(); + ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); - const model = normalizeModelRef(provider, modelOverride); - if (!model) { - console.warn(`No default model mapping for provider "${provider}"`); - return; - } - - const modelId = extractModelId(provider, model); - const fallbackModelIds = extractFallbackModelIds(provider, fallbackModels); - - const agents = (config.agents || {}) as Record; - const defaults = (agents.defaults || {}) as Record; - defaults.model = { - primary: model, - fallbacks: fallbackModels, - }; - agents.defaults = defaults; - config.agents = agents; - - if (override.baseUrl && override.api) { - upsertOpenClawProviderEntry(config, provider, { - baseUrl: override.baseUrl, - api: override.api, - apiKeyEnv: override.apiKeyEnv, - headers: override.headers, - authHeader: override.authHeader, - modelIds: [modelId, ...fallbackModelIds], - }); - } - - const gateway = (config.gateway || {}) as Record; - if (!gateway.mode) gateway.mode = 'local'; - config.gateway = gateway; - - // Ensure the extension plugin is marked as enabled in openclaw.json - if (isOpenClawOAuthPluginProviderKey(provider)) { - const plugins = (config.plugins || {}) as Record; - const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : []; - const pEntries = (plugins.entries || {}) as Record; - const pluginId = getOAuthPluginId(provider); - if (!allow.includes(pluginId)) { - allow.push(pluginId); + const model = normalizeModelRef(provider, modelOverride); + if (!model) { + console.warn(`No default model mapping for provider "${provider}"`); + return; } - pEntries[pluginId] = { enabled: true }; - plugins.allow = allow; - plugins.entries = pEntries; - config.plugins = plugins; - } - await writeOpenClawJson(config); - console.log( - `Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)` - ); + const modelId = extractModelId(provider, model); + const fallbackModelIds = extractFallbackModelIds(provider, fallbackModels); + + const agents = (config.agents || {}) as Record; + const defaults = (agents.defaults || {}) as Record; + defaults.model = { + primary: model, + fallbacks: fallbackModels, + }; + agents.defaults = defaults; + config.agents = agents; + + if (override.baseUrl && override.api) { + upsertOpenClawProviderEntry(config, provider, { + baseUrl: override.baseUrl, + api: override.api, + apiKeyEnv: override.apiKeyEnv, + headers: override.headers, + authHeader: override.authHeader, + modelIds: [modelId, ...fallbackModelIds], + }); + } + + const gateway = (config.gateway || {}) as Record; + if (!gateway.mode) gateway.mode = 'local'; + config.gateway = gateway; + + // Ensure the extension plugin is marked as enabled in openclaw.json + if (isOpenClawOAuthPluginProviderKey(provider)) { + const plugins = (config.plugins || {}) as Record; + const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : []; + const pEntries = (plugins.entries || {}) as Record; + const pluginId = getOAuthPluginId(provider); + if (!allow.includes(pluginId)) { + allow.push(pluginId); + } + pEntries[pluginId] = { enabled: true }; + plugins.allow = allow; + plugins.entries = pEntries; + config.plugins = plugins; + } + + await writeOpenClawJson(config); + console.log( + `Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)` + ); + }); } /** @@ -729,75 +738,79 @@ export async function getActiveOpenClawProviders(): Promise> { * Write the ClawX gateway token into ~/.openclaw/openclaw.json. */ export async function syncGatewayTokenToConfig(token: string): Promise { - const config = await readOpenClawJson(); + return withConfigLock(async () => { + const config = await readOpenClawJson(); - const gateway = ( - config.gateway && typeof config.gateway === 'object' - ? { ...(config.gateway as Record) } - : {} - ) as Record; + const gateway = ( + config.gateway && typeof config.gateway === 'object' + ? { ...(config.gateway as Record) } + : {} + ) as Record; - const auth = ( - gateway.auth && typeof gateway.auth === 'object' - ? { ...(gateway.auth as Record) } - : {} - ) as Record; + const auth = ( + gateway.auth && typeof gateway.auth === 'object' + ? { ...(gateway.auth as Record) } + : {} + ) as Record; - auth.mode = 'token'; - auth.token = token; - gateway.auth = auth; + auth.mode = 'token'; + auth.token = token; + gateway.auth = auth; - // Packaged ClawX loads the renderer from file://, so the gateway must allow - // that origin for the chat WebSocket handshake. - const controlUi = ( - gateway.controlUi && typeof gateway.controlUi === 'object' - ? { ...(gateway.controlUi as Record) } - : {} - ) as Record; - const allowedOrigins = Array.isArray(controlUi.allowedOrigins) - ? (controlUi.allowedOrigins as unknown[]).filter((value): value is string => typeof value === 'string') - : []; - if (!allowedOrigins.includes('file://')) { - controlUi.allowedOrigins = [...allowedOrigins, 'file://']; - } - gateway.controlUi = controlUi; + // Packaged ClawX loads the renderer from file://, so the gateway must allow + // that origin for the chat WebSocket handshake. + const controlUi = ( + gateway.controlUi && typeof gateway.controlUi === 'object' + ? { ...(gateway.controlUi as Record) } + : {} + ) as Record; + const allowedOrigins = Array.isArray(controlUi.allowedOrigins) + ? (controlUi.allowedOrigins as unknown[]).filter((value): value is string => typeof value === 'string') + : []; + if (!allowedOrigins.includes('file://')) { + controlUi.allowedOrigins = [...allowedOrigins, 'file://']; + } + gateway.controlUi = controlUi; - if (!gateway.mode) gateway.mode = 'local'; - config.gateway = gateway; + if (!gateway.mode) gateway.mode = 'local'; + config.gateway = gateway; - await writeOpenClawJson(config); - console.log('Synced gateway token to openclaw.json'); + await writeOpenClawJson(config); + console.log('Synced gateway token to openclaw.json'); + }); } /** * Ensure browser automation is enabled in ~/.openclaw/openclaw.json. */ export async function syncBrowserConfigToOpenClaw(): Promise { - const config = await readOpenClawJson(); + return withConfigLock(async () => { + const config = await readOpenClawJson(); - const browser = ( - config.browser && typeof config.browser === 'object' - ? { ...(config.browser as Record) } - : {} - ) as Record; + const browser = ( + config.browser && typeof config.browser === 'object' + ? { ...(config.browser as Record) } + : {} + ) as Record; - let changed = false; + let changed = false; - if (browser.enabled === undefined) { - browser.enabled = true; - changed = true; - } + if (browser.enabled === undefined) { + browser.enabled = true; + changed = true; + } - if (browser.defaultProfile === undefined) { - browser.defaultProfile = 'openclaw'; - changed = true; - } + if (browser.defaultProfile === undefined) { + browser.defaultProfile = 'openclaw'; + changed = true; + } - if (!changed) return; + if (!changed) return; - config.browser = browser; - await writeOpenClawJson(config); - console.log('Synced browser config to openclaw.json'); + config.browser = browser; + await writeOpenClawJson(config); + console.log('Synced browser config to openclaw.json'); + }); } /** @@ -879,175 +892,177 @@ export async function updateAgentModelProvider( * (`runOpenClawDoctorRepair`) runs `openclaw doctor --fix` as a fallback. */ export async function sanitizeOpenClawConfig(): Promise { - const config = await readOpenClawJson(); - let modified = false; + return withConfigLock(async () => { + const config = await readOpenClawJson(); + let modified = false; - // ── skills section ────────────────────────────────────────────── - // OpenClaw's Zod schema uses .strict() on the skills object, accepting - // only: allowBundled, load, install, limits, entries. - // The key "enabled" belongs inside skills.entries[key].enabled, NOT at - // the skills root level. Older versions may have placed it there. - const skills = config.skills; - if (skills && typeof skills === 'object' && !Array.isArray(skills)) { - const skillsObj = skills as Record; - // Keys that are known to be invalid at the skills root level. - const KNOWN_INVALID_SKILLS_ROOT_KEYS = ['enabled', 'disabled']; - for (const key of KNOWN_INVALID_SKILLS_ROOT_KEYS) { - if (key in skillsObj) { - console.log(`[sanitize] Removing misplaced key "skills.${key}" from openclaw.json`); - delete skillsObj[key]; - modified = true; - } - } - } - - // ── plugins section ────────────────────────────────────────────── - // Remove absolute paths in plugins that no longer exist or are bundled (preventing hardlink validation errors) - const plugins = config.plugins; - if (plugins) { - if (Array.isArray(plugins)) { - const validPlugins: unknown[] = []; - for (const p of plugins) { - if (typeof p === 'string' && p.startsWith('/')) { - if (p.includes('node_modules/openclaw/extensions') || !(await fileExists(p))) { - console.log(`[sanitize] Removing stale/bundled plugin path "${p}" from openclaw.json`); - modified = true; - } else { - validPlugins.push(p); - } - } else { - validPlugins.push(p); + // ── skills section ────────────────────────────────────────────── + // OpenClaw's Zod schema uses .strict() on the skills object, accepting + // only: allowBundled, load, install, limits, entries. + // The key "enabled" belongs inside skills.entries[key].enabled, NOT at + // the skills root level. Older versions may have placed it there. + const skills = config.skills; + if (skills && typeof skills === 'object' && !Array.isArray(skills)) { + const skillsObj = skills as Record; + // Keys that are known to be invalid at the skills root level. + const KNOWN_INVALID_SKILLS_ROOT_KEYS = ['enabled', 'disabled']; + for (const key of KNOWN_INVALID_SKILLS_ROOT_KEYS) { + if (key in skillsObj) { + console.log(`[sanitize] Removing misplaced key "skills.${key}" from openclaw.json`); + delete skillsObj[key]; + modified = true; } } - if (modified) config.plugins = validPlugins; - } else if (typeof plugins === 'object') { - const pluginsObj = plugins as Record; - if (Array.isArray(pluginsObj.load)) { - const validLoad: unknown[] = []; - for (const p of pluginsObj.load) { + } + + // ── plugins section ────────────────────────────────────────────── + // Remove absolute paths in plugins that no longer exist or are bundled (preventing hardlink validation errors) + const plugins = config.plugins; + if (plugins) { + if (Array.isArray(plugins)) { + const validPlugins: unknown[] = []; + for (const p of plugins) { if (typeof p === 'string' && p.startsWith('/')) { if (p.includes('node_modules/openclaw/extensions') || !(await fileExists(p))) { console.log(`[sanitize] Removing stale/bundled plugin path "${p}" from openclaw.json`); modified = true; } else { - validLoad.push(p); + validPlugins.push(p); } } else { - validLoad.push(p); + validPlugins.push(p); } } - if (modified) pluginsObj.load = validLoad; - } - } - } - - // ── commands section ─────────────────────────────────────────── - // Required for SIGUSR1 in-process reload authorization. - const commands = ( - config.commands && typeof config.commands === 'object' - ? { ...(config.commands as Record) } - : {} - ) as Record; - if (commands.restart !== true) { - commands.restart = true; - config.commands = commands; - modified = true; - console.log('[sanitize] Enabling commands.restart for graceful reload support'); - } - - // ── tools.web.search.kimi ───────────────────────────────────── - // OpenClaw web_search(kimi) prioritizes tools.web.search.kimi.apiKey over - // environment/auth-profiles. A stale inline key can cause persistent 401s. - // When ClawX-managed moonshot provider exists, prefer centralized key - // resolution and strip the inline key. - const providers = ((config.models as Record | undefined)?.providers as Record | undefined) || {}; - if (providers[OPENCLAW_PROVIDER_KEY_MOONSHOT]) { - const tools = (config.tools as Record | undefined) || {}; - const web = (tools.web as Record | undefined) || {}; - const search = (web.search as Record | undefined) || {}; - const kimi = (search.kimi as Record | undefined) || {}; - if ('apiKey' in kimi) { - console.log('[sanitize] Removing stale key "tools.web.search.kimi.apiKey" from openclaw.json'); - delete kimi.apiKey; - search.kimi = kimi; - web.search = search; - tools.web = web; - config.tools = tools; - modified = true; - } - } - - // ── tools.profile & sessions.visibility ─────────────────────── - // OpenClaw 3.8+ requires tools.profile = 'full' and tools.sessions.visibility = 'all' - // for ClawX to properly integrate with its updated tool system. - const toolsConfig = (config.tools as Record | undefined) || {}; - let toolsModified = false; - - if (toolsConfig.profile !== 'full') { - toolsConfig.profile = 'full'; - toolsModified = true; - } - - const sessions = (toolsConfig.sessions as Record | undefined) || {}; - if (sessions.visibility !== 'all') { - sessions.visibility = 'all'; - toolsConfig.sessions = sessions; - toolsModified = true; - } - - if (toolsModified) { - config.tools = toolsConfig; - modified = true; - console.log('[sanitize] Enforced tools.profile="full" and tools.sessions.visibility="all" for OpenClaw 3.8+'); - } - - // ── plugins.entries.feishu cleanup ────────────────────────────── - // The official feishu plugin registers its channel AS 'feishu' via - // openclaw.plugin.json. An explicit entries.feishu.enabled=false - // (set by older ClawX to disable the legacy built-in) blocks the - // official plugin's channel from starting. Delete it. - if (typeof plugins === 'object' && !Array.isArray(plugins)) { - const pluginsObj = plugins as Record; - const pEntries = pluginsObj.entries as Record> | undefined; - if (pEntries?.feishu) { - console.log('[sanitize] Removing stale plugins.entries.feishu that blocks the official feishu plugin channel'); - delete pEntries.feishu; - modified = true; - } - } - - // ── channels default-account migration ───────────────────────── - // Most OpenClaw channel plugins read the default account's credentials - // from the top level of `channels.` (e.g. channels.feishu.appId), - // but ClawX historically stored them only under `channels..accounts.default`. - // Mirror the default account credentials at the top level so plugins can - // discover them. - const channelsObj = config.channels as Record> | undefined; - if (channelsObj && typeof channelsObj === 'object') { - for (const [channelType, section] of Object.entries(channelsObj)) { - if (!section || typeof section !== 'object') continue; - const accounts = section.accounts as Record> | undefined; - const defaultAccount = accounts?.default; - if (!defaultAccount || typeof defaultAccount !== 'object') continue; - // Mirror each missing key from accounts.default to the top level - let mirrored = false; - for (const [key, value] of Object.entries(defaultAccount)) { - if (!(key in section)) { - section[key] = value; - mirrored = true; + if (modified) config.plugins = validPlugins; + } else if (typeof plugins === 'object') { + const pluginsObj = plugins as Record; + if (Array.isArray(pluginsObj.load)) { + const validLoad: unknown[] = []; + for (const p of pluginsObj.load) { + if (typeof p === 'string' && p.startsWith('/')) { + if (p.includes('node_modules/openclaw/extensions') || !(await fileExists(p))) { + console.log(`[sanitize] Removing stale/bundled plugin path "${p}" from openclaw.json`); + modified = true; + } else { + validLoad.push(p); + } + } else { + validLoad.push(p); + } + } + if (modified) pluginsObj.load = validLoad; } } - if (mirrored) { + } + + // ── commands section ─────────────────────────────────────────── + // Required for SIGUSR1 in-process reload authorization. + const commands = ( + config.commands && typeof config.commands === 'object' + ? { ...(config.commands as Record) } + : {} + ) as Record; + if (commands.restart !== true) { + commands.restart = true; + config.commands = commands; + modified = true; + console.log('[sanitize] Enabling commands.restart for graceful reload support'); + } + + // ── tools.web.search.kimi ───────────────────────────────────── + // OpenClaw web_search(kimi) prioritizes tools.web.search.kimi.apiKey over + // environment/auth-profiles. A stale inline key can cause persistent 401s. + // When ClawX-managed moonshot provider exists, prefer centralized key + // resolution and strip the inline key. + const providers = ((config.models as Record | undefined)?.providers as Record | undefined) || {}; + if (providers[OPENCLAW_PROVIDER_KEY_MOONSHOT]) { + const tools = (config.tools as Record | undefined) || {}; + const web = (tools.web as Record | undefined) || {}; + const search = (web.search as Record | undefined) || {}; + const kimi = (search.kimi as Record | undefined) || {}; + if ('apiKey' in kimi) { + console.log('[sanitize] Removing stale key "tools.web.search.kimi.apiKey" from openclaw.json'); + delete kimi.apiKey; + search.kimi = kimi; + web.search = search; + tools.web = web; + config.tools = tools; modified = true; - console.log(`[sanitize] Mirrored ${channelType} default account credentials to top-level channels.${channelType}`); } } - } - if (modified) { - await writeOpenClawJson(config); - console.log('[sanitize] openclaw.json sanitized successfully'); - } + // ── tools.profile & sessions.visibility ─────────────────────── + // OpenClaw 3.8+ requires tools.profile = 'full' and tools.sessions.visibility = 'all' + // for ClawX to properly integrate with its updated tool system. + const toolsConfig = (config.tools as Record | undefined) || {}; + let toolsModified = false; + + if (toolsConfig.profile !== 'full') { + toolsConfig.profile = 'full'; + toolsModified = true; + } + + const sessions = (toolsConfig.sessions as Record | undefined) || {}; + if (sessions.visibility !== 'all') { + sessions.visibility = 'all'; + toolsConfig.sessions = sessions; + toolsModified = true; + } + + if (toolsModified) { + config.tools = toolsConfig; + modified = true; + console.log('[sanitize] Enforced tools.profile="full" and tools.sessions.visibility="all" for OpenClaw 3.8+'); + } + + // ── plugins.entries.feishu cleanup ────────────────────────────── + // The official feishu plugin registers its channel AS 'feishu' via + // openclaw.plugin.json. An explicit entries.feishu.enabled=false + // (set by older ClawX to disable the legacy built-in) blocks the + // official plugin's channel from starting. Delete it. + if (typeof plugins === 'object' && !Array.isArray(plugins)) { + const pluginsObj = plugins as Record; + const pEntries = pluginsObj.entries as Record> | undefined; + if (pEntries?.feishu) { + console.log('[sanitize] Removing stale plugins.entries.feishu that blocks the official feishu plugin channel'); + delete pEntries.feishu; + modified = true; + } + } + + // ── channels default-account migration ───────────────────────── + // Most OpenClaw channel plugins read the default account's credentials + // from the top level of `channels.` (e.g. channels.feishu.appId), + // but ClawX historically stored them only under `channels..accounts.default`. + // Mirror the default account credentials at the top level so plugins can + // discover them. + const channelsObj = config.channels as Record> | undefined; + if (channelsObj && typeof channelsObj === 'object') { + for (const [channelType, section] of Object.entries(channelsObj)) { + if (!section || typeof section !== 'object') continue; + const accounts = section.accounts as Record> | undefined; + const defaultAccount = accounts?.default; + if (!defaultAccount || typeof defaultAccount !== 'object') continue; + // Mirror each missing key from accounts.default to the top level + let mirrored = false; + for (const [key, value] of Object.entries(defaultAccount)) { + if (!(key in section)) { + section[key] = value; + mirrored = true; + } + } + if (mirrored) { + modified = true; + console.log(`[sanitize] Mirrored ${channelType} default account credentials to top-level channels.${channelType}`); + } + } + } + + if (modified) { + await writeOpenClawJson(config); + console.log('[sanitize] openclaw.json sanitized successfully'); + } + }); } export { getProviderEnvVar } from './provider-registry'; diff --git a/electron/utils/openclaw-proxy.ts b/electron/utils/openclaw-proxy.ts index 1ae6f9b92..1922348fc 100644 --- a/electron/utils/openclaw-proxy.ts +++ b/electron/utils/openclaw-proxy.ts @@ -1,43 +1,46 @@ import { readOpenClawConfig, writeOpenClawConfig } from './channel-config'; import { resolveProxySettings, type ProxySettings } from './proxy'; import { logger } from './logger'; +import { withConfigLock } from './config-mutex'; /** * Sync ClawX global proxy settings into OpenClaw channel config where the * upstream runtime expects an explicit per-channel proxy knob. */ export async function syncProxyConfigToOpenClaw(settings: ProxySettings): Promise { - const config = await readOpenClawConfig(); - const telegramConfig = config.channels?.telegram; + return withConfigLock(async () => { + const config = await readOpenClawConfig(); + const telegramConfig = config.channels?.telegram; - if (!telegramConfig) { - return; - } + if (!telegramConfig) { + return; + } - const resolved = resolveProxySettings(settings); - const nextProxy = settings.proxyEnabled - ? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy) - : ''; - const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : ''; + const resolved = resolveProxySettings(settings); + const nextProxy = settings.proxyEnabled + ? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy) + : ''; + const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : ''; - if (!nextProxy && !currentProxy) { - return; - } + if (!nextProxy && !currentProxy) { + return; + } - if (!config.channels) { - config.channels = {}; - } + if (!config.channels) { + config.channels = {}; + } - config.channels.telegram = { - ...telegramConfig, - }; + config.channels.telegram = { + ...telegramConfig, + }; - if (nextProxy) { - config.channels.telegram.proxy = nextProxy; - } else { - delete config.channels.telegram.proxy; - } + if (nextProxy) { + config.channels.telegram.proxy = nextProxy; + } else { + delete config.channels.telegram.proxy; + } - await writeOpenClawConfig(config); - logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`); + await writeOpenClawConfig(config); + logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`); + }); } diff --git a/electron/utils/skill-config.ts b/electron/utils/skill-config.ts index e3c5742f1..886b83840 100644 --- a/electron/utils/skill-config.ts +++ b/electron/utils/skill-config.ts @@ -12,6 +12,7 @@ import { join } from 'path'; import { homedir } from 'os'; import { getOpenClawDir, getResourcesDir } from './paths'; import { logger } from './logger'; +import { withConfigLock } from './config-mutex'; const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); @@ -87,19 +88,21 @@ async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise< if (skillKeys.length === 0) { return; } - const config = await readConfig(); - if (!config.skills) { - config.skills = {}; - } - if (!config.skills.entries) { - config.skills.entries = {}; - } - for (const skillKey of skillKeys) { - const entry = config.skills.entries[skillKey] || {}; - entry.enabled = enabled; - config.skills.entries[skillKey] = entry; - } - await writeConfig(config); + return withConfigLock(async () => { + const config = await readConfig(); + if (!config.skills) { + config.skills = {}; + } + if (!config.skills.entries) { + config.skills.entries = {}; + } + for (const skillKey of skillKeys) { + const entry = config.skills.entries[skillKey] || {}; + entry.enabled = enabled; + config.skills.entries[skillKey] = entry; + } + await writeConfig(config); + }); } /** @@ -118,55 +121,57 @@ export async function updateSkillConfig( updates: { apiKey?: string; env?: Record } ): Promise<{ success: boolean; error?: string }> { try { - const config = await readConfig(); + return await withConfigLock(async () => { + const config = await readConfig(); - // Ensure skills.entries exists - if (!config.skills) { - config.skills = {}; - } - if (!config.skills.entries) { - config.skills.entries = {}; - } - - // Get or create skill entry - const entry = config.skills.entries[skillKey] || {}; - - // Update apiKey - if (updates.apiKey !== undefined) { - const trimmed = updates.apiKey.trim(); - if (trimmed) { - entry.apiKey = trimmed; - } else { - delete entry.apiKey; + // Ensure skills.entries exists + if (!config.skills) { + config.skills = {}; + } + if (!config.skills.entries) { + config.skills.entries = {}; } - } - // Update env - if (updates.env !== undefined) { - const newEnv: Record = {}; + // Get or create skill entry + const entry = config.skills.entries[skillKey] || {}; - for (const [key, value] of Object.entries(updates.env)) { - const trimmedKey = key.trim(); - if (!trimmedKey) continue; - - const trimmedVal = value.trim(); - if (trimmedVal) { - newEnv[trimmedKey] = trimmedVal; + // Update apiKey + if (updates.apiKey !== undefined) { + const trimmed = updates.apiKey.trim(); + if (trimmed) { + entry.apiKey = trimmed; + } else { + delete entry.apiKey; } } - if (Object.keys(newEnv).length > 0) { - entry.env = newEnv; - } else { - delete entry.env; + // Update env + if (updates.env !== undefined) { + const newEnv: Record = {}; + + for (const [key, value] of Object.entries(updates.env)) { + const trimmedKey = key.trim(); + if (!trimmedKey) continue; + + const trimmedVal = value.trim(); + if (trimmedVal) { + newEnv[trimmedKey] = trimmedVal; + } + } + + if (Object.keys(newEnv).length > 0) { + entry.env = newEnv; + } else { + delete entry.env; + } } - } - // Save entry back - config.skills.entries[skillKey] = entry; + // Save entry back + config.skills.entries[skillKey] = entry; - await writeConfig(config); - return { success: true }; + await writeConfig(config); + return { success: true }; + }); } catch (err) { console.error('Failed to update skill config:', err); return { success: false, error: String(err) }; diff --git a/src/components/channels/ChannelConfigModal.tsx b/src/components/channels/ChannelConfigModal.tsx index 8269adfcb..c7cba3278 100644 --- a/src/components/channels/ChannelConfigModal.tsx +++ b/src/components/channels/ChannelConfigModal.tsx @@ -19,7 +19,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; import { useChannelsStore } from '@/stores/channels'; -import { useGatewayStore } from '@/stores/gateway'; + import { hostApiFetch } from '@/lib/host-api'; import { subscribeHostEvent } from '@/lib/host-events'; import { cn } from '@/lib/utils'; @@ -98,7 +98,7 @@ export function ChannelConfigModal({ setValidationResult(null); setQrCode(null); setConnecting(false); - hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => {}); + hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { }); return; } @@ -193,7 +193,10 @@ export function ChannelConfigModal({ } await finishSave('whatsapp'); - useGatewayStore.getState().restart().catch(console.error); + // Gateway restart is already triggered by scheduleGatewayChannelRestart + // in the POST /api/channels/config route handler (debounced). Calling + // restart() here directly races with that debounced restart and the + // config write, which can cause openclaw.json overwrites. onClose(); } catch (error) { toast.error(t('toast.configFailed', { error: String(error) })); @@ -216,7 +219,7 @@ export function ChannelConfigModal({ removeQrListener(); removeSuccessListener(); removeErrorListener(); - hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => {}); + hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { }); }; }, [selectedType, finishSave, onClose, t]);