fix(win): Windows stability improvements (#207) (#208)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
Haze
2026-02-27 22:10:35 +08:00
committed by GitHub
Unverified
parent 0fb1a1a78d
commit 386d4c5454
14 changed files with 754 additions and 871 deletions

View File

@@ -130,17 +130,20 @@ export class GatewayManager extends EventEmitter {
timeout: NodeJS.Timeout;
}> = new Map();
private deviceIdentity: DeviceIdentity | null = null;
private restartDebounceTimer: NodeJS.Timeout | null = null;
constructor(config?: Partial<ReconnectConfig>) {
super();
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
this.initDeviceIdentity();
// Device identity is loaded lazily in start() — not in the constructor —
// so that async file I/O and key generation don't block module loading.
}
private initDeviceIdentity(): void {
private async initDeviceIdentity(): Promise<void> {
if (this.deviceIdentity) return; // already loaded
try {
const identityPath = path.join(app.getPath('userData'), 'clawx-device-identity.json');
this.deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
this.deviceIdentity = await loadOrCreateDeviceIdentity(identityPath);
logger.debug(`Device identity loaded (deviceId=${this.deviceIdentity.deviceId})`);
} catch (err) {
logger.warn('Failed to load device identity, scopes will be limited:', err);
@@ -211,6 +214,10 @@ export class GatewayManager extends EventEmitter {
this.lastSpawnSummary = null;
this.shouldReconnect = true;
// Lazily load device identity (async file I/O + key generation).
// Must happen before connect() which uses the identity for the handshake.
await this.initDeviceIdentity();
// Manual start should override and cancel any pending reconnect timer.
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
@@ -369,6 +376,26 @@ export class GatewayManager extends EventEmitter {
await this.stop();
await this.start();
}
/**
* Debounced restart — coalesces multiple rapid restart requests into a
* single restart after `delayMs` of inactivity. This prevents the
* cascading stop/start cycles that occur when provider:save,
* provider:setDefault and channel:saveConfig all fire within seconds
* of each other during setup.
*/
debouncedRestart(delayMs = 2000): void {
if (this.restartDebounceTimer) {
clearTimeout(this.restartDebounceTimer);
}
logger.debug(`Gateway restart debounced (will fire in ${delayMs}ms)`);
this.restartDebounceTimer = setTimeout(() => {
this.restartDebounceTimer = null;
void this.restart().catch((err) => {
logger.warn('Debounced Gateway restart failed:', err);
});
}, delayMs);
}
/**
* Clear all active timers
@@ -386,6 +413,10 @@ export class GatewayManager extends EventEmitter {
clearInterval(this.healthCheckInterval);
this.healthCheckInterval = null;
}
if (this.restartDebounceTimer) {
clearTimeout(this.restartDebounceTimer);
this.restartDebounceTimer = null;
}
}
/**
@@ -540,9 +571,15 @@ export class GatewayManager extends EventEmitter {
const port = PORTS.OPENCLAW_GATEWAY;
try {
// Platform-specific command to find processes listening on the gateway port.
// On Windows, lsof doesn't exist; use PowerShell's Get-NetTCPConnection instead.
const cmd = process.platform === 'win32'
? `powershell -NoProfile -Command "(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`
: `lsof -i :${port} -sTCP:LISTEN -t`;
const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => {
import('child_process').then(cp => {
cp.exec(`lsof -i :${port} -sTCP:LISTEN -t`, { timeout: 5000 }, (err, stdout) => {
cp.exec(cmd, { timeout: 5000 }, (err, stdout) => {
if (err) resolve({ stdout: '' });
else resolve({ stdout });
});
@@ -550,7 +587,7 @@ export class GatewayManager extends EventEmitter {
});
if (stdout.trim()) {
const pids = stdout.trim().split('\n')
const pids = stdout.trim().split(/\r?\n/)
.map(s => s.trim())
.filter(Boolean);
@@ -560,18 +597,33 @@ export class GatewayManager extends EventEmitter {
// Unload the launchctl service first so macOS doesn't auto-
// respawn the process we're about to kill.
await this.unloadLaunchctlService();
if (process.platform === 'darwin') {
await this.unloadLaunchctlService();
}
// SIGTERM first so the gateway can clean up its lock file.
// Terminate orphaned processes
for (const pid of pids) {
try { process.kill(parseInt(pid), 'SIGTERM'); } catch { /* ignore */ }
try {
if (process.platform === 'win32') {
// On Windows, use taskkill for reliable process group termination
import('child_process').then(cp => {
cp.exec(`taskkill /PID ${pid} /T /F`, { timeout: 5000 }, () => {});
}).catch(() => {});
} else {
// SIGTERM first so the gateway can clean up its lock file.
process.kill(parseInt(pid), 'SIGTERM');
}
} catch { /* ignore */ }
}
await new Promise(r => setTimeout(r, 3000));
// SIGKILL any survivors.
for (const pid of pids) {
try { process.kill(parseInt(pid), 0); process.kill(parseInt(pid), 'SIGKILL'); } catch { /* already exited */ }
await new Promise(r => setTimeout(r, process.platform === 'win32' ? 2000 : 3000));
// SIGKILL any survivors (Unix only — Windows taskkill /F is already forceful)
if (process.platform !== 'win32') {
for (const pid of pids) {
try { process.kill(parseInt(pid), 0); process.kill(parseInt(pid), 'SIGKILL'); } catch { /* already exited */ }
}
await new Promise(r => setTimeout(r, 1000));
}
await new Promise(r => setTimeout(r, 1000));
return null;
}
}
@@ -633,13 +685,13 @@ export class GatewayManager extends EventEmitter {
// system-managed launchctl service) the WebSocket handshake will fail
// with "token mismatch" even though we pass --token on the CLI.
try {
syncGatewayTokenToConfig(gatewayToken);
await syncGatewayTokenToConfig(gatewayToken);
} catch (err) {
logger.warn('Failed to sync gateway token to openclaw.json:', err);
}
try {
syncBrowserConfigToOpenClaw();
await syncBrowserConfigToOpenClaw();
} catch (err) {
logger.warn('Failed to sync browser config to openclaw.json:', err);
}

View File

@@ -1,5 +1,18 @@
export let isQuitting = false;
/**
* Application quit state.
*
* Exposed as a function accessor (not a bare `export let`) so that every
* import site reads the *live* value. With `export let`, bundlers that
* compile to CJS may snapshot the variable at import time, causing
* `isQuitting` to stay `false` forever and preventing the window from
* closing on Windows/Linux.
*/
let _isQuitting = false;
export function isQuitting(): boolean {
return _isQuitting;
}
export function setQuitting(value = true): void {
isQuitting = value;
_isQuitting = value;
}

View File

@@ -17,8 +17,13 @@ import { ClawHubService } from '../gateway/clawhub';
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
import { isQuitting, setQuitting } from './app-state';
// Disable GPU acceleration for better compatibility
app.disableHardwareAcceleration();
// Disable GPU acceleration only on Linux where GPU driver issues are common.
// On Windows and macOS, hardware acceleration is essential for responsive UI;
// forcing CPU rendering makes the main thread compete with sync I/O and
// contributes to "Not Responding" hangs.
if (process.platform === 'linux') {
app.disableHardwareAcceleration();
}
// Global references
let mainWindow: BrowserWindow | null = null;
@@ -122,30 +127,28 @@ async function initialize(): Promise<void> {
// Create system tray
createTray(mainWindow);
// Override security headers ONLY for the OpenClaw Gateway Control UI
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const isGatewayUrl = details.url.includes('127.0.0.1:18789') || details.url.includes('localhost:18789');
if (!isGatewayUrl) {
callback({ responseHeaders: details.responseHeaders });
return;
}
const headers = { ...details.responseHeaders };
delete headers['X-Frame-Options'];
delete headers['x-frame-options'];
if (headers['Content-Security-Policy']) {
headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map(
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
);
}
if (headers['content-security-policy']) {
headers['content-security-policy'] = headers['content-security-policy'].map(
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
);
}
callback({ responseHeaders: headers });
});
// Override security headers ONLY for the OpenClaw Gateway Control UI.
// The URL filter ensures this callback only fires for gateway requests,
// avoiding unnecessary overhead on every other HTTP response.
session.defaultSession.webRequest.onHeadersReceived(
{ urls: ['http://127.0.0.1:18789/*', 'http://localhost:18789/*'] },
(details, callback) => {
const headers = { ...details.responseHeaders };
delete headers['X-Frame-Options'];
delete headers['x-frame-options'];
if (headers['Content-Security-Policy']) {
headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map(
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
);
}
if (headers['content-security-policy']) {
headers['content-security-policy'] = headers['content-security-policy'].map(
(csp) => csp.replace(/frame-ancestors\s+'none'/g, "frame-ancestors 'self' *")
);
}
callback({ responseHeaders: headers });
},
);
// Register IPC handlers
registerIpcHandlers(gatewayManager, clawHubService, mainWindow);
@@ -158,7 +161,7 @@ async function initialize(): Promise<void> {
// Minimize to tray on close instead of quitting (macOS & Windows)
mainWindow.on('close', (event) => {
if (!isQuitting) {
if (!isQuitting()) {
event.preventDefault();
mainWindow?.hide();
}
@@ -171,11 +174,9 @@ async function initialize(): Promise<void> {
// Repair any bootstrap files that only contain ClawX markers (no OpenClaw
// template content). This fixes a race condition where ensureClawXContext()
// previously created the file before the gateway could seed the full template.
try {
repairClawXOnlyBootstrapFiles();
} catch (error) {
void repairClawXOnlyBootstrapFiles().catch((error) => {
logger.warn('Failed to repair bootstrap files:', error);
}
});
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
try {

View File

@@ -3,7 +3,7 @@
* Registers all IPC handlers for main-renderer communication
*/
import { ipcMain, BrowserWindow, shell, dialog, app, nativeImage } from 'electron';
import { existsSync, copyFileSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, extname, basename } from 'node:path';
import crypto from 'node:crypto';
@@ -85,7 +85,7 @@ export function registerIpcHandlers(
registerClawHubHandlers(clawHubService);
// OpenClaw handlers
registerOpenClawHandlers();
registerOpenClawHandlers(gatewayManager);
// Provider handlers
registerProviderHandlers(gatewayManager);
@@ -135,7 +135,7 @@ function registerSkillConfigHandlers(): void {
apiKey?: string;
env?: Record<string, string>;
}) => {
return updateSkillConfig(params.skillKey, {
return await updateSkillConfig(params.skillKey, {
apiKey: params.apiKey,
env: params.env,
});
@@ -143,12 +143,12 @@ function registerSkillConfigHandlers(): void {
// Get skill config
ipcMain.handle('skill:getConfig', async (_, skillKey: string) => {
return getSkillConfig(skillKey);
return await getSkillConfig(skillKey);
});
// Get all skill configs
ipcMain.handle('skill:getAllConfigs', async () => {
return getAllSkillConfigs();
return await getAllSkillConfigs();
});
}
@@ -374,7 +374,7 @@ function registerLogHandlers(): void {
// Read log file content (last N lines)
ipcMain.handle('log:readFile', async (_, tailLines?: number) => {
return logger.readLogFile(tailLines);
return await logger.readLogFile(tailLines);
});
// Get log file path (so user can open in file explorer)
@@ -389,7 +389,7 @@ function registerLogHandlers(): void {
// List all log files
ipcMain.handle('log:listFiles', async () => {
return logger.listLogFiles();
return await logger.listLogFiles();
});
}
@@ -479,8 +479,10 @@ function registerGatewayHandlers(
const fileReferences: string[] = [];
if (params.media && params.media.length > 0) {
const fsP = await import('fs/promises');
for (const m of params.media) {
logger.info(`[chat:sendWithMedia] Processing file: ${m.fileName} (${m.mimeType}), path: ${m.filePath}, exists: ${existsSync(m.filePath)}, isVision: ${VISION_MIME_TYPES.has(m.mimeType)}`);
const exists = await fsP.access(m.filePath).then(() => true, () => false);
logger.info(`[chat:sendWithMedia] Processing file: ${m.fileName} (${m.mimeType}), path: ${m.filePath}, exists: ${exists}, isVision: ${VISION_MIME_TYPES.has(m.mimeType)}`);
// Always add file path reference so the model can access it via tools
fileReferences.push(
@@ -491,7 +493,7 @@ function registerGatewayHandlers(
// Send as base64 attachment in the format the Gateway expects:
// { content: base64String, mimeType: string, fileName?: string }
// The Gateway normalizer looks for `a.content` (NOT `a.source.data`).
const fileBuffer = readFileSync(m.filePath);
const fileBuffer = await fsP.readFile(m.filePath);
const base64Data = fileBuffer.toString('base64');
logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
imageAttachments.push({
@@ -605,7 +607,7 @@ function registerGatewayHandlers(
* OpenClaw-related IPC handlers
* For checking package status and channel configuration
*/
function registerOpenClawHandlers(): void {
function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
// Get OpenClaw package status
ipcMain.handle('openclaw:status', () => {
@@ -664,7 +666,13 @@ function registerOpenClawHandlers(): void {
ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record<string, unknown>) => {
try {
logger.info('channel:saveConfig', { channelType, keys: Object.keys(config || {}) });
saveChannelConfig(channelType, config);
await saveChannelConfig(channelType, config);
// Debounced restart so the gateway picks up the new channel config.
// The gateway watches openclaw.json, but a restart ensures a clean
// start for newly-added channels. Using debouncedRestart() here
// instead of an explicit restart on the frontend side means that
// rapid config changes (e.g. setup wizard) coalesce into one restart.
gatewayManager.debouncedRestart();
return { success: true };
} catch (error) {
console.error('Failed to save channel config:', error);
@@ -675,7 +683,7 @@ function registerOpenClawHandlers(): void {
// Get channel configuration
ipcMain.handle('channel:getConfig', async (_, channelType: string) => {
try {
const config = getChannelConfig(channelType);
const config = await getChannelConfig(channelType);
return { success: true, config };
} catch (error) {
console.error('Failed to get channel config:', error);
@@ -686,7 +694,7 @@ function registerOpenClawHandlers(): void {
// Get channel form values (reverse-transformed for UI pre-fill)
ipcMain.handle('channel:getFormValues', async (_, channelType: string) => {
try {
const values = getChannelFormValues(channelType);
const values = await getChannelFormValues(channelType);
return { success: true, values };
} catch (error) {
console.error('Failed to get channel form values:', error);
@@ -697,7 +705,7 @@ function registerOpenClawHandlers(): void {
// Delete channel configuration
ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => {
try {
deleteChannelConfig(channelType);
await deleteChannelConfig(channelType);
return { success: true };
} catch (error) {
console.error('Failed to delete channel config:', error);
@@ -708,7 +716,7 @@ function registerOpenClawHandlers(): void {
// List configured channels
ipcMain.handle('channel:listConfigured', async () => {
try {
const channels = listConfiguredChannels();
const channels = await listConfiguredChannels();
return { success: true, channels };
} catch (error) {
console.error('Failed to list channels:', error);
@@ -719,7 +727,7 @@ function registerOpenClawHandlers(): void {
// Enable or disable a channel
ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => {
try {
setChannelEnabled(channelType, enabled);
await setChannelEnabled(channelType, enabled);
return { success: true };
} catch (error) {
console.error('Failed to set channel enabled:', error);
@@ -838,10 +846,8 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void {
function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Listen for OAuth success to automatically restart the Gateway with new tokens/configs
deviceOAuthManager.on('oauth:success', (providerType) => {
logger.info(`[IPC] Restarting Gateway after ${providerType} OAuth success...`);
void gatewayManager.restart().catch(err => {
logger.error('Failed to restart Gateway after OAuth success:', err);
});
logger.info(`[IPC] Scheduling Gateway restart after ${providerType} OAuth success...`);
gatewayManager.debouncedRestart();
});
// Get all providers with key info
@@ -871,7 +877,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// Also write to OpenClaw auth-profiles.json so the gateway can use it
try {
saveProviderKeyToOpenClaw(ock, trimmedKey);
await saveProviderKeyToOpenClaw(ock, trimmedKey);
} catch (err) {
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
}
@@ -884,7 +890,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
if (api) {
syncProviderConfigToOpenClaw(ock, config.model, {
await syncProviderConfigToOpenClaw(ock, config.model, {
baseUrl: config.baseUrl || meta?.baseUrl,
api,
apiKeyEnv: meta?.apiKeyEnv,
@@ -897,7 +903,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
: await getApiKey(config.id);
if (resolvedKey && config.baseUrl) {
const modelId = config.model;
updateAgentModelProvider(ock, {
await updateAgentModelProvider(ock, {
baseUrl: config.baseUrl,
api: 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
@@ -906,11 +912,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
}
}
// Restart Gateway so it picks up the new config and env vars
logger.info(`Restarting Gateway after saving provider "${ock}" config`);
void gatewayManager.restart().catch((err) => {
logger.warn('Gateway restart after provider save failed:', err);
});
// Debounced restart so the gateway picks up new config/env vars.
// Multiple rapid provider saves (e.g. during setup) are coalesced.
logger.info(`Scheduling Gateway restart after saving provider "${ock}" config`);
gatewayManager.debouncedRestart();
}
} catch (err) {
console.warn('Failed to sync openclaw provider config:', err);
@@ -932,13 +937,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
if (existing?.type) {
try {
const ock = getOpenClawProviderKey(existing.type, providerId);
removeProviderFromOpenClaw(ock);
await removeProviderFromOpenClaw(ock);
// Restart Gateway so it no longer loads the deleted provider's plugin/config
logger.info(`Restarting Gateway after deleting provider "${ock}"`);
void gatewayManager.restart().catch((err) => {
logger.warn('Gateway restart after provider delete failed:', err);
});
// Debounced restart so the gateway stops loading the deleted provider.
logger.info(`Scheduling Gateway restart after deleting provider "${ock}"`);
gatewayManager.debouncedRestart();
} catch (err) {
console.warn('Failed to completely remove provider from OpenClaw:', err);
}
@@ -960,7 +963,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
const providerType = provider?.type || providerId;
const ock = getOpenClawProviderKey(providerType, providerId);
try {
saveProviderKeyToOpenClaw(ock, apiKey);
await saveProviderKeyToOpenClaw(ock, apiKey);
} catch (err) {
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
}
@@ -1003,10 +1006,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await storeApiKey(providerId, trimmedKey);
saveProviderKeyToOpenClaw(ock, trimmedKey);
await saveProviderKeyToOpenClaw(ock, trimmedKey);
} else {
await deleteApiKey(providerId);
removeProviderFromOpenClaw(ock);
await removeProviderFromOpenClaw(ock);
}
}
@@ -1016,7 +1019,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api;
if (api) {
syncProviderConfigToOpenClaw(ock, nextConfig.model, {
await syncProviderConfigToOpenClaw(ock, nextConfig.model, {
baseUrl: nextConfig.baseUrl || meta?.baseUrl,
api,
apiKeyEnv: meta?.apiKeyEnv,
@@ -1029,7 +1032,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
: await getApiKey(providerId);
if (resolvedKey && nextConfig.baseUrl) {
const modelId = nextConfig.model;
updateAgentModelProvider(ock, {
await updateAgentModelProvider(ock, {
baseUrl: nextConfig.baseUrl,
api: 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
@@ -1046,20 +1049,18 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
? `${ock}/${nextConfig.model}`
: undefined;
if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') {
setOpenClawDefaultModel(nextConfig.type, modelOverride);
await setOpenClawDefaultModel(nextConfig.type, modelOverride);
} else {
setOpenClawDefaultModelWithOverride(ock, modelOverride, {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: nextConfig.baseUrl,
api: 'openai-completions',
});
}
}
// Restart Gateway so it picks up the new config and env vars
logger.info(`Restarting Gateway after updating provider "${ock}" config`);
void gatewayManager.restart().catch((err) => {
logger.warn('Gateway restart after provider update failed:', err);
});
// Debounced restart so the gateway picks up updated config/env vars.
logger.info(`Scheduling Gateway restart after updating provider "${ock}" config`);
gatewayManager.debouncedRestart();
} catch (err) {
console.warn('Failed to sync openclaw config after provider update:', err);
}
@@ -1071,10 +1072,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
await saveProvider(existing);
if (previousKey) {
await storeApiKey(providerId, previousKey);
saveProviderKeyToOpenClaw(previousOck, previousKey);
await saveProviderKeyToOpenClaw(previousOck, previousKey);
} else {
await deleteApiKey(providerId);
removeProviderFromOpenClaw(previousOck);
await removeProviderFromOpenClaw(previousOck);
}
} catch (rollbackError) {
console.warn('Failed to rollback provider updateWithKey:', rollbackError);
@@ -1096,7 +1097,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
const ock = getOpenClawProviderKey(providerType, providerId);
try {
if (ock) {
removeProviderFromOpenClaw(ock);
await removeProviderFromOpenClaw(ock);
}
} catch (err) {
console.warn('Failed to completely remove provider from OpenClaw:', err);
@@ -1144,17 +1145,17 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
: undefined;
if (provider.type === 'custom' || provider.type === 'ollama') {
setOpenClawDefaultModelWithOverride(ock, modelOverride, {
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
baseUrl: provider.baseUrl,
api: 'openai-completions',
});
} else {
setOpenClawDefaultModel(provider.type, modelOverride);
await setOpenClawDefaultModel(provider.type, modelOverride);
}
// Keep auth-profiles in sync with the default provider instance.
if (providerKey) {
saveProviderKeyToOpenClaw(ock, providerKey);
await saveProviderKeyToOpenClaw(ock, providerKey);
}
} else {
// OAuth providers (minimax-portal, minimax-portal-cn, qwen-portal)
@@ -1177,7 +1178,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
? 'minimax-portal'
: provider.type;
setOpenClawDefaultModelWithOverride(targetProviderKey, undefined, {
await setOpenClawDefaultModelWithOverride(targetProviderKey, undefined, {
baseUrl,
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
@@ -1191,7 +1192,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
// authHeader immediately, without waiting for Gateway to sync openclaw.json.
try {
const defaultModelId = provider.model?.split('/').pop();
updateAgentModelProvider(targetProviderKey, {
await updateAgentModelProvider(targetProviderKey, {
baseUrl,
api,
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
@@ -1210,7 +1211,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
provider.baseUrl
) {
const modelId = provider.model;
updateAgentModelProvider(ock, {
await updateAgentModelProvider(ock, {
baseUrl: provider.baseUrl,
api: 'openai-completions',
models: modelId ? [{ id: modelId, name: modelId }] : [],
@@ -1218,12 +1219,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
});
}
// Restart Gateway so it picks up the new config and env vars.
// Debounced restart so the gateway picks up the new default provider.
if (gatewayManager.isConnected()) {
logger.info(`Restarting Gateway after provider switch to "${ock}"`);
void gatewayManager.restart().catch((err) => {
logger.warn('Gateway restart after provider switch failed:', err);
});
logger.info(`Scheduling Gateway restart after provider switch to "${ock}"`);
gatewayManager.debouncedRestart();
}
} catch (err) {
console.warn('Failed to set OpenClaw default model:', err);
@@ -1757,7 +1756,7 @@ const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
* longer side so the image is never squished). The frontend handles
* square cropping via CSS object-fit: cover.
*/
function generateImagePreview(filePath: string, mimeType: string): string | null {
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
try {
const img = nativeImage.createFromPath(filePath);
if (img.isEmpty()) return null;
@@ -1770,8 +1769,9 @@ function generateImagePreview(filePath: string, mimeType: string): string | null
: img.resize({ height: maxDim }); // portrait → constrain height
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
}
// Small image — use original
const buf = readFileSync(filePath);
// Small image — use original (async read to avoid blocking)
const { readFile: readFileAsync } = await import('fs/promises');
const buf = await readFileAsync(filePath);
return `data:${mimeType};base64,${buf.toString('base64')}`;
} catch {
return null;
@@ -1785,26 +1785,27 @@ function generateImagePreview(filePath: string, mimeType: string): string | null
function registerFileHandlers(): void {
// Stage files from real disk paths (used with dialog:open)
ipcMain.handle('file:stage', async (_, filePaths: string[]) => {
mkdirSync(OUTBOUND_DIR, { recursive: true });
const fsP = await import('fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const results = [];
for (const filePath of filePaths) {
const id = crypto.randomUUID();
const ext = extname(filePath);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
copyFileSync(filePath, stagedPath);
await fsP.copyFile(filePath, stagedPath);
const stat = statSync(stagedPath);
const s = await fsP.stat(stagedPath);
const mimeType = getMimeType(ext);
const fileName = basename(filePath);
// Generate preview for images
let preview: string | null = null;
if (mimeType.startsWith('image/')) {
preview = generateImagePreview(stagedPath, mimeType);
preview = await generateImagePreview(stagedPath, mimeType);
}
results.push({ id, fileName, mimeType, fileSize: stat.size, stagedPath, preview });
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
}
return results;
});
@@ -1815,13 +1816,14 @@ function registerFileHandlers(): void {
fileName: string;
mimeType: string;
}) => {
mkdirSync(OUTBOUND_DIR, { recursive: true });
const fsP = await import('fs/promises');
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
const id = crypto.randomUUID();
const ext = extname(payload.fileName) || mimeToExt(payload.mimeType);
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
const buffer = Buffer.from(payload.base64, 'base64');
writeFileSync(stagedPath, buffer);
await fsP.writeFile(stagedPath, buffer);
const mimeType = payload.mimeType || getMimeType(ext);
const fileSize = buffer.length;
@@ -1829,7 +1831,7 @@ function registerFileHandlers(): void {
// Generate preview for images
let preview: string | null = null;
if (mimeType.startsWith('image/')) {
preview = generateImagePreview(stagedPath, mimeType);
preview = await generateImagePreview(stagedPath, mimeType);
}
return { id, fileName: payload.fileName, mimeType, fileSize, stagedPath, preview };
@@ -1856,11 +1858,17 @@ function registerFileHandlers(): void {
});
if (result.canceled || !result.filePath) return { success: false };
if (params.filePath && existsSync(params.filePath)) {
copyFileSync(params.filePath, result.filePath);
const fsP = await import('fs/promises');
if (params.filePath) {
try {
await fsP.access(params.filePath);
await fsP.copyFile(params.filePath, result.filePath);
} catch {
return { success: false, error: 'Source file not found' };
}
} else if (params.base64) {
const buffer = Buffer.from(params.base64, 'base64');
writeFileSync(result.filePath, buffer);
await fsP.writeFile(result.filePath, buffer);
} else {
return { success: false, error: 'No image data provided' };
}
@@ -1871,19 +1879,16 @@ function registerFileHandlers(): void {
});
ipcMain.handle('media:getThumbnails', async (_, paths: Array<{ filePath: string; mimeType: string }>) => {
const fsP = await import('fs/promises');
const results: Record<string, { preview: string | null; fileSize: number }> = {};
for (const { filePath, mimeType } of paths) {
try {
if (!existsSync(filePath)) {
results[filePath] = { preview: null, fileSize: 0 };
continue;
}
const stat = statSync(filePath);
const s = await fsP.stat(filePath);
let preview: string | null = null;
if (mimeType.startsWith('image/')) {
preview = generateImagePreview(filePath, mimeType);
preview = await generateImagePreview(filePath, mimeType);
}
results[filePath] = { preview, fileSize: stat.size };
results[filePath] = { preview, fileSize: s.size };
} catch {
results[filePath] = { preview: null, fileSize: 0 };
}

View File

@@ -1,8 +1,11 @@
/**
* Channel Configuration Utilities
* Manages channel configuration in OpenClaw config files
* Manages channel configuration in OpenClaw config files.
*
* All file I/O uses async fs/promises to avoid blocking the main thread.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, rmSync } from 'fs';
import { access, mkdir, readFile, writeFile, readdir, stat, rm } from 'fs/promises';
import { constants } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { getOpenClawResolvedDir } from './paths';
@@ -14,6 +17,14 @@ const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
const PLUGIN_CHANNELS = ['whatsapp'];
// ── Helpers ──────────────────────────────────────────────────────
async function fileExists(p: string): Promise<boolean> {
try { await access(p, constants.F_OK); return true; } catch { return false; }
}
// ── Types ────────────────────────────────────────────────────────
export interface ChannelConfigData {
enabled?: boolean;
[key: string]: unknown;
@@ -30,27 +41,23 @@ export interface OpenClawConfig {
[key: string]: unknown;
}
/**
* Ensure OpenClaw config directory exists
*/
function ensureConfigDir(): void {
if (!existsSync(OPENCLAW_DIR)) {
mkdirSync(OPENCLAW_DIR, { recursive: true });
// ── Config I/O ───────────────────────────────────────────────────
async function ensureConfigDir(): Promise<void> {
if (!(await fileExists(OPENCLAW_DIR))) {
await mkdir(OPENCLAW_DIR, { recursive: true });
}
}
/**
* Read OpenClaw configuration
*/
export function readOpenClawConfig(): OpenClawConfig {
ensureConfigDir();
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
await ensureConfigDir();
if (!existsSync(CONFIG_FILE)) {
if (!(await fileExists(CONFIG_FILE))) {
return {};
}
try {
const content = readFileSync(CONFIG_FILE, 'utf-8');
const content = await readFile(CONFIG_FILE, 'utf-8');
return JSON.parse(content) as OpenClawConfig;
} catch (error) {
logger.error('Failed to read OpenClaw config', error);
@@ -59,14 +66,11 @@ export function readOpenClawConfig(): OpenClawConfig {
}
}
/**
* Write OpenClaw configuration
*/
export function writeOpenClawConfig(config: OpenClawConfig): void {
ensureConfigDir();
export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void> {
await ensureConfigDir();
try {
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
} catch (error) {
logger.error('Failed to write OpenClaw config', error);
console.error('Failed to write OpenClaw config:', error);
@@ -74,16 +78,13 @@ export function writeOpenClawConfig(config: OpenClawConfig): void {
}
}
/**
* Save channel configuration
* @param channelType - The channel type (e.g., 'telegram', 'discord')
* @param config - The channel configuration object
*/
export function saveChannelConfig(
// ── Channel operations ───────────────────────────────────────────
export async function saveChannelConfig(
channelType: string,
config: ChannelConfigData
): void {
const currentConfig = readOpenClawConfig();
): Promise<void> {
const currentConfig = await readOpenClawConfig();
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
if (PLUGIN_CHANNELS.includes(channelType)) {
@@ -97,7 +98,7 @@ export function saveChannelConfig(
...currentConfig.plugins.entries[channelType],
enabled: config.enabled ?? true,
};
writeOpenClawConfig(currentConfig);
await writeOpenClawConfig(currentConfig);
logger.info('Plugin channel config saved', {
channelType,
configFile: CONFIG_FILE,
@@ -119,7 +120,6 @@ export function saveChannelConfig(
const { guildId, channelId, ...restConfig } = config;
transformedConfig = { ...restConfig };
// Add standard Discord config
transformedConfig.groupPolicy = 'allowlist';
transformedConfig.dm = { enabled: false };
transformedConfig.retry = {
@@ -129,21 +129,17 @@ export function saveChannelConfig(
jitter: 0.1,
};
// Build guilds structure
if (guildId && typeof guildId === 'string' && guildId.trim()) {
const guildConfig: Record<string, unknown> = {
users: ['*'],
requireMention: true,
};
// Add channels config
if (channelId && typeof channelId === 'string' && channelId.trim()) {
// Specific channel
guildConfig.channels = {
[channelId.trim()]: { allow: true, requireMention: true }
};
} else {
// All channels
guildConfig.channels = {
'*': { allow: true, requireMention: true }
};
@@ -166,8 +162,7 @@ export function saveChannelConfig(
.filter(u => u.length > 0);
if (users.length > 0) {
transformedConfig.allowFrom = users; // Use 'allowFrom' (correct key)
// transformedConfig.groupPolicy = 'allowlist'; // Default is allowlist
transformedConfig.allowFrom = users;
}
}
}
@@ -176,17 +171,16 @@ export function saveChannelConfig(
if (channelType === 'feishu') {
const existingConfig = currentConfig.channels[channelType] || {};
transformedConfig.dmPolicy = transformedConfig.dmPolicy ?? existingConfig.dmPolicy ?? 'open';
let allowFrom = transformedConfig.allowFrom ?? existingConfig.allowFrom ?? ['*'];
if (!Array.isArray(allowFrom)) {
allowFrom = [allowFrom];
}
// If dmPolicy is open, OpenClaw schema requires '*' in allowFrom
if (transformedConfig.dmPolicy === 'open' && !allowFrom.includes('*')) {
allowFrom = [...allowFrom, '*'];
}
transformedConfig.allowFrom = allowFrom;
}
@@ -197,7 +191,7 @@ export function saveChannelConfig(
enabled: transformedConfig.enabled ?? true,
};
writeOpenClawConfig(currentConfig);
await writeOpenClawConfig(currentConfig);
logger.info('Channel config saved', {
channelType,
configFile: CONFIG_FILE,
@@ -208,42 +202,26 @@ export function saveChannelConfig(
console.log(`Saved channel config for ${channelType}`);
}
/**
* Get channel configuration
* @param channelType - The channel type
*/
export function getChannelConfig(channelType: string): ChannelConfigData | undefined {
const config = readOpenClawConfig();
export async function getChannelConfig(channelType: string): Promise<ChannelConfigData | undefined> {
const config = await readOpenClawConfig();
return config.channels?.[channelType];
}
/**
* Get channel configuration as form-friendly values.
* Reverses the transformation done in saveChannelConfig so the
* values can be fed back into the UI form fields.
*
* @param channelType - The channel type
* @returns A flat Record<string, string> matching the form field keys, or undefined
*/
export function getChannelFormValues(channelType: string): Record<string, string> | undefined {
const saved = getChannelConfig(channelType);
export async function getChannelFormValues(channelType: string): Promise<Record<string, string> | undefined> {
const saved = await getChannelConfig(channelType);
if (!saved) return undefined;
const values: Record<string, string> = {};
if (channelType === 'discord') {
// token is stored at top level
if (saved.token && typeof saved.token === 'string') {
values.token = saved.token;
}
// Extract guildId and channelId from the nested guilds structure
const guilds = saved.guilds as Record<string, Record<string, unknown>> | undefined;
if (guilds) {
const guildIds = Object.keys(guilds);
if (guildIds.length > 0) {
values.guildId = guildIds[0];
const guildConfig = guilds[guildIds[0]];
const channels = guildConfig?.channels as Record<string, unknown> | undefined;
if (channels) {
@@ -255,19 +233,15 @@ export function getChannelFormValues(channelType: string): Record<string, string
}
}
} else if (channelType === 'telegram') {
// Special handling for Telegram: convert allowFrom array to allowedUsers string
if (Array.isArray(saved.allowFrom)) {
values.allowedUsers = saved.allowFrom.join(', ');
}
// Also extract other string values
for (const [key, value] of Object.entries(saved)) {
if (typeof value === 'string' && key !== 'enabled') {
values[key] = value;
}
}
} else {
// For other channel types, extract all string values directly
for (const [key, value] of Object.entries(saved)) {
if (typeof value === 'string' && key !== 'enabled') {
values[key] = value;
@@ -278,31 +252,23 @@ export function getChannelFormValues(channelType: string): Record<string, string
return Object.keys(values).length > 0 ? values : undefined;
}
/**
* Delete channel configuration
* @param channelType - The channel type
*/
export function deleteChannelConfig(channelType: string): void {
const currentConfig = readOpenClawConfig();
export async function deleteChannelConfig(channelType: string): Promise<void> {
const currentConfig = await readOpenClawConfig();
if (currentConfig.channels?.[channelType]) {
delete currentConfig.channels[channelType];
writeOpenClawConfig(currentConfig);
await writeOpenClawConfig(currentConfig);
console.log(`Deleted channel config for ${channelType}`);
} else if (PLUGIN_CHANNELS.includes(channelType)) {
// Handle plugin channels (like whatsapp)
if (currentConfig.plugins?.entries?.[channelType]) {
delete currentConfig.plugins.entries[channelType];
// Cleanup empty objects
if (Object.keys(currentConfig.plugins.entries).length === 0) {
delete currentConfig.plugins.entries;
}
if (currentConfig.plugins && Object.keys(currentConfig.plugins).length === 0) {
delete currentConfig.plugins;
}
writeOpenClawConfig(currentConfig);
await writeOpenClawConfig(currentConfig);
console.log(`Deleted plugin channel config for ${channelType}`);
}
}
@@ -310,10 +276,9 @@ export function deleteChannelConfig(channelType: string): void {
// Special handling for WhatsApp credentials
if (channelType === 'whatsapp') {
try {
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
if (existsSync(whatsappDir)) {
rmSync(whatsappDir, { recursive: true, force: true });
if (await fileExists(whatsappDir)) {
await rm(whatsappDir, { recursive: true, force: true });
console.log('Deleted WhatsApp credentials directory');
}
} catch (error) {
@@ -322,11 +287,8 @@ export function deleteChannelConfig(channelType: string): void {
}
}
/**
* List all configured channels
*/
export function listConfiguredChannels(): string[] {
const config = readOpenClawConfig();
export async function listConfiguredChannels(): Promise<string[]> {
const config = await readOpenClawConfig();
const channels: string[] = [];
if (config.channels) {
@@ -338,14 +300,17 @@ export function listConfiguredChannels(): string[] {
// Check for WhatsApp credentials directory
try {
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
if (existsSync(whatsappDir)) {
const entries = readdirSync(whatsappDir);
// Check if there's at least one directory (session)
const hasSession = entries.some((entry: string) => {
try {
return statSync(join(whatsappDir, entry)).isDirectory();
} catch { return false; }
});
if (await fileExists(whatsappDir)) {
const entries = await readdir(whatsappDir);
const hasSession = await (async () => {
for (const entry of entries) {
try {
const s = await stat(join(whatsappDir, entry));
if (s.isDirectory()) return true;
} catch { /* ignore */ }
}
return false;
})();
if (hasSession && !channels.includes('whatsapp')) {
channels.push('whatsapp');
@@ -358,42 +323,28 @@ export function listConfiguredChannels(): string[] {
return channels;
}
/**
* Enable or disable a channel
*/
export function setChannelEnabled(channelType: string, enabled: boolean): void {
const currentConfig = readOpenClawConfig();
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
const currentConfig = await readOpenClawConfig();
// Plugin-based channels go under plugins.entries
if (PLUGIN_CHANNELS.includes(channelType)) {
if (!currentConfig.plugins) {
currentConfig.plugins = {};
}
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
if (!currentConfig.plugins.entries[channelType]) {
currentConfig.plugins.entries[channelType] = {};
}
if (!currentConfig.plugins) currentConfig.plugins = {};
if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {};
if (!currentConfig.plugins.entries[channelType]) currentConfig.plugins.entries[channelType] = {};
currentConfig.plugins.entries[channelType].enabled = enabled;
writeOpenClawConfig(currentConfig);
await writeOpenClawConfig(currentConfig);
console.log(`Set plugin channel ${channelType} enabled: ${enabled}`);
return;
}
if (!currentConfig.channels) {
currentConfig.channels = {};
}
if (!currentConfig.channels[channelType]) {
currentConfig.channels[channelType] = {};
}
if (!currentConfig.channels) currentConfig.channels = {};
if (!currentConfig.channels[channelType]) currentConfig.channels[channelType] = {};
currentConfig.channels[channelType].enabled = enabled;
writeOpenClawConfig(currentConfig);
await writeOpenClawConfig(currentConfig);
console.log(`Set channel ${channelType} enabled: ${enabled}`);
}
// ── Validation ───────────────────────────────────────────────────
export interface ValidationResult {
valid: boolean;
errors: string[];
@@ -404,17 +355,9 @@ export interface CredentialValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
/** Extra info returned from the API (e.g. bot username, guild name) */
details?: Record<string, string>;
}
/**
* Validate channel credentials by calling the actual service APIs
* This validates the raw config values BEFORE saving them.
*
* @param channelType - The channel type (e.g., 'discord', 'telegram')
* @param config - The raw config values from the form
*/
export async function validateChannelCredentials(
channelType: string,
config: Record<string, string>
@@ -425,14 +368,10 @@ export async function validateChannelCredentials(
case 'telegram':
return validateTelegramCredentials(config);
default:
// For channels without specific validation, just check required fields are present
return { valid: true, errors: [], warnings: ['No online validation available for this channel type.'] };
}
}
/**
* Validate Discord bot token and optional guild/channel IDs
*/
async function validateDiscordCredentials(
config: Record<string, string>
): Promise<CredentialValidationResult> {
@@ -443,12 +382,10 @@ async function validateDiscordCredentials(
return { valid: false, errors: ['Bot token is required'], warnings: [] };
}
// 1) Validate bot token by calling GET /users/@me
try {
const meResponse = await fetch('https://discord.com/api/v10/users/@me', {
headers: { Authorization: `Bot ${token}` },
});
if (!meResponse.ok) {
if (meResponse.status === 401) {
return { valid: false, errors: ['Invalid bot token. Please check and try again.'], warnings: [] };
@@ -457,38 +394,25 @@ async function validateDiscordCredentials(
const msg = (errorData as { message?: string }).message || `Discord API error: ${meResponse.status}`;
return { valid: false, errors: [msg], warnings: [] };
}
const meData = (await meResponse.json()) as { username?: string; id?: string; bot?: boolean };
if (!meData.bot) {
return {
valid: false,
errors: ['The provided token belongs to a user account, not a bot. Please use a bot token.'],
warnings: [],
};
return { valid: false, errors: ['The provided token belongs to a user account, not a bot. Please use a bot token.'], warnings: [] };
}
result.details!.botUsername = meData.username || 'Unknown';
result.details!.botId = meData.id || '';
} catch (error) {
return {
valid: false,
errors: [`Connection error when validating bot token: ${error instanceof Error ? error.message : String(error)}`],
warnings: [],
};
return { valid: false, errors: [`Connection error when validating bot token: ${error instanceof Error ? error.message : String(error)}`], warnings: [] };
}
// 2) Validate guild ID (optional)
const guildId = config.guildId?.trim();
if (guildId) {
try {
const guildResponse = await fetch(`https://discord.com/api/v10/guilds/${guildId}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!guildResponse.ok) {
if (guildResponse.status === 403 || guildResponse.status === 404) {
result.errors.push(
`Cannot access guild (server) with ID "${guildId}". Make sure the bot has been invited to this server.`
);
result.errors.push(`Cannot access guild (server) with ID "${guildId}". Make sure the bot has been invited to this server.`);
result.valid = false;
} else {
result.errors.push(`Failed to verify guild ID: Discord API returned ${guildResponse.status}`);
@@ -503,19 +427,15 @@ async function validateDiscordCredentials(
}
}
// 3) Validate channel ID (optional)
const channelId = config.channelId?.trim();
if (channelId) {
try {
const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!channelResponse.ok) {
if (channelResponse.status === 403 || channelResponse.status === 404) {
result.errors.push(
`Cannot access channel with ID "${channelId}". Make sure the bot has permission to view this channel.`
);
result.errors.push(`Cannot access channel with ID "${channelId}". Make sure the bot has permission to view this channel.`);
result.valid = false;
} else {
result.errors.push(`Failed to verify channel ID: Discord API returned ${channelResponse.status}`);
@@ -524,12 +444,8 @@ async function validateDiscordCredentials(
} else {
const channelData = (await channelResponse.json()) as { name?: string; guild_id?: string };
result.details!.channelName = channelData.name || 'Unknown';
// Cross-check: if both guild and channel are provided, make sure channel belongs to the guild
if (guildId && channelData.guild_id && channelData.guild_id !== guildId) {
result.errors.push(
`Channel "${channelData.name}" does not belong to the specified guild. It belongs to a different server.`
);
result.errors.push(`Channel "${channelData.name}" does not belong to the specified guild. It belongs to a different server.`);
result.valid = false;
}
}
@@ -541,80 +457,52 @@ async function validateDiscordCredentials(
return result;
}
/**
* Validate Telegram bot token
*/
async function validateTelegramCredentials(
config: Record<string, string>
): Promise<CredentialValidationResult> {
const botToken = config.botToken?.trim();
const allowedUsers = config.allowedUsers?.trim();
if (!botToken) {
return { valid: false, errors: ['Bot token is required'], warnings: [] };
}
if (!allowedUsers) {
return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] };
}
if (!botToken) return { valid: false, errors: ['Bot token is required'], warnings: [] };
if (!allowedUsers) return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] };
try {
const response = await fetch(`https://api.telegram.org/bot${botToken}/getMe`);
const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } };
if (data.ok) {
return {
valid: true,
errors: [],
warnings: [],
details: { botUsername: data.result?.username || 'Unknown' },
};
return { valid: true, errors: [], warnings: [], details: { botUsername: data.result?.username || 'Unknown' } };
}
return {
valid: false,
errors: [data.description || 'Invalid bot token'],
warnings: [],
};
return { valid: false, errors: [data.description || 'Invalid bot token'], warnings: [] };
} catch (error) {
return {
valid: false,
errors: [`Connection error: ${error instanceof Error ? error.message : String(error)}`],
warnings: [],
};
return { valid: false, errors: [`Connection error: ${error instanceof Error ? error.message : String(error)}`], warnings: [] };
}
}
/**
* Validate channel configuration using OpenClaw doctor
*/
export async function validateChannelConfig(channelType: string): Promise<ValidationResult> {
const { execSync } = await import('child_process');
const { exec } = await import('child_process');
const result: ValidationResult = {
valid: true,
errors: [],
warnings: [],
};
const result: ValidationResult = { valid: true, errors: [], warnings: [] };
try {
// Get OpenClaw path
const openclawPath = getOpenClawResolvedDir();
// Run openclaw doctor command to validate config
const output = execSync(
`node openclaw.mjs doctor --json 2>&1`,
{
cwd: openclawPath,
encoding: 'utf-8',
timeout: 30000,
}
);
// Run openclaw doctor command to validate config (async to avoid
// blocking the main thread).
const output = await new Promise<string>((resolve, reject) => {
exec(
`node openclaw.mjs doctor --json 2>&1`,
{
cwd: openclawPath,
encoding: 'utf-8',
timeout: 30000,
},
(err, stdout) => {
if (err) reject(err);
else resolve(stdout);
},
);
});
// Parse output for errors related to the channel
const lines = output.split('\n');
for (const line of lines) {
const lowerLine = line.toLowerCase();
@@ -629,8 +517,7 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
}
}
// If no specific errors found, check if config exists and is valid
const config = readOpenClawConfig();
const config = await readOpenClawConfig();
if (!config.channels?.[channelType]) {
result.errors.push(`Channel ${channelType} is not configured`);
result.valid = false;
@@ -638,7 +525,6 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
result.warnings.push(`Channel ${channelType} is disabled`);
}
// Channel-specific validation
if (channelType === 'discord') {
const discordConfig = config.channels?.discord;
if (!discordConfig?.token) {
@@ -651,7 +537,6 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
result.errors.push('Telegram: Bot token is required');
result.valid = false;
}
// Check allowed users (stored as allowFrom array)
const allowedUsers = telegramConfig?.allowFrom as string[] | undefined;
if (!allowedUsers || allowedUsers.length === 0) {
result.errors.push('Telegram: Allowed User IDs are required');
@@ -666,7 +551,6 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Check for config errors in the error message
if (errorMessage.includes('Unrecognized key') || errorMessage.includes('invalid config')) {
result.errors.push(errorMessage);
result.valid = false;
@@ -674,11 +558,8 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
result.errors.push('OpenClaw not found. Please ensure OpenClaw is installed.');
result.valid = false;
} else {
// Doctor command might fail but config could still be valid
// Just log it and do basic validation
console.warn('Doctor command failed:', errorMessage);
const config = readOpenClawConfig();
const config = await readOpenClawConfig();
if (config.channels?.[channelType]) {
result.valid = true;
} else {
@@ -689,4 +570,4 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
}
return result;
}
}

View File

@@ -4,9 +4,13 @@
* OpenClaw Gateway 2026.2.15+ requires a signed device identity in the
* connect handshake to grant scopes (operator.read, operator.write, etc.).
* Without a device, the gateway strips all requested scopes.
*
* All file I/O uses async fs/promises to avoid blocking the main thread.
* Key generation (Ed25519) uses the async crypto.generateKeyPair API.
*/
import crypto from 'crypto';
import fs from 'fs';
import { access, readFile, writeFile, mkdir, chmod } from 'fs/promises';
import { constants } from 'fs';
import path from 'path';
export interface DeviceIdentity {
@@ -49,8 +53,21 @@ function fingerprintPublicKey(publicKeyPem: string): string {
return crypto.createHash('sha256').update(raw).digest('hex');
}
function generateIdentity(): DeviceIdentity {
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
/** Non-throwing async existence check. */
async function fileExists(p: string): Promise<boolean> {
try { await access(p, constants.F_OK); return true; } catch { return false; }
}
/** Generate a new Ed25519 identity (async key generation). */
async function generateIdentity(): Promise<DeviceIdentity> {
const { publicKey, privateKey } = await new Promise<crypto.KeyPairKeyObjectResult>(
(resolve, reject) => {
crypto.generateKeyPair('ed25519', (err, publicKey, privateKey) => {
if (err) reject(err);
else resolve({ publicKey, privateKey });
});
},
);
const publicKeyPem = (publicKey.export({ type: 'spki', format: 'pem' }) as Buffer).toString();
const privateKeyPem = (privateKey.export({ type: 'pkcs8', format: 'pem' }) as Buffer).toString();
return {
@@ -63,11 +80,13 @@ function generateIdentity(): DeviceIdentity {
/**
* Load device identity from disk, or create and persist a new one.
* The identity file is stored at `filePath` with mode 0o600.
*
* Fully async — no synchronous file I/O or crypto.
*/
export function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity {
export async function loadOrCreateDeviceIdentity(filePath: string): Promise<DeviceIdentity> {
try {
if (fs.existsSync(filePath)) {
const raw = fs.readFileSync(filePath, 'utf8');
if (await fileExists(filePath)) {
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (
parsed?.version === 1 &&
@@ -78,7 +97,7 @@ export function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity {
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
if (derivedId && derivedId !== parsed.deviceId) {
const updated = { ...parsed, deviceId: derivedId };
fs.writeFileSync(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
await writeFile(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
}
return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
@@ -88,12 +107,12 @@ export function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity {
// fall through to create a new identity
}
const identity = generateIdentity();
const identity = await generateIdentity();
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
if (!(await fileExists(dir))) await mkdir(dir, { recursive: true });
const stored = { version: 1, ...identity, createdAtMs: Date.now() };
fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
try { fs.chmodSync(filePath, 0o600); } catch { /* ignore */ }
await writeFile(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
try { await chmod(filePath, 0o600); } catch { /* ignore */ }
return identity;
}

View File

@@ -204,7 +204,7 @@ class DeviceOAuthManager extends EventEmitter {
// so OpenClaw's gateway auto-refresher knows how to find it.
try {
const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType;
saveOAuthTokenToOpenClaw(tokenProviderId, {
await saveOAuthTokenToOpenClaw(tokenProviderId, {
access: token.access,
refresh: token.refresh,
expires: token.expires,
@@ -230,7 +230,7 @@ class DeviceOAuthManager extends EventEmitter {
try {
const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType;
setOpenClawDefaultModelWithOverride(tokenProviderId, undefined, {
await setOpenClawDefaultModelWithOverride(tokenProviderId, undefined, {
baseUrl,
api: token.api,
// Tells OpenClaw's anthropic adapter to use `Authorization: Bearer` instead of `x-api-key`

View File

@@ -1,10 +1,16 @@
/**
* Logger Utility
* Centralized logging with levels, file output, and log retrieval for UI
* Centralized logging with levels, file output, and log retrieval for UI.
*
* File writes use an async buffered writer so that high-frequency logging
* (e.g. during gateway startup) never blocks the Electron main thread.
* Only the final `process.on('exit')` handler uses synchronous I/O to
* guarantee the last few messages are flushed before the process exits.
*/
import { app } from 'electron';
import { join } from 'path';
import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, statSync } from 'fs';
import { existsSync, mkdirSync, appendFileSync } from 'fs';
import { appendFile, readFile, readdir, stat } from 'fs/promises';
/**
* Log levels
@@ -19,7 +25,11 @@ export enum LogLevel {
/**
* Current log level (can be changed at runtime)
*/
let currentLevel = LogLevel.DEBUG; // Default to DEBUG for better diagnostics
// Default to INFO in packaged builds to reduce sync-like overhead from
// high-volume DEBUG logging. In dev mode, keep DEBUG for diagnostics.
// Note: app.isPackaged may not be available before app.isReady(), but the
// logger is initialised after that point so this is safe.
let currentLevel = LogLevel.DEBUG;
/**
* Log file path
@@ -33,11 +43,58 @@ let logDir: string | null = null;
const RING_BUFFER_SIZE = 500;
const recentLogs: string[] = [];
// ── Async write buffer ───────────────────────────────────────────
/** Pending log lines waiting to be flushed to disk. */
let writeBuffer: string[] = [];
/** Timer for the next scheduled flush. */
let flushTimer: NodeJS.Timeout | null = null;
/** Whether a flush is currently in progress. */
let flushing = false;
const FLUSH_INTERVAL_MS = 500;
const FLUSH_SIZE_THRESHOLD = 20;
async function flushBuffer(): Promise<void> {
if (flushing || writeBuffer.length === 0 || !logFilePath) return;
flushing = true;
const batch = writeBuffer.join('');
writeBuffer = [];
try {
await appendFile(logFilePath, batch);
} catch {
// Silently fail if we can't write to file
} finally {
flushing = false;
}
}
/** Synchronous flush for the `exit` handler — guaranteed to write. */
function flushBufferSync(): void {
if (writeBuffer.length === 0 || !logFilePath) return;
try {
appendFileSync(logFilePath, writeBuffer.join(''));
} catch {
// Silently fail
}
writeBuffer = [];
}
// Ensure all buffered data reaches disk before the process exits.
process.on('exit', flushBufferSync);
// ── Initialisation ───────────────────────────────────────────────
/**
* Initialize logger — safe to call before app.isReady()
*/
export function initLogger(): void {
try {
// In production, default to INFO to reduce log volume and overhead.
if (app.isPackaged && currentLevel < LogLevel.INFO) {
currentLevel = LogLevel.INFO;
}
logDir = join(app.getPath('userData'), 'logs');
if (!existsSync(logDir)) {
@@ -47,7 +104,7 @@ export function initLogger(): void {
const timestamp = new Date().toISOString().split('T')[0];
logFilePath = join(logDir, `clawx-${timestamp}.log`);
// Write a separator for new session
// Write a separator for new session (sync is OK — happens once at startup)
const sessionHeader = `\n${'='.repeat(80)}\n[${new Date().toISOString()}] === ClawX Session Start (v${app.getVersion()}) ===\n${'='.repeat(80)}\n`;
appendFileSync(logFilePath, sessionHeader);
} catch (error) {
@@ -55,30 +112,22 @@ export function initLogger(): void {
}
}
/**
* Set log level
*/
// ── Level / path accessors ───────────────────────────────────────
export function setLogLevel(level: LogLevel): void {
currentLevel = level;
}
/**
* Get log file directory path
*/
export function getLogDir(): string | null {
return logDir;
}
/**
* Get current log file path
*/
export function getLogFilePath(): string | null {
return logFilePath;
}
/**
* Format log message
*/
// ── Formatting ───────────────────────────────────────────────────
function formatMessage(level: string, message: string, ...args: unknown[]): string {
const timestamp = new Date().toISOString();
const formattedArgs = args.length > 0 ? ' ' + args.map(arg => {
@@ -98,29 +147,36 @@ function formatMessage(level: string, message: string, ...args: unknown[]): stri
return `[${timestamp}] [${level.padEnd(5)}] ${message}${formattedArgs}`;
}
// ── Core write ───────────────────────────────────────────────────
/**
* Write to log file and ring buffer
* Write to ring buffer + schedule an async flush to disk.
*/
function writeLog(formatted: string): void {
// Ring buffer
// Ring buffer (always synchronous — in-memory only)
recentLogs.push(formatted);
if (recentLogs.length > RING_BUFFER_SIZE) {
recentLogs.shift();
}
// File
// Async file write via buffer
if (logFilePath) {
try {
appendFileSync(logFilePath, formatted + '\n');
} catch {
// Silently fail if we can't write to file
writeBuffer.push(formatted + '\n');
if (writeBuffer.length >= FLUSH_SIZE_THRESHOLD) {
// Buffer is large enough — flush immediately (non-blocking)
void flushBuffer();
} else if (!flushTimer) {
// Schedule a flush after a short delay
flushTimer = setTimeout(() => {
flushTimer = null;
void flushBuffer();
}, FLUSH_INTERVAL_MS);
}
}
}
/**
* Log debug message
*/
// ── Public log methods ───────────────────────────────────────────
export function debug(message: string, ...args: unknown[]): void {
if (currentLevel <= LogLevel.DEBUG) {
const formatted = formatMessage('DEBUG', message, ...args);
@@ -129,9 +185,6 @@ export function debug(message: string, ...args: unknown[]): void {
}
}
/**
* Log info message
*/
export function info(message: string, ...args: unknown[]): void {
if (currentLevel <= LogLevel.INFO) {
const formatted = formatMessage('INFO', message, ...args);
@@ -140,9 +193,6 @@ export function info(message: string, ...args: unknown[]): void {
}
}
/**
* Log warning message
*/
export function warn(message: string, ...args: unknown[]): void {
if (currentLevel <= LogLevel.WARN) {
const formatted = formatMessage('WARN', message, ...args);
@@ -151,9 +201,6 @@ export function warn(message: string, ...args: unknown[]): void {
}
}
/**
* Log error message
*/
export function error(message: string, ...args: unknown[]): void {
if (currentLevel <= LogLevel.ERROR) {
const formatted = formatMessage('ERROR', message, ...args);
@@ -162,11 +209,8 @@ export function error(message: string, ...args: unknown[]): void {
}
}
/**
* Get recent logs from ring buffer (for UI display)
* @param count Number of recent log lines to return (default: all)
* @param minLevel Minimum log level to include (default: DEBUG)
*/
// ── Log retrieval (for UI / diagnostics) ─────────────────────────
export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
const filtered = minLevel != null
? recentLogs.filter(line => {
@@ -181,14 +225,13 @@ export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
}
/**
* Read the current day's log file content (last N lines)
* Read the current day's log file content (last N lines).
* Uses async I/O to avoid blocking.
*/
export function readLogFile(tailLines = 200): string {
if (!logFilePath || !existsSync(logFilePath)) {
return '(No log file found)';
}
export async function readLogFile(tailLines = 200): Promise<string> {
if (!logFilePath) return '(No log file found)';
try {
const content = readFileSync(logFilePath, 'utf-8');
const content = await readFile(logFilePath, 'utf-8');
const lines = content.split('\n');
if (lines.length <= tailLines) return content;
return lines.slice(-tailLines).join('\n');
@@ -198,24 +241,26 @@ export function readLogFile(tailLines = 200): string {
}
/**
* List available log files
* List available log files.
* Uses async I/O to avoid blocking.
*/
export function listLogFiles(): Array<{ name: string; path: string; size: number; modified: string }> {
if (!logDir || !existsSync(logDir)) return [];
export async function listLogFiles(): Promise<Array<{ name: string; path: string; size: number; modified: string }>> {
if (!logDir) return [];
try {
return readdirSync(logDir)
.filter(f => f.endsWith('.log'))
.map(f => {
const fullPath = join(logDir!, f);
const stat = statSync(fullPath);
return {
name: f,
path: fullPath,
size: stat.size,
modified: stat.mtime.toISOString(),
};
})
.sort((a, b) => b.modified.localeCompare(a.modified));
const files = await readdir(logDir);
const results: Array<{ name: string; path: string; size: number; modified: string }> = [];
for (const f of files) {
if (!f.endsWith('.log')) continue;
const fullPath = join(logDir, f);
const s = await stat(fullPath);
results.push({
name: f,
path: fullPath,
size: s.size,
modified: s.mtime.toISOString(),
});
}
return results.sort((a, b) => b.modified.localeCompare(a.modified));
} catch {
return [];
}

View File

@@ -2,8 +2,14 @@
* OpenClaw Auth Profiles Utility
* Writes API keys to ~/.openclaw/agents/main/agent/auth-profiles.json
* so the OpenClaw Gateway can load them for AI provider calls.
*
* All file I/O is asynchronous (fs/promises) to avoid blocking the
* Electron main thread. On Windows + NTFS + Defender the synchronous
* equivalents could stall for 500 ms 2 s+ per call, causing "Not
* Responding" hangs.
*/
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
import { access, mkdir, readFile, writeFile, readdir } from 'fs/promises';
import { constants, Dirent } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import {
@@ -15,18 +21,50 @@ import {
const AUTH_STORE_VERSION = 1;
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
/**
* Auth profile entry for an API key
*/
// ── Helpers ──────────────────────────────────────────────────────
/** Non-throwing async existence check (replaces existsSync). */
async function fileExists(p: string): Promise<boolean> {
try {
await access(p, constants.F_OK);
return true;
} catch {
return false;
}
}
/** Ensure a directory exists (replaces mkdirSync). */
async function ensureDir(dir: string): Promise<void> {
if (!(await fileExists(dir))) {
await mkdir(dir, { recursive: true });
}
}
/** Read a JSON file, returning `null` on any error. */
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
if (!(await fileExists(filePath))) return null;
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw) as T;
} catch {
return null;
}
}
/** Write a JSON file, creating parent directories if needed. */
async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
await ensureDir(join(filePath, '..'));
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
}
// ── Types ────────────────────────────────────────────────────────
interface AuthProfileEntry {
type: 'api_key';
provider: string;
key: string;
}
/**
* Auth profile entry for an OAuth token (matches OpenClaw plugin format)
*/
interface OAuthProfileEntry {
type: 'oauth';
provider: string;
@@ -35,9 +73,6 @@ interface OAuthProfileEntry {
expires: number;
}
/**
* Auth profiles store format
*/
interface AuthProfilesStore {
version: number;
profiles: Record<string, AuthProfileEntry | OAuthProfileEntry>;
@@ -45,90 +80,78 @@ interface AuthProfilesStore {
lastGood?: Record<string, string>;
}
/**
* Get the path to the auth-profiles.json for a given agent
*/
// ── Auth Profiles I/O ────────────────────────────────────────────
function getAuthProfilesPath(agentId = 'main'): string {
return join(homedir(), '.openclaw', 'agents', agentId, 'agent', AUTH_PROFILE_FILENAME);
}
/**
* Read existing auth profiles store, or create an empty one
*/
function readAuthProfiles(agentId = 'main'): AuthProfilesStore {
async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
const filePath = getAuthProfilesPath(agentId);
try {
if (existsSync(filePath)) {
const raw = readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as AuthProfilesStore;
// Validate basic structure
if (data.version && data.profiles && typeof data.profiles === 'object') {
return data;
}
const data = await readJsonFile<AuthProfilesStore>(filePath);
if (data?.version && data.profiles && typeof data.profiles === 'object') {
return data;
}
} catch (error) {
console.warn('Failed to read auth-profiles.json, creating fresh store:', error);
}
return {
version: AUTH_STORE_VERSION,
profiles: {},
};
return { version: AUTH_STORE_VERSION, profiles: {} };
}
/**
* Write auth profiles store to disk
*/
function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): void {
const filePath = getAuthProfilesPath(agentId);
const dir = join(filePath, '..');
// Ensure directory exists
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(filePath, JSON.stringify(store, null, 2), 'utf-8');
async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Promise<void> {
await writeJsonFile(getAuthProfilesPath(agentId), store);
}
/**
* Discover all agent IDs that have an agent/ subdirectory.
*/
function discoverAgentIds(): string[] {
// ── Agent Discovery ──────────────────────────────────────────────
async function discoverAgentIds(): Promise<string[]> {
const agentsDir = join(homedir(), '.openclaw', 'agents');
try {
if (!existsSync(agentsDir)) return ['main'];
return readdirSync(agentsDir, { withFileTypes: true })
.filter((d) => d.isDirectory() && existsSync(join(agentsDir, d.name, 'agent')))
.map((d) => d.name);
if (!(await fileExists(agentsDir))) return ['main'];
const entries: Dirent[] = await readdir(agentsDir, { withFileTypes: true });
const ids: string[] = [];
for (const d of entries) {
if (d.isDirectory() && await fileExists(join(agentsDir, d.name, 'agent'))) {
ids.push(d.name);
}
}
return ids.length > 0 ? ids : ['main'];
} catch {
return ['main'];
}
}
// ── OpenClaw Config Helpers ──────────────────────────────────────
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
async function readOpenClawJson(): Promise<Record<string, unknown>> {
return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {};
}
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
await writeJsonFile(OPENCLAW_CONFIG_PATH, config);
}
// ── Exported Functions (all async) ───────────────────────────────
/**
* Save an OAuth token to OpenClaw's auth-profiles.json.
* Writes in OpenClaw's native OAuth credential format (type: 'oauth'),
* matching exactly what `openclaw models auth login` (upsertAuthProfile) writes.
*
* @param provider - Provider type (e.g. 'minimax-portal', 'qwen-portal')
* @param token - OAuth token from the provider's login function
* @param agentId - Optional single agent ID. When omitted, writes to every agent.
*/
export function saveOAuthTokenToOpenClaw(
export async function saveOAuthTokenToOpenClaw(
provider: string,
token: { access: string; refresh: string; expires: number },
agentId?: string
): void {
const agentIds = agentId ? [agentId] : discoverAgentIds();
): Promise<void> {
const agentIds = agentId ? [agentId] : await discoverAgentIds();
if (agentIds.length === 0) agentIds.push('main');
for (const id of agentIds) {
const store = readAuthProfiles(id);
const store = await readAuthProfiles(id);
const profileId = `${provider}:default`;
const entry: OAuthProfileEntry = {
store.profiles[profileId] = {
type: 'oauth',
provider,
access: token.access,
@@ -136,8 +159,6 @@ export function saveOAuthTokenToOpenClaw(
expires: token.expires,
};
store.profiles[profileId] = entry;
if (!store.order) store.order = {};
if (!store.order[provider]) store.order[provider] = [];
if (!store.order[provider].includes(profileId)) {
@@ -147,9 +168,8 @@ export function saveOAuthTokenToOpenClaw(
if (!store.lastGood) store.lastGood = {};
store.lastGood[provider] = profileId;
writeAuthProfiles(store, id);
await writeAuthProfiles(store, id);
}
console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
}
@@ -161,12 +181,12 @@ export function saveOAuthTokenToOpenClaw(
* @param agentId - Optional single agent ID to read from, defaults to 'main'
* @returns The OAuth token access string or null if not found
*/
export function getOAuthTokenFromOpenClaw(
export async function getOAuthTokenFromOpenClaw(
provider: string,
agentId = 'main'
): string | null {
): Promise<string | null> {
try {
const store = readAuthProfiles(agentId);
const store = await readAuthProfiles(agentId);
const profileId = `${provider}:default`;
const profile = store.profiles[profileId];
@@ -181,65 +201,36 @@ export function getOAuthTokenFromOpenClaw(
/**
* Save a provider API key to OpenClaw's auth-profiles.json
* This writes the key in the format OpenClaw expects so the gateway
* can use it for AI provider calls.
*
* Writes to ALL discovered agent directories so every agent
* (including non-"main" agents like "dev") stays in sync.
*
* @param provider - Provider type (e.g., 'anthropic', 'openrouter', 'openai', 'google')
* @param apiKey - The API key to store
* @param agentId - Optional single agent ID. When omitted, writes to every agent.
*/
export function saveProviderKeyToOpenClaw(
export async function saveProviderKeyToOpenClaw(
provider: string,
apiKey: string,
agentId?: string
): void {
// OAuth providers (qwen-portal, minimax-portal) typically have their credentials
// managed by OpenClaw plugins via `openclaw models auth login`.
// Skip only if there's no explicit API key — meaning the user is using OAuth.
// If the user provided an actual API key, write it normally.
): Promise<void> {
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
if (OAUTH_PROVIDERS.includes(provider) && !apiKey) {
console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`);
return;
}
const agentIds = agentId ? [agentId] : discoverAgentIds();
const agentIds = agentId ? [agentId] : await discoverAgentIds();
if (agentIds.length === 0) agentIds.push('main');
for (const id of agentIds) {
const store = readAuthProfiles(id);
// Profile ID follows OpenClaw convention: <provider>:default
const store = await readAuthProfiles(id);
const profileId = `${provider}:default`;
// Upsert the profile entry
store.profiles[profileId] = {
type: 'api_key',
provider,
key: apiKey,
};
store.profiles[profileId] = { type: 'api_key', provider, key: apiKey };
// Update order to include this profile
if (!store.order) {
store.order = {};
}
if (!store.order[provider]) {
store.order[provider] = [];
}
if (!store.order) store.order = {};
if (!store.order[provider]) store.order[provider] = [];
if (!store.order[provider].includes(profileId)) {
store.order[provider].push(profileId);
}
// Set as last good
if (!store.lastGood) {
store.lastGood = {};
}
if (!store.lastGood) store.lastGood = {};
store.lastGood[provider] = profileId;
writeAuthProfiles(store, id);
await writeAuthProfiles(store, id);
}
console.log(`Saved API key for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
}
@@ -247,38 +238,31 @@ export function saveProviderKeyToOpenClaw(
/**
* Remove a provider API key from OpenClaw auth-profiles.json
*/
export function removeProviderKeyFromOpenClaw(
export async function removeProviderKeyFromOpenClaw(
provider: string,
agentId?: string
): void {
// OAuth providers have their credentials managed by OpenClaw plugins.
// Do NOT delete their auth-profiles entries.
): Promise<void> {
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
if (OAUTH_PROVIDERS.includes(provider)) {
console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`);
return;
}
const agentIds = agentId ? [agentId] : discoverAgentIds();
const agentIds = agentId ? [agentId] : await discoverAgentIds();
if (agentIds.length === 0) agentIds.push('main');
for (const id of agentIds) {
const store = readAuthProfiles(id);
const store = await readAuthProfiles(id);
const profileId = `${provider}:default`;
delete store.profiles[profileId];
if (store.order?.[provider]) {
store.order[provider] = store.order[provider].filter((aid) => aid !== profileId);
if (store.order[provider].length === 0) {
delete store.order[provider];
}
if (store.order[provider].length === 0) delete store.order[provider];
}
if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider];
if (store.lastGood?.[provider] === profileId) {
delete store.lastGood[provider];
}
writeAuthProfiles(store, id);
await writeAuthProfiles(store, id);
}
console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
}
@@ -286,12 +270,12 @@ export function removeProviderKeyFromOpenClaw(
/**
* Remove a provider completely from OpenClaw (delete config, disable plugins, delete keys)
*/
export function removeProviderFromOpenClaw(provider: string): void {
export async function removeProviderFromOpenClaw(provider: string): Promise<void> {
// 1. Remove from auth-profiles.json
const agentIds = discoverAgentIds();
const agentIds = await discoverAgentIds();
if (agentIds.length === 0) agentIds.push('main');
for (const id of agentIds) {
const store = readAuthProfiles(id);
const store = await readAuthProfiles(id);
const profileId = `${provider}:default`;
if (store.profiles[profileId]) {
delete store.profiles[profileId];
@@ -300,55 +284,55 @@ export function removeProviderFromOpenClaw(provider: string): void {
if (store.order[provider].length === 0) delete store.order[provider];
}
if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider];
writeAuthProfiles(store, id);
await writeAuthProfiles(store, id);
}
}
// 2. Remove from models.json (per-agent model registry used by pi-ai directly)
for (const agentId of agentIds) {
const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json');
for (const id of agentIds) {
const modelsPath = join(homedir(), '.openclaw', 'agents', id, 'agent', 'models.json');
try {
if (existsSync(modelsPath)) {
const data = JSON.parse(readFileSync(modelsPath, 'utf-8')) as Record<string, unknown>;
if (await fileExists(modelsPath)) {
const raw = await readFile(modelsPath, 'utf-8');
const data = JSON.parse(raw) as Record<string, unknown>;
const providers = data.providers as Record<string, unknown> | undefined;
if (providers && providers[provider]) {
delete providers[provider];
writeFileSync(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Removed models.json entry for provider "${provider}" (agent "${agentId}")`);
await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`);
}
}
} catch (err) {
console.warn(`Failed to remove provider ${provider} from models.json (agent "${agentId}"):`, err);
console.warn(`Failed to remove provider ${provider} from models.json (agent "${id}"):`, err);
}
}
// 3. Remove from openclaw.json
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
try {
if (existsSync(configPath)) {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
let modified = false;
const config = await readOpenClawJson();
let modified = false;
// Disable plugin (for OAuth like qwen-portal-auth)
if (config.plugins?.entries) {
const pluginName = `${provider}-auth`;
if (config.plugins.entries[pluginName]) {
config.plugins.entries[pluginName].enabled = false;
modified = true;
console.log(`Disabled OpenClaw plugin: ${pluginName}`);
}
}
// Disable plugin (for OAuth like qwen-portal-auth)
const plugins = config.plugins as Record<string, unknown> | undefined;
const entries = (plugins?.entries ?? {}) as Record<string, Record<string, unknown>>;
const pluginName = `${provider}-auth`;
if (entries[pluginName]) {
entries[pluginName].enabled = false;
modified = true;
console.log(`Disabled OpenClaw plugin: ${pluginName}`);
}
// Remove from models.providers
if (config.models?.providers?.[provider]) {
delete config.models.providers[provider];
modified = true;
console.log(`Removed OpenClaw provider config: ${provider}`);
}
// Remove from models.providers
const models = config.models as Record<string, unknown> | undefined;
const providers = (models?.providers ?? {}) as Record<string, unknown>;
if (providers[provider]) {
delete providers[provider];
modified = true;
console.log(`Removed OpenClaw provider config: ${provider}`);
}
if (modified) {
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
}
if (modified) {
await writeOpenClawJson(config);
}
} catch (err) {
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
@@ -361,37 +345,21 @@ export function removeProviderFromOpenClaw(provider: string): void {
*/
export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: string }>): Record<string, string> {
const env: Record<string, string> = {};
for (const { type, apiKey } of providers) {
const envVar = getProviderEnvVar(type);
if (envVar && apiKey) {
env[envVar] = apiKey;
}
}
return env;
}
/**
* Update the OpenClaw config to use the given provider and model
* Writes to ~/.openclaw/openclaw.json
*
* @param provider - Provider type (e.g. 'anthropic', 'siliconflow')
* @param modelOverride - Optional model string to use instead of the registry default.
* For siliconflow this is the user-supplied model ID prefixed with "siliconflow/".
*/
export function setOpenClawDefaultModel(provider: string, modelOverride?: string): void {
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
let config: Record<string, unknown> = {};
try {
if (existsSync(configPath)) {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
}
} catch (err) {
console.warn('Failed to read openclaw.json, creating fresh config:', err);
}
export async function setOpenClawDefaultModel(provider: string, modelOverride?: string): Promise<void> {
const config = await readOpenClawJson();
const model = modelOverride || getProviderDefaultModel(provider);
if (!model) {
@@ -404,7 +372,6 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
: model;
// Set the default model for the agents
// model must be an object: { primary: "provider/model", fallbacks?: [] }
const agents = (config.agents || {}) as Record<string, unknown>;
const defaults = (agents.defaults || {}) as Record<string, unknown>;
defaults.model = { primary: model };
@@ -412,8 +379,6 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
config.agents = agents;
// Configure models.providers for providers that need explicit registration.
// Built-in providers (anthropic, google) are part of OpenClaw's pi-ai catalog
// and must NOT have a models.providers entry — it would override the built-in.
const providerCfg = getProviderConfig(provider);
if (providerCfg) {
const models = (config.models || {}) as Record<string, unknown>;
@@ -456,9 +421,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
models.providers = providers;
config.models = models;
} else {
// Built-in provider: remove any stale models.providers entry that may
// have been written by an earlier version. Leaving it in place would
// override the native pi-ai catalog and can break streaming/auth.
// 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]) {
@@ -471,18 +434,10 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
// Ensure gateway mode is set
const gateway = (config.gateway || {}) as Record<string, unknown>;
if (!gateway.mode) {
gateway.mode = 'local';
}
if (!gateway.mode) gateway.mode = 'local';
config.gateway = gateway;
// Ensure directory exists
const dir = join(configPath, '..');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
await writeOpenClawJson(config);
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`);
}
@@ -498,39 +453,26 @@ interface RuntimeProviderConfigOverride {
* Register or update a provider's configuration in openclaw.json
* without changing the current default model.
*/
export function syncProviderConfigToOpenClaw(
export async function syncProviderConfigToOpenClaw(
provider: string,
modelId: string | undefined,
override: RuntimeProviderConfigOverride
): void {
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
let config: Record<string, unknown> = {};
try {
if (existsSync(configPath)) {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
}
} catch (err) {
console.warn('Failed to read openclaw.json, creating fresh config:', err);
}
): Promise<void> {
const config = await readOpenClawJson();
if (override.baseUrl && override.api) {
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
const nextModels: Array<Record<string, unknown>> = [];
if (modelId) {
nextModels.push({ id: modelId, name: modelId });
}
if (modelId) nextModels.push({ id: modelId, name: modelId });
const nextProvider: Record<string, unknown> = {
baseUrl: override.baseUrl,
api: override.api,
models: nextModels,
};
if (override.apiKeyEnv) {
nextProvider.apiKey = override.apiKeyEnv;
}
if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv;
if (override.headers && Object.keys(override.headers).length > 0) {
nextProvider.headers = override.headers;
}
@@ -543,40 +485,24 @@ export function syncProviderConfigToOpenClaw(
// Ensure extension is enabled for oauth providers to prevent gateway wiping config
if (provider === 'minimax-portal' || provider === 'qwen-portal') {
const plugins = (config.plugins || {}) as Record<string, unknown>;
const entries = (plugins.entries || {}) as Record<string, unknown>;
entries[`${provider}-auth`] = { enabled: true };
plugins.entries = entries;
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
pEntries[`${provider}-auth`] = { enabled: true };
plugins.entries = pEntries;
config.plugins = plugins;
}
const dir = join(configPath, '..');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
await writeOpenClawJson(config);
}
/**
* Update OpenClaw model + provider config using runtime config values.
* Useful for user-configurable providers (custom/ollama-like) where
* baseUrl/model are not in the static registry.
*/
export function setOpenClawDefaultModelWithOverride(
export async function setOpenClawDefaultModelWithOverride(
provider: string,
modelOverride: string | undefined,
override: RuntimeProviderConfigOverride
): void {
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
let config: Record<string, unknown> = {};
try {
if (existsSync(configPath)) {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
}
} catch (err) {
console.warn('Failed to read openclaw.json, creating fresh config:', err);
}
): Promise<void> {
const config = await readOpenClawJson();
const model = modelOverride || getProviderDefaultModel(provider);
if (!model) {
@@ -598,23 +524,15 @@ export function setOpenClawDefaultModelWithOverride(
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
// Replace the provider entry entirely rather than merging.
// Different custom/ollama provider instances have different baseUrls,
// so merging models from a previous instance creates an inconsistent
// config (models pointing at the wrong endpoint).
const nextModels: Array<Record<string, unknown>> = [];
if (modelId) {
nextModels.push({ id: modelId, name: modelId });
}
if (modelId) nextModels.push({ id: modelId, name: modelId });
const nextProvider: Record<string, unknown> = {
baseUrl: override.baseUrl,
api: override.api,
models: nextModels,
};
if (override.apiKeyEnv) {
nextProvider.apiKey = override.apiKeyEnv;
}
if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv;
if (override.headers && Object.keys(override.headers).length > 0) {
nextProvider.headers = override.headers;
}
@@ -628,48 +546,48 @@ export function setOpenClawDefaultModelWithOverride(
}
const gateway = (config.gateway || {}) as Record<string, unknown>;
if (!gateway.mode) {
gateway.mode = 'local';
}
if (!gateway.mode) gateway.mode = 'local';
config.gateway = gateway;
// Ensure the extension plugin is marked as enabled in openclaw.json
// Without this, the OpenClaw Gateway will silently wipe the provider config on startup
if (provider === 'minimax-portal' || provider === 'qwen-portal') {
const plugins = (config.plugins || {}) as Record<string, unknown>;
const entries = (plugins.entries || {}) as Record<string, unknown>;
entries[`${provider}-auth`] = { enabled: true };
plugins.entries = entries;
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
pEntries[`${provider}-auth`] = { enabled: true };
plugins.entries = pEntries;
config.plugins = plugins;
}
const dir = join(configPath, '..');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
await writeOpenClawJson(config);
console.log(
`Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)`
);
}
/**
* Get a set of all active provider IDs configured in openclaw.json and auth-profiles.json.
* This is used to sync ClawX's local provider list with the actual OpenClaw engine state.
* Get a set of all active provider IDs configured in openclaw.json.
* Reads the file ONCE and extracts both models.providers and plugins.entries.
*/
export function getActiveOpenClawProviders(): Set<string> {
export async function getActiveOpenClawProviders(): Promise<Set<string>> {
const activeProviders = new Set<string>();
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
// 1. Read openclaw.json models.providers
try {
if (existsSync(configPath)) {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
const providers = config.models?.providers;
if (providers && typeof providers === 'object') {
for (const key of Object.keys(providers)) {
activeProviders.add(key);
const config = await readOpenClawJson();
// 1. models.providers
const providers = (config.models as Record<string, unknown> | undefined)?.providers;
if (providers && typeof providers === 'object') {
for (const key of Object.keys(providers as Record<string, unknown>)) {
activeProviders.add(key);
}
}
// 2. plugins.entries for OAuth providers
const plugins = (config.plugins as Record<string, unknown> | undefined)?.entries;
if (plugins && typeof plugins === 'object') {
for (const [pluginId, meta] of Object.entries(plugins as Record<string, unknown>)) {
if (pluginId.endsWith('-auth') && (meta as Record<string, unknown>).enabled) {
activeProviders.add(pluginId.replace(/-auth$/, ''));
}
}
}
@@ -677,48 +595,14 @@ export function getActiveOpenClawProviders(): Set<string> {
console.warn('Failed to read openclaw.json for active providers:', err);
}
// 2. Read openclaw.json plugins.entries for OAuth providers
try {
if (existsSync(configPath)) {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
const plugins = config.plugins?.entries;
if (plugins && typeof plugins === 'object') {
for (const [pluginId, meta] of Object.entries(plugins)) {
// If the plugin ends with -auth and is enabled, it's an OAuth provider
// e.g. 'qwen-portal-auth' implies provider 'qwen-portal'
if (pluginId.endsWith('-auth') && (meta as Record<string, unknown>).enabled) {
const providerId = pluginId.replace(/-auth$/, '');
activeProviders.add(providerId);
}
}
}
}
} catch (err) {
console.warn('Failed to read openclaw.json for active plugins:', err);
}
return activeProviders;
}
// Re-export for backwards compatibility
/**
* Write the ClawX gateway token into ~/.openclaw/openclaw.json so the
* gateway process reads the same token we use for the WebSocket handshake.
*
* Without this, openclaw.json may contain a stale token written by the
* system-managed gateway service (launchctl), causing a "token mismatch"
* auth failure when ClawX connects to the process it just spawned.
* Write the ClawX gateway token into ~/.openclaw/openclaw.json.
*/
export function syncGatewayTokenToConfig(token: string): void {
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
let config: Record<string, unknown> = {};
try {
if (existsSync(configPath)) {
config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
}
} catch {
// start from a blank config if the file is corrupt
}
export async function syncGatewayTokenToConfig(token: string): Promise<void> {
const config = await readOpenClawJson();
const gateway = (
config.gateway && typeof config.gateway === 'object'
@@ -738,31 +622,15 @@ export function syncGatewayTokenToConfig(token: string): void {
if (!gateway.mode) gateway.mode = 'local';
config.gateway = gateway;
const dir = join(configPath, '..');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
await writeOpenClawJson(config);
console.log('Synced gateway token to openclaw.json');
}
/**
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json with the
* "openclaw" managed profile as the default.
*
* Only sets values that are not already present so existing user
* customisation (e.g. switching to a remote CDP profile) is preserved.
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json.
*/
export function syncBrowserConfigToOpenClaw(): void {
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
let config: Record<string, unknown> = {};
try {
if (existsSync(configPath)) {
config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
}
} catch {
// start from a blank config if the file is corrupt
}
export async function syncBrowserConfigToOpenClaw(): Promise<void> {
const config = await readOpenClawJson();
const browser = (
config.browser && typeof config.browser === 'object'
@@ -785,29 +653,14 @@ export function syncBrowserConfigToOpenClaw(): void {
if (!changed) return;
config.browser = browser;
const dir = join(configPath, '..');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
await writeOpenClawJson(config);
console.log('Synced browser config to openclaw.json');
}
/**
* Update a provider entry in every discovered agent's models.json.
*
* The gateway caches resolved provider configs in
* ~/.openclaw/agents/<id>/agent/models.json and serves requests from
* that file (not from openclaw.json directly). We must update it
* whenever the active provider changes so the gateway immediately picks
* up the new baseUrl / apiKey without requiring a full restart.
*
* Existing model-level metadata (contextWindow, cost, etc.) is preserved
* when the model ID matches; only the top-level provider fields and the
* models list are updated.
*/
export function updateAgentModelProvider(
export async function updateAgentModelProvider(
providerType: string,
entry: {
baseUrl?: string;
@@ -817,15 +670,13 @@ export function updateAgentModelProvider(
/** When true, pi-ai sends Authorization: Bearer instead of x-api-key */
authHeader?: boolean;
}
): void {
const agentIds = discoverAgentIds();
): Promise<void> {
const agentIds = await discoverAgentIds();
for (const agentId of agentIds) {
const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json');
let data: Record<string, unknown> = {};
try {
if (existsSync(modelsPath)) {
data = JSON.parse(readFileSync(modelsPath, 'utf-8')) as Record<string, unknown>;
}
data = (await readJsonFile<Record<string, unknown>>(modelsPath)) ?? {};
} catch {
// corrupt / missing start with an empty object
}
@@ -839,8 +690,6 @@ export function updateAgentModelProvider(
? { ...providers[providerType] }
: {};
// Preserve per-model metadata (reasoning, cost, contextWindow…) for
// models that already exist; use a minimal stub for new models.
const existingModels = Array.isArray(existing.models)
? (existing.models as Array<Record<string, unknown>>)
: [];
@@ -860,7 +709,7 @@ export function updateAgentModelProvider(
data.providers = providers;
try {
writeFileSync(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
await writeJsonFile(modelsPath, data);
console.log(`Updated models.json for agent "${agentId}" provider "${providerType}"`);
} catch (err) {
console.warn(`Failed to update models.json for agent "${agentId}":`, err);

View File

@@ -1,4 +1,11 @@
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync } from 'fs';
/**
* OpenClaw workspace context utilities.
*
* All file I/O is async (fs/promises) to avoid blocking the Electron
* main thread.
*/
import { access, readFile, writeFile, readdir, mkdir, unlink } from 'fs/promises';
import { constants, Dirent } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { logger } from './logger';
@@ -7,6 +14,20 @@ import { getResourcesDir } from './paths';
const CLAWX_BEGIN = '<!-- clawx:begin -->';
const CLAWX_END = '<!-- clawx:end -->';
// ── Helpers ──────────────────────────────────────────────────────
async function fileExists(p: string): Promise<boolean> {
try { await access(p, constants.F_OK); return true; } catch { return false; }
}
async function ensureDir(dir: string): Promise<void> {
if (!(await fileExists(dir))) {
await mkdir(dir, { recursive: true });
}
}
// ── Pure helpers (no I/O) ────────────────────────────────────────
/**
* Merge a ClawX context section into an existing file's content.
* If markers already exist, replaces the section in-place.
@@ -22,62 +43,21 @@ export function mergeClawXSection(existing: string, section: string): string {
return existing.trimEnd() + '\n\n' + wrapped + '\n';
}
/**
* Detect and remove bootstrap .md files that contain only ClawX markers
* with no meaningful OpenClaw content outside them. This repairs a race
* condition where ensureClawXContext() created the file before the gateway
* could seed the full template. Deleting the hollow file lets the gateway
* re-seed the complete template on next start.
*/
export function repairClawXOnlyBootstrapFiles(): void {
const workspaceDirs = resolveAllWorkspaceDirs();
for (const workspaceDir of workspaceDirs) {
if (!existsSync(workspaceDir)) continue;
let entries: string[];
try {
entries = readdirSync(workspaceDir).filter((f) => f.endsWith('.md'));
} catch {
continue;
}
for (const file of entries) {
const filePath = join(workspaceDir, file);
let content: string;
try {
content = readFileSync(filePath, 'utf-8');
} catch {
continue;
}
const beginIdx = content.indexOf(CLAWX_BEGIN);
const endIdx = content.indexOf(CLAWX_END);
if (beginIdx === -1 || endIdx === -1) continue;
const before = content.slice(0, beginIdx).trim();
const after = content.slice(endIdx + CLAWX_END.length).trim();
if (before === '' && after === '') {
try {
unlinkSync(filePath);
logger.info(`Removed ClawX-only bootstrap file for re-seeding: ${file} (${workspaceDir})`);
} catch {
logger.warn(`Failed to remove ClawX-only bootstrap file: ${filePath}`);
}
}
}
}
}
// ── Workspace directory resolution ───────────────────────────────
/**
* Collect all unique workspace directories from the openclaw config:
* the defaults workspace, each agent's workspace, and any workspace-*
* directories that already exist under ~/.openclaw/.
*/
function resolveAllWorkspaceDirs(): string[] {
async function resolveAllWorkspaceDirs(): Promise<string[]> {
const openclawDir = join(homedir(), '.openclaw');
const dirs = new Set<string>();
const configPath = join(openclawDir, 'openclaw.json');
try {
if (existsSync(configPath)) {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
if (await fileExists(configPath)) {
const config = JSON.parse(await readFile(configPath, 'utf-8'));
const defaultWs = config?.agents?.defaults?.workspace;
if (typeof defaultWs === 'string' && defaultWs.trim()) {
@@ -99,7 +79,8 @@ function resolveAllWorkspaceDirs(): string[] {
}
try {
for (const entry of readdirSync(openclawDir, { withFileTypes: true })) {
const entries: Dirent[] = await readdir(openclawDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('workspace')) {
dirs.add(join(openclawDir, entry.name));
}
@@ -115,49 +96,93 @@ function resolveAllWorkspaceDirs(): string[] {
return [...dirs];
}
// ── Bootstrap file repair ────────────────────────────────────────
/**
* Synchronously merge ClawX context snippets into workspace bootstrap
* files that already exist on disk. Returns the number of target files
* that were skipped because they don't exist yet.
* Detect and remove bootstrap .md files that contain only ClawX markers
* with no meaningful OpenClaw content outside them.
*/
function mergeClawXContextOnce(): number {
export async function repairClawXOnlyBootstrapFiles(): Promise<void> {
const workspaceDirs = await resolveAllWorkspaceDirs();
for (const workspaceDir of workspaceDirs) {
if (!(await fileExists(workspaceDir))) continue;
let entries: string[];
try {
entries = (await readdir(workspaceDir)).filter((f) => f.endsWith('.md'));
} catch {
continue;
}
for (const file of entries) {
const filePath = join(workspaceDir, file);
let content: string;
try {
content = await readFile(filePath, 'utf-8');
} catch {
continue;
}
const beginIdx = content.indexOf(CLAWX_BEGIN);
const endIdx = content.indexOf(CLAWX_END);
if (beginIdx === -1 || endIdx === -1) continue;
const before = content.slice(0, beginIdx).trim();
const after = content.slice(endIdx + CLAWX_END.length).trim();
if (before === '' && after === '') {
try {
await unlink(filePath);
logger.info(`Removed ClawX-only bootstrap file for re-seeding: ${file} (${workspaceDir})`);
} catch {
logger.warn(`Failed to remove ClawX-only bootstrap file: ${filePath}`);
}
}
}
}
}
// ── Context merging ──────────────────────────────────────────────
/**
* Merge ClawX context snippets into workspace bootstrap files that
* already exist on disk. Returns the number of target files that were
* skipped because they don't exist yet.
*/
async function mergeClawXContextOnce(): Promise<number> {
const contextDir = join(getResourcesDir(), 'context');
if (!existsSync(contextDir)) {
if (!(await fileExists(contextDir))) {
logger.debug('ClawX context directory not found, skipping context merge');
return 0;
}
let files: string[];
try {
files = readdirSync(contextDir).filter((f) => f.endsWith('.clawx.md'));
files = (await readdir(contextDir)).filter((f) => f.endsWith('.clawx.md'));
} catch {
return 0;
}
const workspaceDirs = resolveAllWorkspaceDirs();
const workspaceDirs = await resolveAllWorkspaceDirs();
let skipped = 0;
for (const workspaceDir of workspaceDirs) {
if (!existsSync(workspaceDir)) {
mkdirSync(workspaceDir, { recursive: true });
}
await ensureDir(workspaceDir);
for (const file of files) {
const targetName = file.replace('.clawx.md', '.md');
const targetPath = join(workspaceDir, targetName);
if (!existsSync(targetPath)) {
if (!(await fileExists(targetPath))) {
logger.debug(`Skipping ${targetName} in ${workspaceDir} (file does not exist yet, will be seeded by gateway)`);
skipped++;
continue;
}
const section = readFileSync(join(contextDir, file), 'utf-8');
const existing = readFileSync(targetPath, 'utf-8');
const section = await readFile(join(contextDir, file), 'utf-8');
const existing = await readFile(targetPath, 'utf-8');
const merged = mergeClawXSection(existing, section);
if (merged !== existing) {
writeFileSync(targetPath, merged, 'utf-8');
await writeFile(targetPath, merged, 'utf-8');
logger.info(`Merged ClawX context into ${targetName} (${workspaceDir})`);
}
}
@@ -171,22 +196,15 @@ const MAX_RETRIES = 15;
/**
* Ensure ClawX context snippets are merged into the openclaw workspace
* bootstrap files. Reads `*.clawx.md` templates from resources/context/
* and injects them as marker-delimited sections into the corresponding
* workspace `.md` files (e.g. AGENTS.clawx.md -> AGENTS.md).
*
* The gateway seeds workspace files asynchronously after its HTTP server
* starts, so the target files may not exist yet when this is first called.
* When files are missing, retries with a delay until all targets are merged
* or the retry budget is exhausted.
* bootstrap files.
*/
export async function ensureClawXContext(): Promise<void> {
let skipped = mergeClawXContextOnce();
let skipped = await mergeClawXContextOnce();
if (skipped === 0) return;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
await new Promise((r) => setTimeout(r, RETRY_INTERVAL_MS));
skipped = mergeClawXContextOnce();
skipped = await mergeClawXContextOnce();
if (skipped === 0) {
logger.info(`ClawX context merge completed after ${attempt} retry(ies)`);
return;

View File

@@ -214,7 +214,7 @@ export async function getAllProvidersWithKeyInfo(): Promise<
> {
const providers = await getAllProviders();
const results: Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> = [];
const activeOpenClawProviders = getActiveOpenClawProviders();
const activeOpenClawProviders = await getActiveOpenClawProviders();
// We need to avoid deleting native ones like 'anthropic' or 'google'
// that don't need to exist in openclaw.json models.providers

View File

@@ -1,9 +1,12 @@
/**
* Skill Config Utilities
* Direct read/write access to skill configuration in ~/.openclaw/openclaw.json
* This bypasses the Gateway RPC for faster and more reliable config updates
* This bypasses the Gateway RPC for faster and more reliable config updates.
*
* All file I/O uses async fs/promises to avoid blocking the main thread.
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { readFile, writeFile, access } from 'fs/promises';
import { constants } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
@@ -23,15 +26,19 @@ interface OpenClawConfig {
[key: string]: unknown;
}
async function fileExists(p: string): Promise<boolean> {
try { await access(p, constants.F_OK); return true; } catch { return false; }
}
/**
* Read the current OpenClaw config
*/
function readConfig(): OpenClawConfig {
if (!existsSync(OPENCLAW_CONFIG_PATH)) {
async function readConfig(): Promise<OpenClawConfig> {
if (!(await fileExists(OPENCLAW_CONFIG_PATH))) {
return {};
}
try {
const raw = readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8');
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
return JSON.parse(raw);
} catch (err) {
console.error('Failed to read openclaw config:', err);
@@ -42,28 +49,28 @@ function readConfig(): OpenClawConfig {
/**
* Write the OpenClaw config
*/
function writeConfig(config: OpenClawConfig): void {
async function writeConfig(config: OpenClawConfig): Promise<void> {
const json = JSON.stringify(config, null, 2);
writeFileSync(OPENCLAW_CONFIG_PATH, json, 'utf-8');
await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8');
}
/**
* Get skill config
*/
export function getSkillConfig(skillKey: string): SkillEntry | undefined {
const config = readConfig();
export async function getSkillConfig(skillKey: string): Promise<SkillEntry | undefined> {
const config = await readConfig();
return config.skills?.entries?.[skillKey];
}
/**
* Update skill config (apiKey and env)
*/
export function updateSkillConfig(
export async function updateSkillConfig(
skillKey: string,
updates: { apiKey?: string; env?: Record<string, string> }
): { success: boolean; error?: string } {
): Promise<{ success: boolean; error?: string }> {
try {
const config = readConfig();
const config = await readConfig();
// Ensure skills.entries exists
if (!config.skills) {
@@ -90,7 +97,6 @@ export function updateSkillConfig(
if (updates.env !== undefined) {
const newEnv: Record<string, string> = {};
// Process all keys from the update
for (const [key, value] of Object.entries(updates.env)) {
const trimmedKey = key.trim();
if (!trimmedKey) continue;
@@ -99,10 +105,8 @@ export function updateSkillConfig(
if (trimmedVal) {
newEnv[trimmedKey] = trimmedVal;
}
// Empty value = don't include (delete)
}
// Only set env if there are values, otherwise delete
if (Object.keys(newEnv).length > 0) {
entry.env = newEnv;
} else {
@@ -113,7 +117,7 @@ export function updateSkillConfig(
// Save entry back
config.skills.entries[skillKey] = entry;
writeConfig(config);
await writeConfig(config);
return { success: true };
} catch (err) {
console.error('Failed to update skill config:', err);
@@ -124,7 +128,7 @@ export function updateSkillConfig(
/**
* Get all skill configs (for syncing to frontend)
*/
export function getAllSkillConfigs(): Record<string, SkillEntry> {
const config = readConfig();
export async function getAllSkillConfigs(): Promise<Record<string, SkillEntry>> {
const config = await readConfig();
return config.skills?.entries || {};
}

View File

@@ -1,6 +1,6 @@
{
"name": "clawx",
"version": "0.1.18",
"version": "0.1.19-alpha.0",
"pnpm": {
"onlyBuiltDependencies": [
"@whiskeysockets/baileys",
@@ -107,4 +107,4 @@
"zx": "^8.8.5"
},
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
}
}

View File

@@ -581,16 +581,12 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
toast.success(t('toast.channelSaved', { name: meta.name }));
// Step 4: Restart the Gateway so it picks up the new channel config
// The Gateway watches the config file, but a restart ensures a clean start
// especially when adding a channel for the first time.
try {
await window.electron.ipcRenderer.invoke('gateway:restart');
toast.success(t('toast.channelConnecting', { name: meta.name }));
} catch (restartError) {
console.warn('Gateway restart after channel config:', restartError);
toast.info(t('toast.restartManual'));
}
// Gateway restart is now handled server-side via debouncedRestart()
// inside the channel:saveConfig IPC handler, so we don't need to
// trigger it explicitly here. This avoids cascading restarts when
// multiple config changes happen in quick succession (e.g. during
// the setup wizard).
toast.success(t('toast.channelConnecting', { name: meta.name }));
// Brief delay so user can see the success state before dialog closes
await new Promise((resolve) => setTimeout(resolve, 800));