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,6 +502,7 @@ export async function listConfiguredAgentIds(): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createAgent(name: string): Promise<AgentsSnapshot> {
|
export async function createAgent(name: string): Promise<AgentsSnapshot> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||||
const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config);
|
const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config);
|
||||||
const normalizedName = normalizeAgentName(name);
|
const normalizedName = normalizeAgentName(name);
|
||||||
@@ -536,9 +538,11 @@ export async function createAgent(name: string): Promise<AgentsSnapshot> {
|
|||||||
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> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||||
const { agentsConfig, entries } = normalizeAgentsConfig(config);
|
const { agentsConfig, entries } = normalizeAgentsConfig(config);
|
||||||
const normalizedName = normalizeAgentName(name);
|
const normalizedName = normalizeAgentName(name);
|
||||||
@@ -560,9 +564,11 @@ export async function updateAgentName(agentId: string, name: string): Promise<Ag
|
|||||||
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> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
if (agentId === MAIN_AGENT_ID) {
|
if (agentId === MAIN_AGENT_ID) {
|
||||||
throw new Error('The main agent cannot be deleted');
|
throw new Error('The main agent cannot be deleted');
|
||||||
}
|
}
|
||||||
@@ -596,9 +602,11 @@ export async function deleteAgentConfig(agentId: string): Promise<AgentsSnapshot
|
|||||||
await removeAgentWorkspaceDirectory(removedEntry);
|
await removeAgentWorkspaceDirectory(removedEntry);
|
||||||
logger.info('Deleted agent config entry', { agentId });
|
logger.info('Deleted agent config entry', { agentId });
|
||||||
return buildSnapshotFromConfig(config);
|
return buildSnapshotFromConfig(config);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assignChannelToAgent(agentId: string, channelType: string): Promise<AgentsSnapshot> {
|
export async function assignChannelToAgent(agentId: string, channelType: string): Promise<AgentsSnapshot> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||||
const { entries } = normalizeAgentsConfig(config);
|
const { entries } = normalizeAgentsConfig(config);
|
||||||
if (!entries.some((entry) => entry.id === agentId)) {
|
if (!entries.some((entry) => entry.id === agentId)) {
|
||||||
@@ -610,17 +618,21 @@ export async function assignChannelToAgent(agentId: string, channelType: string)
|
|||||||
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> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||||
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId);
|
config.bindings = upsertBindingsForChannel(config.bindings, channelType, null, accountId);
|
||||||
await writeOpenClawConfig(config);
|
await writeOpenClawConfig(config);
|
||||||
logger.info('Cleared channel binding', { channelType, accountId });
|
logger.info('Cleared channel binding', { channelType, accountId });
|
||||||
return buildSnapshotFromConfig(config);
|
return buildSnapshotFromConfig(config);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearAllBindingsForChannel(channelType: string): Promise<void> {
|
export async function clearAllBindingsForChannel(channelType: string): Promise<void> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||||
if (!Array.isArray(config.bindings)) return;
|
if (!Array.isArray(config.bindings)) return;
|
||||||
|
|
||||||
@@ -632,4 +644,5 @@ export async function clearAllBindingsForChannel(channelType: string): Promise<v
|
|||||||
config.bindings = nextBindings.length > 0 ? nextBindings : undefined;
|
config.bindings = nextBindings.length > 0 ? nextBindings : undefined;
|
||||||
await writeOpenClawConfig(config);
|
await writeOpenClawConfig(config);
|
||||||
logger.info('Cleared all bindings for channel', { channelType });
|
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,6 +318,7 @@ export async function saveChannelConfig(
|
|||||||
config: ChannelConfigData,
|
config: ChannelConfigData,
|
||||||
accountId?: string,
|
accountId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const currentConfig = await readOpenClawConfig();
|
const currentConfig = await readOpenClawConfig();
|
||||||
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
|
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
|
||||||
|
|
||||||
@@ -391,6 +393,7 @@ export async function saveChannelConfig(
|
|||||||
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,6 +466,7 @@ 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> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const currentConfig = await readOpenClawConfig();
|
const currentConfig = await readOpenClawConfig();
|
||||||
const channelSection = currentConfig.channels?.[channelType];
|
const channelSection = currentConfig.channels?.[channelType];
|
||||||
if (!channelSection) return;
|
if (!channelSection) return;
|
||||||
@@ -480,9 +484,11 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
|||||||
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> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const currentConfig = await readOpenClawConfig();
|
const currentConfig = await readOpenClawConfig();
|
||||||
|
|
||||||
if (currentConfig.channels?.[channelType]) {
|
if (currentConfig.channels?.[channelType]) {
|
||||||
@@ -514,6 +520,7 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
|
|||||||
console.error('Failed to delete WhatsApp credentials:', error);
|
console.error('Failed to delete WhatsApp credentials:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function channelHasAnyAccount(channelSection: ChannelConfigData): boolean {
|
function channelHasAnyAccount(channelSection: ChannelConfigData): boolean {
|
||||||
@@ -564,6 +571,7 @@ export async function listConfiguredChannels(): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAgentChannelAccounts(agentId: string): Promise<void> {
|
export async function deleteAgentChannelAccounts(agentId: string): Promise<void> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const currentConfig = await readOpenClawConfig();
|
const currentConfig = await readOpenClawConfig();
|
||||||
if (!currentConfig.channels) return;
|
if (!currentConfig.channels) return;
|
||||||
|
|
||||||
@@ -587,9 +595,11 @@ export async function deleteAgentChannelAccounts(agentId: string): Promise<void>
|
|||||||
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> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const currentConfig = await readOpenClawConfig();
|
const currentConfig = await readOpenClawConfig();
|
||||||
|
|
||||||
if (PLUGIN_CHANNELS.includes(channelType)) {
|
if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||||
@@ -607,6 +617,7 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
|
|||||||
currentConfig.channels[channelType].enabled = enabled;
|
currentConfig.channels[channelType].enabled = enabled;
|
||||||
await writeOpenClawConfig(currentConfig);
|
await writeOpenClawConfig(currentConfig);
|
||||||
console.log(`Set channel ${channelType} enabled: ${enabled}`);
|
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,6 +349,7 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
|
|||||||
|
|
||||||
// 3. Remove from openclaw.json
|
// 3. Remove from openclaw.json
|
||||||
try {
|
try {
|
||||||
|
await withConfigLock(async () => {
|
||||||
const config = await readOpenClawJson();
|
const config = await readOpenClawJson();
|
||||||
let modified = false;
|
let modified = false;
|
||||||
|
|
||||||
@@ -373,6 +375,7 @@ export async function removeProviderFromOpenClaw(provider: string): Promise<void
|
|||||||
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,6 +405,7 @@ export async function setOpenClawDefaultModel(
|
|||||||
modelOverride?: string,
|
modelOverride?: string,
|
||||||
fallbackModels: string[] = []
|
fallbackModels: string[] = []
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readOpenClawJson();
|
const config = await readOpenClawJson();
|
||||||
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
||||||
|
|
||||||
@@ -456,6 +460,7 @@ export async function setOpenClawDefaultModel(
|
|||||||
|
|
||||||
await writeOpenClawJson(config);
|
await writeOpenClawJson(config);
|
||||||
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`);
|
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuntimeProviderConfigOverride {
|
interface RuntimeProviderConfigOverride {
|
||||||
@@ -594,6 +599,7 @@ export async function syncProviderConfigToOpenClaw(
|
|||||||
modelId: string | undefined,
|
modelId: string | undefined,
|
||||||
override: RuntimeProviderConfigOverride
|
override: RuntimeProviderConfigOverride
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readOpenClawJson();
|
const config = await readOpenClawJson();
|
||||||
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
||||||
|
|
||||||
@@ -623,6 +629,7 @@ export async function syncProviderConfigToOpenClaw(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await writeOpenClawJson(config);
|
await writeOpenClawJson(config);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -634,6 +641,7 @@ export async function setOpenClawDefaultModelWithOverride(
|
|||||||
override: RuntimeProviderConfigOverride,
|
override: RuntimeProviderConfigOverride,
|
||||||
fallbackModels: string[] = []
|
fallbackModels: string[] = []
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readOpenClawJson();
|
const config = await readOpenClawJson();
|
||||||
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
|
||||||
|
|
||||||
@@ -689,6 +697,7 @@ export async function setOpenClawDefaultModelWithOverride(
|
|||||||
console.log(
|
console.log(
|
||||||
`Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)`
|
`Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)`
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -729,6 +738,7 @@ 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> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readOpenClawJson();
|
const config = await readOpenClawJson();
|
||||||
|
|
||||||
const gateway = (
|
const gateway = (
|
||||||
@@ -767,12 +777,14 @@ export async function syncGatewayTokenToConfig(token: string): Promise<void> {
|
|||||||
|
|
||||||
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> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readOpenClawJson();
|
const config = await readOpenClawJson();
|
||||||
|
|
||||||
const browser = (
|
const browser = (
|
||||||
@@ -798,6 +810,7 @@ export async function syncBrowserConfigToOpenClaw(): Promise<void> {
|
|||||||
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,6 +892,7 @@ 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> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readOpenClawJson();
|
const config = await readOpenClawJson();
|
||||||
let modified = false;
|
let modified = false;
|
||||||
|
|
||||||
@@ -1048,6 +1062,7 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
|||||||
await writeOpenClawJson(config);
|
await writeOpenClawJson(config);
|
||||||
console.log('[sanitize] openclaw.json sanitized successfully');
|
console.log('[sanitize] openclaw.json sanitized successfully');
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getProviderEnvVar } from './provider-registry';
|
export { getProviderEnvVar } from './provider-registry';
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
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> {
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readOpenClawConfig();
|
const config = await readOpenClawConfig();
|
||||||
const telegramConfig = config.channels?.telegram;
|
const telegramConfig = config.channels?.telegram;
|
||||||
|
|
||||||
@@ -40,4 +42,5 @@ export async function syncProxyConfigToOpenClaw(settings: ProxySettings): Promis
|
|||||||
|
|
||||||
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,6 +88,7 @@ async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise<
|
|||||||
if (skillKeys.length === 0) {
|
if (skillKeys.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
return withConfigLock(async () => {
|
||||||
const config = await readConfig();
|
const config = await readConfig();
|
||||||
if (!config.skills) {
|
if (!config.skills) {
|
||||||
config.skills = {};
|
config.skills = {};
|
||||||
@@ -100,6 +102,7 @@ async function setSkillsEnabled(skillKeys: string[], enabled: boolean): Promise<
|
|||||||
config.skills.entries[skillKey] = entry;
|
config.skills.entries[skillKey] = entry;
|
||||||
}
|
}
|
||||||
await writeConfig(config);
|
await writeConfig(config);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,6 +121,7 @@ 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 {
|
||||||
|
return await withConfigLock(async () => {
|
||||||
const config = await readConfig();
|
const config = await readConfig();
|
||||||
|
|
||||||
// Ensure skills.entries exists
|
// Ensure skills.entries exists
|
||||||
@@ -167,6 +171,7 @@ export async function updateSkillConfig(
|
|||||||
|
|
||||||
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