Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || {};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user