fix: resolve channel-config overwrite bug by implementing reentrant config mutex (#462)
This commit is contained in:
committed by
GitHub
Unverified
parent
740116ae9d
commit
abc0c6e7d5
@@ -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 });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────────
|
||||||
|
|||||||
83
electron/utils/config-mutex.ts
Normal file
83
electron/utils/config-mutex.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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'})`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user