fix: resolve channel-config overwrite bug by implementing reentrant config mutex (#462)

This commit is contained in:
paisley
2026-03-13 12:13:57 +08:00
committed by GitHub
Unverified
parent 740116ae9d
commit abc0c6e7d5
7 changed files with 806 additions and 673 deletions

View File

@@ -2,6 +2,7 @@ import { access, copyFile, mkdir, readdir, rm } from 'fs/promises';
import { constants } from 'fs'; import { constants } from 'fs';
import { join, normalize } from 'path'; import { join, normalize } from 'path';
import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config'; import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config';
import { withConfigLock } from './config-mutex';
import { expandPath, getOpenClawConfigDir } from './paths'; import { expandPath, getOpenClawConfigDir } from './paths';
import * as logger from './logger'; import * as logger from './logger';
@@ -501,135 +502,147 @@ export async function listConfiguredAgentIds(): Promise<string[]> {
} }
export async function createAgent(name: string): Promise<AgentsSnapshot> { export async function createAgent(name: string): Promise<AgentsSnapshot> {
const config = await readOpenClawConfig() as AgentConfigDocument; return withConfigLock(async () => {
const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config); const config = await readOpenClawConfig() as AgentConfigDocument;
const normalizedName = normalizeAgentName(name); const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config);
const existingIds = new Set(entries.map((entry) => entry.id)); const normalizedName = normalizeAgentName(name);
const diskIds = await listExistingAgentIdsOnDisk(); const existingIds = new Set(entries.map((entry) => entry.id));
let nextId = slugifyAgentId(normalizedName); const diskIds = await listExistingAgentIdsOnDisk();
let suffix = 2; let nextId = slugifyAgentId(normalizedName);
let suffix = 2;
while (existingIds.has(nextId) || diskIds.has(nextId)) { while (existingIds.has(nextId) || diskIds.has(nextId)) {
nextId = `${slugifyAgentId(normalizedName)}-${suffix}`; nextId = `${slugifyAgentId(normalizedName)}-${suffix}`;
suffix += 1; suffix += 1;
} }
const nextEntries = syntheticMain ? [createImplicitMainEntry(config), ...entries.filter((_, index) => index > 0)] : [...entries]; const nextEntries = syntheticMain ? [createImplicitMainEntry(config), ...entries.filter((_, index) => index > 0)] : [...entries];
const newAgent: AgentListEntry = { const newAgent: AgentListEntry = {
id: nextId, id: nextId,
name: normalizedName, name: normalizedName,
workspace: `~/.openclaw/workspace-${nextId}`, workspace: `~/.openclaw/workspace-${nextId}`,
agentDir: getDefaultAgentDirPath(nextId), agentDir: getDefaultAgentDirPath(nextId),
}; };
if (!nextEntries.some((entry) => entry.id === MAIN_AGENT_ID) && syntheticMain) { if (!nextEntries.some((entry) => entry.id === MAIN_AGENT_ID) && syntheticMain) {
nextEntries.unshift(createImplicitMainEntry(config)); nextEntries.unshift(createImplicitMainEntry(config));
} }
nextEntries.push(newAgent); nextEntries.push(newAgent);
config.agents = { config.agents = {
...agentsConfig, ...agentsConfig,
list: nextEntries, list: nextEntries,
}; };
await provisionAgentFilesystem(config, newAgent); await provisionAgentFilesystem(config, newAgent);
await writeOpenClawConfig(config); await writeOpenClawConfig(config);
logger.info('Created agent config entry', { agentId: nextId }); logger.info('Created agent config entry', { agentId: nextId });
return buildSnapshotFromConfig(config); return buildSnapshotFromConfig(config);
});
} }
export async function updateAgentName(agentId: string, name: string): Promise<AgentsSnapshot> { export async function updateAgentName(agentId: string, name: string): Promise<AgentsSnapshot> {
const config = await readOpenClawConfig() as AgentConfigDocument; return withConfigLock(async () => {
const { agentsConfig, entries } = normalizeAgentsConfig(config); const config = await readOpenClawConfig() as AgentConfigDocument;
const normalizedName = normalizeAgentName(name); const { agentsConfig, entries } = normalizeAgentsConfig(config);
const index = entries.findIndex((entry) => entry.id === agentId); const normalizedName = normalizeAgentName(name);
if (index === -1) { const index = entries.findIndex((entry) => entry.id === agentId);
throw new Error(`Agent "${agentId}" not found`); if (index === -1) {
} throw new Error(`Agent "${agentId}" not found`);
}
entries[index] = { entries[index] = {
...entries[index], ...entries[index],
name: normalizedName, name: normalizedName,
}; };
config.agents = { config.agents = {
...agentsConfig, ...agentsConfig,
list: entries, list: entries,
}; };
await writeOpenClawConfig(config); await writeOpenClawConfig(config);
logger.info('Updated agent name', { agentId, name: normalizedName }); logger.info('Updated agent name', { agentId, name: normalizedName });
return buildSnapshotFromConfig(config); return buildSnapshotFromConfig(config);
});
} }
export async function deleteAgentConfig(agentId: string): Promise<AgentsSnapshot> { export async function deleteAgentConfig(agentId: string): Promise<AgentsSnapshot> {
if (agentId === MAIN_AGENT_ID) { return withConfigLock(async () => {
throw new Error('The main agent cannot be deleted'); if (agentId === MAIN_AGENT_ID) {
} throw new Error('The main agent cannot be deleted');
}
const config = await readOpenClawConfig() as AgentConfigDocument; const config = await readOpenClawConfig() as AgentConfigDocument;
const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config); const { agentsConfig, entries, defaultAgentId } = normalizeAgentsConfig(config);
const removedEntry = entries.find((entry) => entry.id === agentId); const removedEntry = entries.find((entry) => entry.id === agentId);
const nextEntries = entries.filter((entry) => entry.id !== agentId); const nextEntries = entries.filter((entry) => entry.id !== agentId);
if (!removedEntry || nextEntries.length === entries.length) { if (!removedEntry || nextEntries.length === entries.length) {
throw new Error(`Agent "${agentId}" not found`); throw new Error(`Agent "${agentId}" not found`);
} }
config.agents = { config.agents = {
...agentsConfig, ...agentsConfig,
list: nextEntries, 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.bindings = Array.isArray(config.bindings)
? config.bindings.filter((binding) => !(isChannelBinding(binding) && binding.agentId === agentId))
: undefined;
await writeOpenClawConfig(config); if (defaultAgentId === agentId && nextEntries.length > 0) {
await deleteAgentChannelAccounts(agentId); nextEntries[0] = {
await removeAgentRuntimeDirectory(agentId); ...nextEntries[0],
await removeAgentWorkspaceDirectory(removedEntry); default: true,
logger.info('Deleted agent config entry', { agentId }); };
return buildSnapshotFromConfig(config); }
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<AgentsSnapshot> { export async function assignChannelToAgent(agentId: string, channelType: string): Promise<AgentsSnapshot> {
const config = await readOpenClawConfig() as AgentConfigDocument; return withConfigLock(async () => {
const { entries } = normalizeAgentsConfig(config); const config = await readOpenClawConfig() as AgentConfigDocument;
if (!entries.some((entry) => entry.id === agentId)) { const { entries } = normalizeAgentsConfig(config);
throw new Error(`Agent "${agentId}" not found`); if (!entries.some((entry) => entry.id === agentId)) {
} throw new Error(`Agent "${agentId}" not found`);
}
const accountId = resolveAccountIdForAgent(agentId); const accountId = resolveAccountIdForAgent(agentId);
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId); config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId);
await writeOpenClawConfig(config); await writeOpenClawConfig(config);
logger.info('Assigned channel to agent', { agentId, channelType, accountId }); logger.info('Assigned channel to agent', { agentId, channelType, accountId });
return buildSnapshotFromConfig(config); return buildSnapshotFromConfig(config);
});
} }
export async function clearChannelBinding(channelType: string, accountId?: string): Promise<AgentsSnapshot> { export async function clearChannelBinding(channelType: string, accountId?: string): Promise<AgentsSnapshot> {
const config = await readOpenClawConfig() as AgentConfigDocument; return withConfigLock(async () => {
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId); const config = await readOpenClawConfig() as AgentConfigDocument;
await writeOpenClawConfig(config); config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId);
logger.info('Cleared channel binding', { channelType, accountId }); await writeOpenClawConfig(config);
return buildSnapshotFromConfig(config); logger.info('Cleared channel binding', { channelType, accountId });
return buildSnapshotFromConfig(config);
});
} }
export async function clearAllBindingsForChannel(channelType: string): Promise<void> { export async function clearAllBindingsForChannel(channelType: string): Promise<void> {
const config = await readOpenClawConfig() as AgentConfigDocument; return withConfigLock(async () => {
if (!Array.isArray(config.bindings)) return; const config = await readOpenClawConfig() as AgentConfigDocument;
if (!Array.isArray(config.bindings)) return;
const nextBindings = config.bindings.filter((binding) => { const nextBindings = config.bindings.filter((binding) => {
if (!isChannelBinding(binding)) return true; if (!isChannelBinding(binding)) return true;
return binding.match?.channel !== channelType; 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 });
} }

View File

@@ -11,6 +11,7 @@ import { homedir } from 'os';
import { getOpenClawResolvedDir } from './paths'; import { getOpenClawResolvedDir } from './paths';
import * as logger from './logger'; import * as logger from './logger';
import { proxyAwareFetch } from './proxy-fetch'; import { proxyAwareFetch } from './proxy-fetch';
import { withConfigLock } from './config-mutex';
const OPENCLAW_DIR = join(homedir(), '.openclaw'); const OPENCLAW_DIR = join(homedir(), '.openclaw');
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
@@ -317,80 +318,82 @@ export async function saveChannelConfig(
config: ChannelConfigData, config: ChannelConfigData,
accountId?: string, accountId?: string,
): Promise<void> { ): Promise<void> {
const currentConfig = await readOpenClawConfig(); return withConfigLock(async () => {
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID; 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 // Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
if (PLUGIN_CHANNELS.includes(channelType)) { if (PLUGIN_CHANNELS.includes(channelType)) {
if (!currentConfig.plugins) { if (!currentConfig.plugins) {
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] = { if (!currentConfig.channels[channelType]) {
...currentConfig.plugins.entries[channelType], currentConfig.channels[channelType] = {};
enabled: config.enabled ?? true, }
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.<accountId>
if (!channelSection.accounts || typeof channelSection.accounts !== 'object') {
channelSection.accounts = {};
}
const accounts = channelSection.accounts as Record<string, ChannelConfigData>;
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) { // Most OpenClaw channel plugins read the default account's credentials
currentConfig.channels = {}; // from the top level of `channels.<type>` (e.g. channels.feishu.appId),
} // not from `accounts.default`. Mirror them there so plugins can discover
if (!currentConfig.channels[channelType]) { // the credentials correctly. We use the final account entry (not
currentConfig.channels[channelType] = {}; // transformedConfig) because `enabled` is only added at the account level.
} if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
for (const [key, value] of Object.entries(accounts[resolvedAccountId])) {
const channelSection = currentConfig.channels[channelType]; channelSection[key] = value;
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID); }
const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId);
const transformedConfig = transformChannelConfig(channelType, config, existingAccountConfig);
// Write credentials into accounts.<accountId>
if (!channelSection.accounts || typeof channelSection.accounts !== 'object') {
channelSection.accounts = {};
}
const accounts = channelSection.accounts as Record<string, ChannelConfigData>;
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.<type>` (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); await writeOpenClawConfig(currentConfig);
logger.info('Channel config saved', { logger.info('Channel config saved', {
channelType, channelType,
accountId: resolvedAccountId, accountId: resolvedAccountId,
configFile: CONFIG_FILE, configFile: CONFIG_FILE,
rawKeys: Object.keys(config), rawKeys: Object.keys(config),
transformedKeys: Object.keys(transformedConfig), 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<ChannelConfigData | undefined> { export async function getChannelConfig(channelType: string, accountId?: string): Promise<ChannelConfigData | undefined> {
@@ -463,57 +466,61 @@ export async function getChannelFormValues(channelType: string, accountId?: stri
} }
export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise<void> { export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise<void> {
const currentConfig = await readOpenClawConfig(); return withConfigLock(async () => {
const channelSection = currentConfig.channels?.[channelType]; const currentConfig = await readOpenClawConfig();
if (!channelSection) return; const channelSection = currentConfig.channels?.[channelType];
if (!channelSection) return;
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID); migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined; const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
if (!accounts?.[accountId]) return; if (!accounts?.[accountId]) return;
delete accounts[accountId]; delete accounts[accountId];
if (Object.keys(accounts).length === 0) { if (Object.keys(accounts).length === 0) {
delete currentConfig.channels![channelType]; delete currentConfig.channels![channelType];
} }
await writeOpenClawConfig(currentConfig); await writeOpenClawConfig(currentConfig);
logger.info('Deleted channel account config', { channelType, accountId }); logger.info('Deleted channel account config', { channelType, accountId });
console.log(`Deleted channel account config for ${channelType}/${accountId}`); console.log(`Deleted channel account config for ${channelType}/${accountId}`);
});
} }
export async function deleteChannelConfig(channelType: string): Promise<void> { export async function deleteChannelConfig(channelType: string): Promise<void> {
const currentConfig = await readOpenClawConfig(); return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
if (currentConfig.channels?.[channelType]) { if (currentConfig.channels?.[channelType]) {
delete 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;
}
await writeOpenClawConfig(currentConfig); await writeOpenClawConfig(currentConfig);
console.log(`Deleted plugin channel config for ${channelType}`); console.log(`Deleted channel config for ${channelType}`);
} } else if (PLUGIN_CHANNELS.includes(channelType)) {
} if (currentConfig.plugins?.entries?.[channelType]) {
delete currentConfig.plugins.entries[channelType];
if (channelType === 'whatsapp') { if (Object.keys(currentConfig.plugins.entries).length === 0) {
try { delete currentConfig.plugins.entries;
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); }
if (await fileExists(whatsappDir)) { if (currentConfig.plugins && Object.keys(currentConfig.plugins).length === 0) {
await rm(whatsappDir, { recursive: true, force: true }); delete currentConfig.plugins;
console.log('Deleted WhatsApp credentials directory'); }
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 { function channelHasAnyAccount(channelSection: ChannelConfigData): boolean {
@@ -564,49 +571,53 @@ export async function listConfiguredChannels(): Promise<string[]> {
} }
export async function deleteAgentChannelAccounts(agentId: string): Promise<void> { export async function deleteAgentChannelAccounts(agentId: string): Promise<void> {
const currentConfig = await readOpenClawConfig(); return withConfigLock(async () => {
if (!currentConfig.channels) return; const currentConfig = await readOpenClawConfig();
if (!currentConfig.channels) return;
const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId; const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId;
let modified = false; let modified = false;
for (const channelType of Object.keys(currentConfig.channels)) { for (const channelType of Object.keys(currentConfig.channels)) {
const section = currentConfig.channels[channelType]; const section = currentConfig.channels[channelType];
migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID); migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID);
const accounts = section.accounts as Record<string, ChannelConfigData> | undefined; const accounts = section.accounts as Record<string, ChannelConfigData> | undefined;
if (!accounts?.[accountId]) continue; if (!accounts?.[accountId]) continue;
delete accounts[accountId]; delete accounts[accountId];
if (Object.keys(accounts).length === 0) { if (Object.keys(accounts).length === 0) {
delete currentConfig.channels[channelType]; delete currentConfig.channels[channelType];
}
modified = true;
} }
modified = true;
}
if (modified) { if (modified) {
await writeOpenClawConfig(currentConfig); await writeOpenClawConfig(currentConfig);
logger.info('Deleted all channel accounts for agent', { agentId, accountId }); logger.info('Deleted all channel accounts for agent', { agentId, accountId });
} }
});
} }
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> { export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
const currentConfig = await readOpenClawConfig(); return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
if (PLUGIN_CHANNELS.includes(channelType)) { if (PLUGIN_CHANNELS.includes(channelType)) {
if (!currentConfig.plugins) currentConfig.plugins = {}; if (!currentConfig.plugins) currentConfig.plugins = {};
if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {}; if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {};
if (!currentConfig.plugins.entries[channelType]) currentConfig.plugins.entries[channelType] = {}; if (!currentConfig.plugins.entries[channelType]) currentConfig.plugins.entries[channelType] = {};
currentConfig.plugins.entries[channelType].enabled = enabled; 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); await writeOpenClawConfig(currentConfig);
console.log(`Set plugin channel ${channelType} enabled: ${enabled}`); console.log(`Set 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}`);
} }
// ── Validation ─────────────────────────────────────────────────── // ── Validation ───────────────────────────────────────────────────

View File

@@ -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<boolean>();
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<T>(fn: () => Promise<T>): Promise<T> {
// 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();
}
}

View File

@@ -23,6 +23,7 @@ import {
isOAuthProviderType, isOAuthProviderType,
isOpenClawOAuthPluginProviderKey, isOpenClawOAuthPluginProviderKey,
} from './provider-keys'; } from './provider-keys';
import { withConfigLock } from './config-mutex';
const AUTH_STORE_VERSION = 1; const AUTH_STORE_VERSION = 1;
const AUTH_PROFILE_FILENAME = 'auth-profiles.json'; const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
@@ -348,31 +349,33 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
// 3. Remove from openclaw.json // 3. Remove from openclaw.json
try { try {
const config = await readOpenClawJson(); await withConfigLock(async () => {
let modified = false; const config = await readOpenClawJson();
let modified = false;
// Disable plugin (for OAuth like qwen-portal-auth) // Disable plugin (for OAuth like qwen-portal-auth)
const plugins = config.plugins as Record<string, unknown> | undefined; const plugins = config.plugins as Record<string, unknown> | undefined;
const entries = (plugins?.entries ?? {}) as Record<string, Record<string, unknown>>; const entries = (plugins?.entries ?? {}) as Record<string, Record<string, unknown>>;
const pluginName = `${provider}-auth`; const pluginName = `${provider}-auth`;
if (entries[pluginName]) { if (entries[pluginName]) {
entries[pluginName].enabled = false; entries[pluginName].enabled = false;
modified = true; modified = true;
console.log(`Disabled OpenClaw plugin: ${pluginName}`); console.log(`Disabled OpenClaw plugin: ${pluginName}`);
} }
// Remove from models.providers // Remove from models.providers
const models = config.models as Record<string, unknown> | undefined; const models = config.models as Record<string, unknown> | undefined;
const providers = (models?.providers ?? {}) as Record<string, unknown>; const providers = (models?.providers ?? {}) as Record<string, unknown>;
if (providers[provider]) { if (providers[provider]) {
delete providers[provider]; delete providers[provider];
modified = true; modified = true;
console.log(`Removed OpenClaw provider config: ${provider}`); console.log(`Removed OpenClaw provider config: ${provider}`);
} }
if (modified) { if (modified) {
await writeOpenClawJson(config); await writeOpenClawJson(config);
} }
});
} catch (err) { } catch (err) {
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err); console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
} }
@@ -402,60 +405,62 @@ export async function setOpenClawDefaultModel(
modelOverride?: string, modelOverride?: string,
fallbackModels: string[] = [] fallbackModels: string[] = []
): Promise<void> { ): Promise<void> {
const config = await readOpenClawJson(); return withConfigLock(async () => {
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
const model = normalizeModelRef(provider, modelOverride); const model = normalizeModelRef(provider, modelOverride);
if (!model) { if (!model) {
console.warn(`No default model mapping for provider "${provider}"`); console.warn(`No default model mapping for provider "${provider}"`);
return; return;
}
const modelId = extractModelId(provider, model);
const fallbackModelIds = extractFallbackModelIds(provider, fallbackModels);
// Set the default model for the agents
const agents = (config.agents || {}) as Record<string, unknown>;
const defaults = (agents.defaults || {}) as Record<string, unknown>;
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<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
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 modelId = extractModelId(provider, model);
const gateway = (config.gateway || {}) as Record<string, unknown>; const fallbackModelIds = extractFallbackModelIds(provider, fallbackModels);
if (!gateway.mode) gateway.mode = 'local';
config.gateway = gateway;
await writeOpenClawJson(config); // Set the default model for the agents
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`); const agents = (config.agents || {}) as Record<string, unknown>;
const defaults = (agents.defaults || {}) as Record<string, unknown>;
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<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
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<string, unknown>;
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 { interface RuntimeProviderConfigOverride {
@@ -594,35 +599,37 @@ export async function syncProviderConfigToOpenClaw(
modelId: string | undefined, modelId: string | undefined,
override: RuntimeProviderConfigOverride override: RuntimeProviderConfigOverride
): Promise<void> { ): Promise<void> {
const config = await readOpenClawJson(); return withConfigLock(async () => {
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
if (override.baseUrl && override.api) { if (override.baseUrl && override.api) {
upsertOpenClawProviderEntry(config, provider, { upsertOpenClawProviderEntry(config, provider, {
baseUrl: override.baseUrl, baseUrl: override.baseUrl,
api: override.api, api: override.api,
apiKeyEnv: override.apiKeyEnv, apiKeyEnv: override.apiKeyEnv,
headers: override.headers, headers: override.headers,
modelIds: modelId ? [modelId] : [], modelIds: modelId ? [modelId] : [],
}); });
}
// Ensure extension is enabled for oauth providers to prevent gateway wiping config
if (isOpenClawOAuthPluginProviderKey(provider)) {
const plugins = (config.plugins || {}) as Record<string, unknown>;
const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : [];
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
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); // Ensure extension is enabled for oauth providers to prevent gateway wiping config
if (isOpenClawOAuthPluginProviderKey(provider)) {
const plugins = (config.plugins || {}) as Record<string, unknown>;
const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : [];
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
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, override: RuntimeProviderConfigOverride,
fallbackModels: string[] = [] fallbackModels: string[] = []
): Promise<void> { ): Promise<void> {
const config = await readOpenClawJson(); return withConfigLock(async () => {
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider); const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
const model = normalizeModelRef(provider, modelOverride); const model = normalizeModelRef(provider, modelOverride);
if (!model) { if (!model) {
console.warn(`No default model mapping for provider "${provider}"`); console.warn(`No default model mapping for provider "${provider}"`);
return; return;
}
const modelId = extractModelId(provider, model);
const fallbackModelIds = extractFallbackModelIds(provider, fallbackModels);
const agents = (config.agents || {}) as Record<string, unknown>;
const defaults = (agents.defaults || {}) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : [];
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
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); const modelId = extractModelId(provider, model);
console.log( const fallbackModelIds = extractFallbackModelIds(provider, fallbackModels);
`Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)`
); const agents = (config.agents || {}) as Record<string, unknown>;
const defaults = (agents.defaults || {}) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : [];
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
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<Set<string>> {
* Write the ClawX gateway token into ~/.openclaw/openclaw.json. * Write the ClawX gateway token into ~/.openclaw/openclaw.json.
*/ */
export async function syncGatewayTokenToConfig(token: string): Promise<void> { export async function syncGatewayTokenToConfig(token: string): Promise<void> {
const config = await readOpenClawJson(); return withConfigLock(async () => {
const config = await readOpenClawJson();
const gateway = ( const gateway = (
config.gateway && typeof config.gateway === 'object' config.gateway && typeof config.gateway === 'object'
? { ...(config.gateway as Record<string, unknown>) } ? { ...(config.gateway as Record<string, unknown>) }
: {} : {}
) as Record<string, unknown>; ) as Record<string, unknown>;
const auth = ( const auth = (
gateway.auth && typeof gateway.auth === 'object' gateway.auth && typeof gateway.auth === 'object'
? { ...(gateway.auth as Record<string, unknown>) } ? { ...(gateway.auth as Record<string, unknown>) }
: {} : {}
) as Record<string, unknown>; ) as Record<string, unknown>;
auth.mode = 'token'; auth.mode = 'token';
auth.token = token; auth.token = token;
gateway.auth = auth; gateway.auth = auth;
// Packaged ClawX loads the renderer from file://, so the gateway must allow // Packaged ClawX loads the renderer from file://, so the gateway must allow
// that origin for the chat WebSocket handshake. // that origin for the chat WebSocket handshake.
const controlUi = ( const controlUi = (
gateway.controlUi && typeof gateway.controlUi === 'object' gateway.controlUi && typeof gateway.controlUi === 'object'
? { ...(gateway.controlUi as Record<string, unknown>) } ? { ...(gateway.controlUi as Record<string, unknown>) }
: {} : {}
) as Record<string, unknown>; ) as Record<string, unknown>;
const allowedOrigins = Array.isArray(controlUi.allowedOrigins) const allowedOrigins = Array.isArray(controlUi.allowedOrigins)
? (controlUi.allowedOrigins as unknown[]).filter((value): value is string => typeof value === 'string') ? (controlUi.allowedOrigins as unknown[]).filter((value): value is string => typeof value === 'string')
: []; : [];
if (!allowedOrigins.includes('file://')) { if (!allowedOrigins.includes('file://')) {
controlUi.allowedOrigins = [...allowedOrigins, 'file://']; controlUi.allowedOrigins = [...allowedOrigins, 'file://'];
} }
gateway.controlUi = controlUi; gateway.controlUi = controlUi;
if (!gateway.mode) gateway.mode = 'local'; if (!gateway.mode) gateway.mode = 'local';
config.gateway = gateway; config.gateway = gateway;
await writeOpenClawJson(config); await writeOpenClawJson(config);
console.log('Synced gateway token to openclaw.json'); console.log('Synced gateway token to openclaw.json');
});
} }
/** /**
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json. * Ensure browser automation is enabled in ~/.openclaw/openclaw.json.
*/ */
export async function syncBrowserConfigToOpenClaw(): Promise<void> { export async function syncBrowserConfigToOpenClaw(): Promise<void> {
const config = await readOpenClawJson(); return withConfigLock(async () => {
const config = await readOpenClawJson();
const browser = ( const browser = (
config.browser && typeof config.browser === 'object' config.browser && typeof config.browser === 'object'
? { ...(config.browser as Record<string, unknown>) } ? { ...(config.browser as Record<string, unknown>) }
: {} : {}
) as Record<string, unknown>; ) as Record<string, unknown>;
let changed = false; let changed = false;
if (browser.enabled === undefined) { if (browser.enabled === undefined) {
browser.enabled = true; browser.enabled = true;
changed = true; changed = true;
} }
if (browser.defaultProfile === undefined) { if (browser.defaultProfile === undefined) {
browser.defaultProfile = 'openclaw'; browser.defaultProfile = 'openclaw';
changed = true; changed = true;
} }
if (!changed) return; if (!changed) return;
config.browser = browser; config.browser = browser;
await writeOpenClawJson(config); await writeOpenClawJson(config);
console.log('Synced browser config to openclaw.json'); console.log('Synced browser config to openclaw.json');
});
} }
/** /**
@@ -879,175 +892,177 @@ export async function updateAgentModelProvider(
* (`runOpenClawDoctorRepair`) runs `openclaw doctor --fix` as a fallback. * (`runOpenClawDoctorRepair`) runs `openclaw doctor --fix` as a fallback.
*/ */
export async function sanitizeOpenClawConfig(): Promise<void> { export async function sanitizeOpenClawConfig(): Promise<void> {
const config = await readOpenClawJson(); return withConfigLock(async () => {
let modified = false; const config = await readOpenClawJson();
let modified = false;
// ── skills section ────────────────────────────────────────────── // ── skills section ──────────────────────────────────────────────
// OpenClaw's Zod schema uses .strict() on the skills object, accepting // OpenClaw's Zod schema uses .strict() on the skills object, accepting
// only: allowBundled, load, install, limits, entries. // only: allowBundled, load, install, limits, entries.
// The key "enabled" belongs inside skills.entries[key].enabled, NOT at // The key "enabled" belongs inside skills.entries[key].enabled, NOT at
// the skills root level. Older versions may have placed it there. // the skills root level. Older versions may have placed it there.
const skills = config.skills; const skills = config.skills;
if (skills && typeof skills === 'object' && !Array.isArray(skills)) { if (skills && typeof skills === 'object' && !Array.isArray(skills)) {
const skillsObj = skills as Record<string, unknown>; const skillsObj = skills as Record<string, unknown>;
// Keys that are known to be invalid at the skills root level. // Keys that are known to be invalid at the skills root level.
const KNOWN_INVALID_SKILLS_ROOT_KEYS = ['enabled', 'disabled']; const KNOWN_INVALID_SKILLS_ROOT_KEYS = ['enabled', 'disabled'];
for (const key of KNOWN_INVALID_SKILLS_ROOT_KEYS) { for (const key of KNOWN_INVALID_SKILLS_ROOT_KEYS) {
if (key in skillsObj) { if (key in skillsObj) {
console.log(`[sanitize] Removing misplaced key "skills.${key}" from openclaw.json`); console.log(`[sanitize] Removing misplaced key "skills.${key}" from openclaw.json`);
delete skillsObj[key]; delete skillsObj[key];
modified = true; 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);
} }
} }
if (modified) config.plugins = validPlugins; }
} else if (typeof plugins === 'object') {
const pluginsObj = plugins as Record<string, unknown>; // ── plugins section ──────────────────────────────────────────────
if (Array.isArray(pluginsObj.load)) { // Remove absolute paths in plugins that no longer exist or are bundled (preventing hardlink validation errors)
const validLoad: unknown[] = []; const plugins = config.plugins;
for (const p of pluginsObj.load) { if (plugins) {
if (Array.isArray(plugins)) {
const validPlugins: unknown[] = [];
for (const p of plugins) {
if (typeof p === 'string' && p.startsWith('/')) { if (typeof p === 'string' && p.startsWith('/')) {
if (p.includes('node_modules/openclaw/extensions') || !(await fileExists(p))) { if (p.includes('node_modules/openclaw/extensions') || !(await fileExists(p))) {
console.log(`[sanitize] Removing stale/bundled plugin path "${p}" from openclaw.json`); console.log(`[sanitize] Removing stale/bundled plugin path "${p}" from openclaw.json`);
modified = true; modified = true;
} else { } else {
validLoad.push(p); validPlugins.push(p);
} }
} else { } else {
validLoad.push(p); validPlugins.push(p);
} }
} }
if (modified) pluginsObj.load = validLoad; if (modified) config.plugins = validPlugins;
} } else if (typeof plugins === 'object') {
} const pluginsObj = plugins as Record<string, unknown>;
} if (Array.isArray(pluginsObj.load)) {
const validLoad: unknown[] = [];
// ── commands section ─────────────────────────────────────────── for (const p of pluginsObj.load) {
// Required for SIGUSR1 in-process reload authorization. if (typeof p === 'string' && p.startsWith('/')) {
const commands = ( if (p.includes('node_modules/openclaw/extensions') || !(await fileExists(p))) {
config.commands && typeof config.commands === 'object' console.log(`[sanitize] Removing stale/bundled plugin path "${p}" from openclaw.json`);
? { ...(config.commands as Record<string, unknown>) } modified = true;
: {} } else {
) as Record<string, unknown>; validLoad.push(p);
if (commands.restart !== true) { }
commands.restart = true; } else {
config.commands = commands; validLoad.push(p);
modified = true; }
console.log('[sanitize] Enabling commands.restart for graceful reload support'); }
} if (modified) pluginsObj.load = validLoad;
// ── 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<string, unknown> | undefined)?.providers as Record<string, unknown> | undefined) || {};
if (providers[OPENCLAW_PROVIDER_KEY_MOONSHOT]) {
const tools = (config.tools as Record<string, unknown> | undefined) || {};
const web = (tools.web as Record<string, unknown> | undefined) || {};
const search = (web.search as Record<string, unknown> | undefined) || {};
const kimi = (search.kimi as Record<string, unknown> | 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<string, unknown> | undefined) || {};
let toolsModified = false;
if (toolsConfig.profile !== 'full') {
toolsConfig.profile = 'full';
toolsModified = true;
}
const sessions = (toolsConfig.sessions as Record<string, unknown> | 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<string, unknown>;
const pEntries = pluginsObj.entries as Record<string, Record<string, unknown>> | 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.<type>` (e.g. channels.feishu.appId),
// but ClawX historically stored them only under `channels.<type>.accounts.default`.
// Mirror the default account credentials at the top level so plugins can
// discover them.
const channelsObj = config.channels as Record<string, Record<string, unknown>> | 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<string, Record<string, unknown>> | 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) { }
// ── commands section ───────────────────────────────────────────
// Required for SIGUSR1 in-process reload authorization.
const commands = (
config.commands && typeof config.commands === 'object'
? { ...(config.commands as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
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<string, unknown> | undefined)?.providers as Record<string, unknown> | undefined) || {};
if (providers[OPENCLAW_PROVIDER_KEY_MOONSHOT]) {
const tools = (config.tools as Record<string, unknown> | undefined) || {};
const web = (tools.web as Record<string, unknown> | undefined) || {};
const search = (web.search as Record<string, unknown> | undefined) || {};
const kimi = (search.kimi as Record<string, unknown> | 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; modified = true;
console.log(`[sanitize] Mirrored ${channelType} default account credentials to top-level channels.${channelType}`);
} }
} }
}
if (modified) { // ── tools.profile & sessions.visibility ───────────────────────
await writeOpenClawJson(config); // OpenClaw 3.8+ requires tools.profile = 'full' and tools.sessions.visibility = 'all'
console.log('[sanitize] openclaw.json sanitized successfully'); // for ClawX to properly integrate with its updated tool system.
} const toolsConfig = (config.tools as Record<string, unknown> | undefined) || {};
let toolsModified = false;
if (toolsConfig.profile !== 'full') {
toolsConfig.profile = 'full';
toolsModified = true;
}
const sessions = (toolsConfig.sessions as Record<string, unknown> | 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<string, unknown>;
const pEntries = pluginsObj.entries as Record<string, Record<string, unknown>> | 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.<type>` (e.g. channels.feishu.appId),
// but ClawX historically stored them only under `channels.<type>.accounts.default`.
// Mirror the default account credentials at the top level so plugins can
// discover them.
const channelsObj = config.channels as Record<string, Record<string, unknown>> | 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<string, Record<string, unknown>> | 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'; export { getProviderEnvVar } from './provider-registry';

View File

@@ -1,43 +1,46 @@
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config'; import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
import { resolveProxySettings, type ProxySettings } from './proxy'; import { resolveProxySettings, type ProxySettings } from './proxy';
import { logger } from './logger'; import { logger } from './logger';
import { withConfigLock } from './config-mutex';
/** /**
* Sync ClawX global proxy settings into OpenClaw channel config where the * Sync ClawX global proxy settings into OpenClaw channel config where the
* upstream runtime expects an explicit per-channel proxy knob. * upstream runtime expects an explicit per-channel proxy knob.
*/ */
export async function syncProxyConfigToOpenClaw(settings: ProxySettings): Promise<void> { export async function syncProxyConfigToOpenClaw(settings: ProxySettings): Promise<void> {
const config = await readOpenClawConfig(); return withConfigLock(async () => {
const telegramConfig = config.channels?.telegram; const config = await readOpenClawConfig();
const telegramConfig = config.channels?.telegram;
if (!telegramConfig) { if (!telegramConfig) {
return; return;
} }
const resolved = resolveProxySettings(settings); const resolved = resolveProxySettings(settings);
const nextProxy = settings.proxyEnabled const nextProxy = settings.proxyEnabled
? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy) ? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy)
: ''; : '';
const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : ''; const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : '';
if (!nextProxy && !currentProxy) { if (!nextProxy && !currentProxy) {
return; return;
} }
if (!config.channels) { if (!config.channels) {
config.channels = {}; config.channels = {};
} }
config.channels.telegram = { config.channels.telegram = {
...telegramConfig, ...telegramConfig,
}; };
if (nextProxy) { if (nextProxy) {
config.channels.telegram.proxy = nextProxy; config.channels.telegram.proxy = nextProxy;
} else { } else {
delete config.channels.telegram.proxy; delete config.channels.telegram.proxy;
} }
await writeOpenClawConfig(config); await writeOpenClawConfig(config);
logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`); logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`);
});
} }

View File

@@ -12,6 +12,7 @@ import { join } from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import { getOpenClawDir, getResourcesDir } from './paths'; import { getOpenClawDir, getResourcesDir } from './paths';
import { logger } from './logger'; import { logger } from './logger';
import { withConfigLock } from './config-mutex';
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); 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) { if (skillKeys.length === 0) {
return; return;
} }
const config = await readConfig(); return withConfigLock(async () => {
if (!config.skills) { const config = await readConfig();
config.skills = {}; if (!config.skills) {
} config.skills = {};
if (!config.skills.entries) { }
config.skills.entries = {}; if (!config.skills.entries) {
} config.skills.entries = {};
for (const skillKey of skillKeys) { }
const entry = config.skills.entries[skillKey] || {}; for (const skillKey of skillKeys) {
entry.enabled = enabled; const entry = config.skills.entries[skillKey] || {};
config.skills.entries[skillKey] = entry; entry.enabled = enabled;
} config.skills.entries[skillKey] = entry;
await writeConfig(config); }
await writeConfig(config);
});
} }
/** /**
@@ -118,55 +121,57 @@ export async function updateSkillConfig(
updates: { apiKey?: string; env?: Record<string, string> } updates: { apiKey?: string; env?: Record<string, string> }
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
try { try {
const config = await readConfig(); return await withConfigLock(async () => {
const config = await readConfig();
// Ensure skills.entries exists // Ensure skills.entries exists
if (!config.skills) { if (!config.skills) {
config.skills = {}; config.skills = {};
} }
if (!config.skills.entries) { if (!config.skills.entries) {
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;
} }
}
// Update env // Get or create skill entry
if (updates.env !== undefined) { const entry = config.skills.entries[skillKey] || {};
const newEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(updates.env)) { // Update apiKey
const trimmedKey = key.trim(); if (updates.apiKey !== undefined) {
if (!trimmedKey) continue; const trimmed = updates.apiKey.trim();
if (trimmed) {
const trimmedVal = value.trim(); entry.apiKey = trimmed;
if (trimmedVal) { } else {
newEnv[trimmedKey] = trimmedVal; delete entry.apiKey;
} }
} }
if (Object.keys(newEnv).length > 0) { // Update env
entry.env = newEnv; if (updates.env !== undefined) {
} else { const newEnv: Record<string, string> = {};
delete entry.env;
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 // Save entry back
config.skills.entries[skillKey] = entry; config.skills.entries[skillKey] = entry;
await writeConfig(config); await writeConfig(config);
return { success: true }; return { success: true };
});
} catch (err) { } catch (err) {
console.error('Failed to update skill config:', err); console.error('Failed to update skill config:', err);
return { success: false, error: String(err) }; return { success: false, error: String(err) };

View File

@@ -19,7 +19,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useChannelsStore } from '@/stores/channels'; import { useChannelsStore } from '@/stores/channels';
import { useGatewayStore } from '@/stores/gateway';
import { hostApiFetch } from '@/lib/host-api'; import { hostApiFetch } from '@/lib/host-api';
import { subscribeHostEvent } from '@/lib/host-events'; import { subscribeHostEvent } from '@/lib/host-events';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -98,7 +98,7 @@ export function ChannelConfigModal({
setValidationResult(null); setValidationResult(null);
setQrCode(null); setQrCode(null);
setConnecting(false); setConnecting(false);
hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => {}); hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { });
return; return;
} }
@@ -193,7 +193,10 @@ export function ChannelConfigModal({
} }
await finishSave('whatsapp'); 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(); onClose();
} catch (error) { } catch (error) {
toast.error(t('toast.configFailed', { error: String(error) })); toast.error(t('toast.configFailed', { error: String(error) }));
@@ -216,7 +219,7 @@ export function ChannelConfigModal({
removeQrListener(); removeQrListener();
removeSuccessListener(); removeSuccessListener();
removeErrorListener(); removeErrorListener();
hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => {}); hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { });
}; };
}, [selectedType, finishSave, onClose, t]); }, [selectedType, finishSave, onClose, t]);