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;
|
timeout: NodeJS.Timeout;
|
||||||
}> = new Map();
|
}> = new Map();
|
||||||
private deviceIdentity: DeviceIdentity | null = null;
|
private deviceIdentity: DeviceIdentity | null = null;
|
||||||
|
private restartDebounceTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(config?: Partial<ReconnectConfig>) {
|
constructor(config?: Partial<ReconnectConfig>) {
|
||||||
super();
|
super();
|
||||||
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
|
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 {
|
try {
|
||||||
const identityPath = path.join(app.getPath('userData'), 'clawx-device-identity.json');
|
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})`);
|
logger.debug(`Device identity loaded (deviceId=${this.deviceIdentity.deviceId})`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to load device identity, scopes will be limited:', 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.lastSpawnSummary = null;
|
||||||
this.shouldReconnect = true;
|
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.
|
// Manual start should override and cancel any pending reconnect timer.
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
@@ -369,6 +376,26 @@ export class GatewayManager extends EventEmitter {
|
|||||||
await this.stop();
|
await this.stop();
|
||||||
await this.start();
|
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
|
* Clear all active timers
|
||||||
@@ -386,6 +413,10 @@ export class GatewayManager extends EventEmitter {
|
|||||||
clearInterval(this.healthCheckInterval);
|
clearInterval(this.healthCheckInterval);
|
||||||
this.healthCheckInterval = null;
|
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;
|
const port = PORTS.OPENCLAW_GATEWAY;
|
||||||
|
|
||||||
try {
|
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) => {
|
const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => {
|
||||||
import('child_process').then(cp => {
|
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: '' });
|
if (err) resolve({ stdout: '' });
|
||||||
else resolve({ stdout });
|
else resolve({ stdout });
|
||||||
});
|
});
|
||||||
@@ -550,7 +587,7 @@ export class GatewayManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (stdout.trim()) {
|
if (stdout.trim()) {
|
||||||
const pids = stdout.trim().split('\n')
|
const pids = stdout.trim().split(/\r?\n/)
|
||||||
.map(s => s.trim())
|
.map(s => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
@@ -560,18 +597,33 @@ export class GatewayManager extends EventEmitter {
|
|||||||
|
|
||||||
// Unload the launchctl service first so macOS doesn't auto-
|
// Unload the launchctl service first so macOS doesn't auto-
|
||||||
// respawn the process we're about to kill.
|
// 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) {
|
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));
|
await new Promise(r => setTimeout(r, process.platform === 'win32' ? 2000 : 3000));
|
||||||
// SIGKILL any survivors.
|
|
||||||
for (const pid of pids) {
|
// SIGKILL any survivors (Unix only — Windows taskkill /F is already forceful)
|
||||||
try { process.kill(parseInt(pid), 0); process.kill(parseInt(pid), 'SIGKILL'); } catch { /* already exited */ }
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -633,13 +685,13 @@ export class GatewayManager extends EventEmitter {
|
|||||||
// system-managed launchctl service) the WebSocket handshake will fail
|
// system-managed launchctl service) the WebSocket handshake will fail
|
||||||
// with "token mismatch" even though we pass --token on the CLI.
|
// with "token mismatch" even though we pass --token on the CLI.
|
||||||
try {
|
try {
|
||||||
syncGatewayTokenToConfig(gatewayToken);
|
await syncGatewayTokenToConfig(gatewayToken);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to sync gateway token to openclaw.json:', err);
|
logger.warn('Failed to sync gateway token to openclaw.json:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
syncBrowserConfigToOpenClaw();
|
await syncBrowserConfigToOpenClaw();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to sync browser config to openclaw.json:', 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 {
|
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 { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
||||||
import { isQuitting, setQuitting } from './app-state';
|
import { isQuitting, setQuitting } from './app-state';
|
||||||
|
|
||||||
// Disable GPU acceleration for better compatibility
|
// Disable GPU acceleration only on Linux where GPU driver issues are common.
|
||||||
app.disableHardwareAcceleration();
|
// 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
|
// Global references
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
@@ -122,30 +127,28 @@ async function initialize(): Promise<void> {
|
|||||||
// Create system tray
|
// Create system tray
|
||||||
createTray(mainWindow);
|
createTray(mainWindow);
|
||||||
|
|
||||||
// Override security headers ONLY for the OpenClaw Gateway Control UI
|
// Override security headers ONLY for the OpenClaw Gateway Control UI.
|
||||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
// The URL filter ensures this callback only fires for gateway requests,
|
||||||
const isGatewayUrl = details.url.includes('127.0.0.1:18789') || details.url.includes('localhost:18789');
|
// avoiding unnecessary overhead on every other HTTP response.
|
||||||
|
session.defaultSession.webRequest.onHeadersReceived(
|
||||||
if (!isGatewayUrl) {
|
{ urls: ['http://127.0.0.1:18789/*', 'http://localhost:18789/*'] },
|
||||||
callback({ responseHeaders: details.responseHeaders });
|
(details, callback) => {
|
||||||
return;
|
const headers = { ...details.responseHeaders };
|
||||||
}
|
delete headers['X-Frame-Options'];
|
||||||
|
delete headers['x-frame-options'];
|
||||||
const headers = { ...details.responseHeaders };
|
if (headers['Content-Security-Policy']) {
|
||||||
delete headers['X-Frame-Options'];
|
headers['Content-Security-Policy'] = headers['Content-Security-Policy'].map(
|
||||||
delete headers['x-frame-options'];
|
(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' *")
|
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 });
|
||||||
);
|
},
|
||||||
}
|
);
|
||||||
callback({ responseHeaders: headers });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register IPC handlers
|
// Register IPC handlers
|
||||||
registerIpcHandlers(gatewayManager, clawHubService, mainWindow);
|
registerIpcHandlers(gatewayManager, clawHubService, mainWindow);
|
||||||
@@ -158,7 +161,7 @@ async function initialize(): Promise<void> {
|
|||||||
|
|
||||||
// Minimize to tray on close instead of quitting (macOS & Windows)
|
// Minimize to tray on close instead of quitting (macOS & Windows)
|
||||||
mainWindow.on('close', (event) => {
|
mainWindow.on('close', (event) => {
|
||||||
if (!isQuitting) {
|
if (!isQuitting()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
mainWindow?.hide();
|
mainWindow?.hide();
|
||||||
}
|
}
|
||||||
@@ -171,11 +174,9 @@ async function initialize(): Promise<void> {
|
|||||||
// Repair any bootstrap files that only contain ClawX markers (no OpenClaw
|
// Repair any bootstrap files that only contain ClawX markers (no OpenClaw
|
||||||
// template content). This fixes a race condition where ensureClawXContext()
|
// template content). This fixes a race condition where ensureClawXContext()
|
||||||
// previously created the file before the gateway could seed the full template.
|
// previously created the file before the gateway could seed the full template.
|
||||||
try {
|
void repairClawXOnlyBootstrapFiles().catch((error) => {
|
||||||
repairClawXOnlyBootstrapFiles();
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('Failed to repair bootstrap files:', error);
|
logger.warn('Failed to repair bootstrap files:', error);
|
||||||
}
|
});
|
||||||
|
|
||||||
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
|
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Registers all IPC handlers for main-renderer communication
|
* Registers all IPC handlers for main-renderer communication
|
||||||
*/
|
*/
|
||||||
import { ipcMain, BrowserWindow, shell, dialog, app, nativeImage } from 'electron';
|
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 { homedir } from 'node:os';
|
||||||
import { join, extname, basename } from 'node:path';
|
import { join, extname, basename } from 'node:path';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
@@ -85,7 +85,7 @@ export function registerIpcHandlers(
|
|||||||
registerClawHubHandlers(clawHubService);
|
registerClawHubHandlers(clawHubService);
|
||||||
|
|
||||||
// OpenClaw handlers
|
// OpenClaw handlers
|
||||||
registerOpenClawHandlers();
|
registerOpenClawHandlers(gatewayManager);
|
||||||
|
|
||||||
// Provider handlers
|
// Provider handlers
|
||||||
registerProviderHandlers(gatewayManager);
|
registerProviderHandlers(gatewayManager);
|
||||||
@@ -135,7 +135,7 @@ function registerSkillConfigHandlers(): void {
|
|||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
}) => {
|
}) => {
|
||||||
return updateSkillConfig(params.skillKey, {
|
return await updateSkillConfig(params.skillKey, {
|
||||||
apiKey: params.apiKey,
|
apiKey: params.apiKey,
|
||||||
env: params.env,
|
env: params.env,
|
||||||
});
|
});
|
||||||
@@ -143,12 +143,12 @@ function registerSkillConfigHandlers(): void {
|
|||||||
|
|
||||||
// Get skill config
|
// Get skill config
|
||||||
ipcMain.handle('skill:getConfig', async (_, skillKey: string) => {
|
ipcMain.handle('skill:getConfig', async (_, skillKey: string) => {
|
||||||
return getSkillConfig(skillKey);
|
return await getSkillConfig(skillKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all skill configs
|
// Get all skill configs
|
||||||
ipcMain.handle('skill:getAllConfigs', async () => {
|
ipcMain.handle('skill:getAllConfigs', async () => {
|
||||||
return getAllSkillConfigs();
|
return await getAllSkillConfigs();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ function registerLogHandlers(): void {
|
|||||||
|
|
||||||
// Read log file content (last N lines)
|
// Read log file content (last N lines)
|
||||||
ipcMain.handle('log:readFile', async (_, tailLines?: number) => {
|
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)
|
// Get log file path (so user can open in file explorer)
|
||||||
@@ -389,7 +389,7 @@ function registerLogHandlers(): void {
|
|||||||
|
|
||||||
// List all log files
|
// List all log files
|
||||||
ipcMain.handle('log:listFiles', async () => {
|
ipcMain.handle('log:listFiles', async () => {
|
||||||
return logger.listLogFiles();
|
return await logger.listLogFiles();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,8 +479,10 @@ function registerGatewayHandlers(
|
|||||||
const fileReferences: string[] = [];
|
const fileReferences: string[] = [];
|
||||||
|
|
||||||
if (params.media && params.media.length > 0) {
|
if (params.media && params.media.length > 0) {
|
||||||
|
const fsP = await import('fs/promises');
|
||||||
for (const m of params.media) {
|
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
|
// Always add file path reference so the model can access it via tools
|
||||||
fileReferences.push(
|
fileReferences.push(
|
||||||
@@ -491,7 +493,7 @@ function registerGatewayHandlers(
|
|||||||
// Send as base64 attachment in the format the Gateway expects:
|
// Send as base64 attachment in the format the Gateway expects:
|
||||||
// { content: base64String, mimeType: string, fileName?: string }
|
// { content: base64String, mimeType: string, fileName?: string }
|
||||||
// The Gateway normalizer looks for `a.content` (NOT `a.source.data`).
|
// 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');
|
const base64Data = fileBuffer.toString('base64');
|
||||||
logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
|
logger.info(`[chat:sendWithMedia] Read ${fileBuffer.length} bytes, base64 length: ${base64Data.length}`);
|
||||||
imageAttachments.push({
|
imageAttachments.push({
|
||||||
@@ -605,7 +607,7 @@ function registerGatewayHandlers(
|
|||||||
* OpenClaw-related IPC handlers
|
* OpenClaw-related IPC handlers
|
||||||
* For checking package status and channel configuration
|
* For checking package status and channel configuration
|
||||||
*/
|
*/
|
||||||
function registerOpenClawHandlers(): void {
|
function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||||
|
|
||||||
// Get OpenClaw package status
|
// Get OpenClaw package status
|
||||||
ipcMain.handle('openclaw:status', () => {
|
ipcMain.handle('openclaw:status', () => {
|
||||||
@@ -664,7 +666,13 @@ function registerOpenClawHandlers(): void {
|
|||||||
ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record<string, unknown>) => {
|
ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record<string, unknown>) => {
|
||||||
try {
|
try {
|
||||||
logger.info('channel:saveConfig', { channelType, keys: Object.keys(config || {}) });
|
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 };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save channel config:', error);
|
console.error('Failed to save channel config:', error);
|
||||||
@@ -675,7 +683,7 @@ function registerOpenClawHandlers(): void {
|
|||||||
// Get channel configuration
|
// Get channel configuration
|
||||||
ipcMain.handle('channel:getConfig', async (_, channelType: string) => {
|
ipcMain.handle('channel:getConfig', async (_, channelType: string) => {
|
||||||
try {
|
try {
|
||||||
const config = getChannelConfig(channelType);
|
const config = await getChannelConfig(channelType);
|
||||||
return { success: true, config };
|
return { success: true, config };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get channel config:', 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)
|
// Get channel form values (reverse-transformed for UI pre-fill)
|
||||||
ipcMain.handle('channel:getFormValues', async (_, channelType: string) => {
|
ipcMain.handle('channel:getFormValues', async (_, channelType: string) => {
|
||||||
try {
|
try {
|
||||||
const values = getChannelFormValues(channelType);
|
const values = await getChannelFormValues(channelType);
|
||||||
return { success: true, values };
|
return { success: true, values };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get channel form values:', error);
|
console.error('Failed to get channel form values:', error);
|
||||||
@@ -697,7 +705,7 @@ function registerOpenClawHandlers(): void {
|
|||||||
// Delete channel configuration
|
// Delete channel configuration
|
||||||
ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => {
|
ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => {
|
||||||
try {
|
try {
|
||||||
deleteChannelConfig(channelType);
|
await deleteChannelConfig(channelType);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete channel config:', error);
|
console.error('Failed to delete channel config:', error);
|
||||||
@@ -708,7 +716,7 @@ function registerOpenClawHandlers(): void {
|
|||||||
// List configured channels
|
// List configured channels
|
||||||
ipcMain.handle('channel:listConfigured', async () => {
|
ipcMain.handle('channel:listConfigured', async () => {
|
||||||
try {
|
try {
|
||||||
const channels = listConfiguredChannels();
|
const channels = await listConfiguredChannels();
|
||||||
return { success: true, channels };
|
return { success: true, channels };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to list channels:', error);
|
console.error('Failed to list channels:', error);
|
||||||
@@ -719,7 +727,7 @@ function registerOpenClawHandlers(): void {
|
|||||||
// Enable or disable a channel
|
// Enable or disable a channel
|
||||||
ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => {
|
ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
setChannelEnabled(channelType, enabled);
|
await setChannelEnabled(channelType, enabled);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to set channel enabled:', error);
|
console.error('Failed to set channel enabled:', error);
|
||||||
@@ -838,10 +846,8 @@ function registerDeviceOAuthHandlers(mainWindow: BrowserWindow): void {
|
|||||||
function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
||||||
// Listen for OAuth success to automatically restart the Gateway with new tokens/configs
|
// Listen for OAuth success to automatically restart the Gateway with new tokens/configs
|
||||||
deviceOAuthManager.on('oauth:success', (providerType) => {
|
deviceOAuthManager.on('oauth:success', (providerType) => {
|
||||||
logger.info(`[IPC] Restarting Gateway after ${providerType} OAuth success...`);
|
logger.info(`[IPC] Scheduling Gateway restart after ${providerType} OAuth success...`);
|
||||||
void gatewayManager.restart().catch(err => {
|
gatewayManager.debouncedRestart();
|
||||||
logger.error('Failed to restart Gateway after OAuth success:', err);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get all providers with key info
|
// 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
|
// Also write to OpenClaw auth-profiles.json so the gateway can use it
|
||||||
try {
|
try {
|
||||||
saveProviderKeyToOpenClaw(ock, trimmedKey);
|
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to save key to OpenClaw auth-profiles:', 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;
|
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
|
||||||
|
|
||||||
if (api) {
|
if (api) {
|
||||||
syncProviderConfigToOpenClaw(ock, config.model, {
|
await syncProviderConfigToOpenClaw(ock, config.model, {
|
||||||
baseUrl: config.baseUrl || meta?.baseUrl,
|
baseUrl: config.baseUrl || meta?.baseUrl,
|
||||||
api,
|
api,
|
||||||
apiKeyEnv: meta?.apiKeyEnv,
|
apiKeyEnv: meta?.apiKeyEnv,
|
||||||
@@ -897,7 +903,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
: await getApiKey(config.id);
|
: await getApiKey(config.id);
|
||||||
if (resolvedKey && config.baseUrl) {
|
if (resolvedKey && config.baseUrl) {
|
||||||
const modelId = config.model;
|
const modelId = config.model;
|
||||||
updateAgentModelProvider(ock, {
|
await updateAgentModelProvider(ock, {
|
||||||
baseUrl: config.baseUrl,
|
baseUrl: config.baseUrl,
|
||||||
api: 'openai-completions',
|
api: 'openai-completions',
|
||||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
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
|
// Debounced restart so the gateway picks up new config/env vars.
|
||||||
logger.info(`Restarting Gateway after saving provider "${ock}" config`);
|
// Multiple rapid provider saves (e.g. during setup) are coalesced.
|
||||||
void gatewayManager.restart().catch((err) => {
|
logger.info(`Scheduling Gateway restart after saving provider "${ock}" config`);
|
||||||
logger.warn('Gateway restart after provider save failed:', err);
|
gatewayManager.debouncedRestart();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to sync openclaw provider config:', err);
|
console.warn('Failed to sync openclaw provider config:', err);
|
||||||
@@ -932,13 +937,11 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
if (existing?.type) {
|
if (existing?.type) {
|
||||||
try {
|
try {
|
||||||
const ock = getOpenClawProviderKey(existing.type, providerId);
|
const ock = getOpenClawProviderKey(existing.type, providerId);
|
||||||
removeProviderFromOpenClaw(ock);
|
await removeProviderFromOpenClaw(ock);
|
||||||
|
|
||||||
// Restart Gateway so it no longer loads the deleted provider's plugin/config
|
// Debounced restart so the gateway stops loading the deleted provider.
|
||||||
logger.info(`Restarting Gateway after deleting provider "${ock}"`);
|
logger.info(`Scheduling Gateway restart after deleting provider "${ock}"`);
|
||||||
void gatewayManager.restart().catch((err) => {
|
gatewayManager.debouncedRestart();
|
||||||
logger.warn('Gateway restart after provider delete failed:', err);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to completely remove provider from OpenClaw:', 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 providerType = provider?.type || providerId;
|
||||||
const ock = getOpenClawProviderKey(providerType, providerId);
|
const ock = getOpenClawProviderKey(providerType, providerId);
|
||||||
try {
|
try {
|
||||||
saveProviderKeyToOpenClaw(ock, apiKey);
|
await saveProviderKeyToOpenClaw(ock, apiKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to save key to OpenClaw auth-profiles:', 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();
|
const trimmedKey = apiKey.trim();
|
||||||
if (trimmedKey) {
|
if (trimmedKey) {
|
||||||
await storeApiKey(providerId, trimmedKey);
|
await storeApiKey(providerId, trimmedKey);
|
||||||
saveProviderKeyToOpenClaw(ock, trimmedKey);
|
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
||||||
} else {
|
} else {
|
||||||
await deleteApiKey(providerId);
|
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;
|
const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api;
|
||||||
|
|
||||||
if (api) {
|
if (api) {
|
||||||
syncProviderConfigToOpenClaw(ock, nextConfig.model, {
|
await syncProviderConfigToOpenClaw(ock, nextConfig.model, {
|
||||||
baseUrl: nextConfig.baseUrl || meta?.baseUrl,
|
baseUrl: nextConfig.baseUrl || meta?.baseUrl,
|
||||||
api,
|
api,
|
||||||
apiKeyEnv: meta?.apiKeyEnv,
|
apiKeyEnv: meta?.apiKeyEnv,
|
||||||
@@ -1029,7 +1032,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
: await getApiKey(providerId);
|
: await getApiKey(providerId);
|
||||||
if (resolvedKey && nextConfig.baseUrl) {
|
if (resolvedKey && nextConfig.baseUrl) {
|
||||||
const modelId = nextConfig.model;
|
const modelId = nextConfig.model;
|
||||||
updateAgentModelProvider(ock, {
|
await updateAgentModelProvider(ock, {
|
||||||
baseUrl: nextConfig.baseUrl,
|
baseUrl: nextConfig.baseUrl,
|
||||||
api: 'openai-completions',
|
api: 'openai-completions',
|
||||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||||
@@ -1046,20 +1049,18 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
? `${ock}/${nextConfig.model}`
|
? `${ock}/${nextConfig.model}`
|
||||||
: undefined;
|
: undefined;
|
||||||
if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') {
|
if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') {
|
||||||
setOpenClawDefaultModel(nextConfig.type, modelOverride);
|
await setOpenClawDefaultModel(nextConfig.type, modelOverride);
|
||||||
} else {
|
} else {
|
||||||
setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||||
baseUrl: nextConfig.baseUrl,
|
baseUrl: nextConfig.baseUrl,
|
||||||
api: 'openai-completions',
|
api: 'openai-completions',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart Gateway so it picks up the new config and env vars
|
// Debounced restart so the gateway picks up updated config/env vars.
|
||||||
logger.info(`Restarting Gateway after updating provider "${ock}" config`);
|
logger.info(`Scheduling Gateway restart after updating provider "${ock}" config`);
|
||||||
void gatewayManager.restart().catch((err) => {
|
gatewayManager.debouncedRestart();
|
||||||
logger.warn('Gateway restart after provider update failed:', err);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to sync openclaw config after provider update:', err);
|
console.warn('Failed to sync openclaw config after provider update:', err);
|
||||||
}
|
}
|
||||||
@@ -1071,10 +1072,10 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
await saveProvider(existing);
|
await saveProvider(existing);
|
||||||
if (previousKey) {
|
if (previousKey) {
|
||||||
await storeApiKey(providerId, previousKey);
|
await storeApiKey(providerId, previousKey);
|
||||||
saveProviderKeyToOpenClaw(previousOck, previousKey);
|
await saveProviderKeyToOpenClaw(previousOck, previousKey);
|
||||||
} else {
|
} else {
|
||||||
await deleteApiKey(providerId);
|
await deleteApiKey(providerId);
|
||||||
removeProviderFromOpenClaw(previousOck);
|
await removeProviderFromOpenClaw(previousOck);
|
||||||
}
|
}
|
||||||
} catch (rollbackError) {
|
} catch (rollbackError) {
|
||||||
console.warn('Failed to rollback provider updateWithKey:', rollbackError);
|
console.warn('Failed to rollback provider updateWithKey:', rollbackError);
|
||||||
@@ -1096,7 +1097,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
const ock = getOpenClawProviderKey(providerType, providerId);
|
const ock = getOpenClawProviderKey(providerType, providerId);
|
||||||
try {
|
try {
|
||||||
if (ock) {
|
if (ock) {
|
||||||
removeProviderFromOpenClaw(ock);
|
await removeProviderFromOpenClaw(ock);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
console.warn('Failed to completely remove provider from OpenClaw:', err);
|
||||||
@@ -1144,17 +1145,17 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (provider.type === 'custom' || provider.type === 'ollama') {
|
if (provider.type === 'custom' || provider.type === 'ollama') {
|
||||||
setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||||
baseUrl: provider.baseUrl,
|
baseUrl: provider.baseUrl,
|
||||||
api: 'openai-completions',
|
api: 'openai-completions',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setOpenClawDefaultModel(provider.type, modelOverride);
|
await setOpenClawDefaultModel(provider.type, modelOverride);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep auth-profiles in sync with the default provider instance.
|
// Keep auth-profiles in sync with the default provider instance.
|
||||||
if (providerKey) {
|
if (providerKey) {
|
||||||
saveProviderKeyToOpenClaw(ock, providerKey);
|
await saveProviderKeyToOpenClaw(ock, providerKey);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// OAuth providers (minimax-portal, minimax-portal-cn, qwen-portal)
|
// OAuth providers (minimax-portal, minimax-portal-cn, qwen-portal)
|
||||||
@@ -1177,7 +1178,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
? 'minimax-portal'
|
? 'minimax-portal'
|
||||||
: provider.type;
|
: provider.type;
|
||||||
|
|
||||||
setOpenClawDefaultModelWithOverride(targetProviderKey, undefined, {
|
await setOpenClawDefaultModelWithOverride(targetProviderKey, undefined, {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
api,
|
api,
|
||||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
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.
|
// authHeader immediately, without waiting for Gateway to sync openclaw.json.
|
||||||
try {
|
try {
|
||||||
const defaultModelId = provider.model?.split('/').pop();
|
const defaultModelId = provider.model?.split('/').pop();
|
||||||
updateAgentModelProvider(targetProviderKey, {
|
await updateAgentModelProvider(targetProviderKey, {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
api,
|
api,
|
||||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||||
@@ -1210,7 +1211,7 @@ function registerProviderHandlers(gatewayManager: GatewayManager): void {
|
|||||||
provider.baseUrl
|
provider.baseUrl
|
||||||
) {
|
) {
|
||||||
const modelId = provider.model;
|
const modelId = provider.model;
|
||||||
updateAgentModelProvider(ock, {
|
await updateAgentModelProvider(ock, {
|
||||||
baseUrl: provider.baseUrl,
|
baseUrl: provider.baseUrl,
|
||||||
api: 'openai-completions',
|
api: 'openai-completions',
|
||||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
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()) {
|
if (gatewayManager.isConnected()) {
|
||||||
logger.info(`Restarting Gateway after provider switch to "${ock}"`);
|
logger.info(`Scheduling Gateway restart after provider switch to "${ock}"`);
|
||||||
void gatewayManager.restart().catch((err) => {
|
gatewayManager.debouncedRestart();
|
||||||
logger.warn('Gateway restart after provider switch failed:', err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to set OpenClaw default model:', 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
|
* longer side so the image is never squished). The frontend handles
|
||||||
* square cropping via CSS object-fit: cover.
|
* 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 {
|
try {
|
||||||
const img = nativeImage.createFromPath(filePath);
|
const img = nativeImage.createFromPath(filePath);
|
||||||
if (img.isEmpty()) return null;
|
if (img.isEmpty()) return null;
|
||||||
@@ -1770,8 +1769,9 @@ function generateImagePreview(filePath: string, mimeType: string): string | null
|
|||||||
: img.resize({ height: maxDim }); // portrait → constrain height
|
: img.resize({ height: maxDim }); // portrait → constrain height
|
||||||
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
|
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
|
||||||
}
|
}
|
||||||
// Small image — use original
|
// Small image — use original (async read to avoid blocking)
|
||||||
const buf = readFileSync(filePath);
|
const { readFile: readFileAsync } = await import('fs/promises');
|
||||||
|
const buf = await readFileAsync(filePath);
|
||||||
return `data:${mimeType};base64,${buf.toString('base64')}`;
|
return `data:${mimeType};base64,${buf.toString('base64')}`;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -1785,26 +1785,27 @@ function generateImagePreview(filePath: string, mimeType: string): string | null
|
|||||||
function registerFileHandlers(): void {
|
function registerFileHandlers(): void {
|
||||||
// Stage files from real disk paths (used with dialog:open)
|
// Stage files from real disk paths (used with dialog:open)
|
||||||
ipcMain.handle('file:stage', async (_, filePaths: string[]) => {
|
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 = [];
|
const results = [];
|
||||||
for (const filePath of filePaths) {
|
for (const filePath of filePaths) {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const ext = extname(filePath);
|
const ext = extname(filePath);
|
||||||
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
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 mimeType = getMimeType(ext);
|
||||||
const fileName = basename(filePath);
|
const fileName = basename(filePath);
|
||||||
|
|
||||||
// Generate preview for images
|
// Generate preview for images
|
||||||
let preview: string | null = null;
|
let preview: string | null = null;
|
||||||
if (mimeType.startsWith('image/')) {
|
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;
|
return results;
|
||||||
});
|
});
|
||||||
@@ -1815,13 +1816,14 @@ function registerFileHandlers(): void {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
mimeType: 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 id = crypto.randomUUID();
|
||||||
const ext = extname(payload.fileName) || mimeToExt(payload.mimeType);
|
const ext = extname(payload.fileName) || mimeToExt(payload.mimeType);
|
||||||
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
||||||
const buffer = Buffer.from(payload.base64, 'base64');
|
const buffer = Buffer.from(payload.base64, 'base64');
|
||||||
writeFileSync(stagedPath, buffer);
|
await fsP.writeFile(stagedPath, buffer);
|
||||||
|
|
||||||
const mimeType = payload.mimeType || getMimeType(ext);
|
const mimeType = payload.mimeType || getMimeType(ext);
|
||||||
const fileSize = buffer.length;
|
const fileSize = buffer.length;
|
||||||
@@ -1829,7 +1831,7 @@ function registerFileHandlers(): void {
|
|||||||
// Generate preview for images
|
// Generate preview for images
|
||||||
let preview: string | null = null;
|
let preview: string | null = null;
|
||||||
if (mimeType.startsWith('image/')) {
|
if (mimeType.startsWith('image/')) {
|
||||||
preview = generateImagePreview(stagedPath, mimeType);
|
preview = await generateImagePreview(stagedPath, mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { id, fileName: payload.fileName, mimeType, fileSize, stagedPath, preview };
|
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 (result.canceled || !result.filePath) return { success: false };
|
||||||
|
|
||||||
if (params.filePath && existsSync(params.filePath)) {
|
const fsP = await import('fs/promises');
|
||||||
copyFileSync(params.filePath, result.filePath);
|
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) {
|
} else if (params.base64) {
|
||||||
const buffer = Buffer.from(params.base64, 'base64');
|
const buffer = Buffer.from(params.base64, 'base64');
|
||||||
writeFileSync(result.filePath, buffer);
|
await fsP.writeFile(result.filePath, buffer);
|
||||||
} else {
|
} else {
|
||||||
return { success: false, error: 'No image data provided' };
|
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 }>) => {
|
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 }> = {};
|
const results: Record<string, { preview: string | null; fileSize: number }> = {};
|
||||||
for (const { filePath, mimeType } of paths) {
|
for (const { filePath, mimeType } of paths) {
|
||||||
try {
|
try {
|
||||||
if (!existsSync(filePath)) {
|
const s = await fsP.stat(filePath);
|
||||||
results[filePath] = { preview: null, fileSize: 0 };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const stat = statSync(filePath);
|
|
||||||
let preview: string | null = null;
|
let preview: string | null = null;
|
||||||
if (mimeType.startsWith('image/')) {
|
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 {
|
} catch {
|
||||||
results[filePath] = { preview: null, fileSize: 0 };
|
results[filePath] = { preview: null, fileSize: 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Channel Configuration Utilities
|
* 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 { join } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { getOpenClawResolvedDir } from './paths';
|
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)
|
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
|
||||||
const PLUGIN_CHANNELS = ['whatsapp'];
|
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 {
|
export interface ChannelConfigData {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -30,27 +41,23 @@ export interface OpenClawConfig {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Config I/O ───────────────────────────────────────────────────
|
||||||
* Ensure OpenClaw config directory exists
|
|
||||||
*/
|
async function ensureConfigDir(): Promise<void> {
|
||||||
function ensureConfigDir(): void {
|
if (!(await fileExists(OPENCLAW_DIR))) {
|
||||||
if (!existsSync(OPENCLAW_DIR)) {
|
await mkdir(OPENCLAW_DIR, { recursive: true });
|
||||||
mkdirSync(OPENCLAW_DIR, { recursive: true });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
|
||||||
* Read OpenClaw configuration
|
await ensureConfigDir();
|
||||||
*/
|
|
||||||
export function readOpenClawConfig(): OpenClawConfig {
|
|
||||||
ensureConfigDir();
|
|
||||||
|
|
||||||
if (!existsSync(CONFIG_FILE)) {
|
if (!(await fileExists(CONFIG_FILE))) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
const content = await readFile(CONFIG_FILE, 'utf-8');
|
||||||
return JSON.parse(content) as OpenClawConfig;
|
return JSON.parse(content) as OpenClawConfig;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to read OpenClaw config', error);
|
logger.error('Failed to read OpenClaw config', error);
|
||||||
@@ -59,14 +66,11 @@ export function readOpenClawConfig(): OpenClawConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void> {
|
||||||
* Write OpenClaw configuration
|
await ensureConfigDir();
|
||||||
*/
|
|
||||||
export function writeOpenClawConfig(config: OpenClawConfig): void {
|
|
||||||
ensureConfigDir();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to write OpenClaw config', error);
|
logger.error('Failed to write OpenClaw config', error);
|
||||||
console.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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Channel operations ───────────────────────────────────────────
|
||||||
* Save channel configuration
|
|
||||||
* @param channelType - The channel type (e.g., 'telegram', 'discord')
|
export async function saveChannelConfig(
|
||||||
* @param config - The channel configuration object
|
|
||||||
*/
|
|
||||||
export function saveChannelConfig(
|
|
||||||
channelType: string,
|
channelType: string,
|
||||||
config: ChannelConfigData
|
config: ChannelConfigData
|
||||||
): void {
|
): Promise<void> {
|
||||||
const currentConfig = readOpenClawConfig();
|
const currentConfig = await readOpenClawConfig();
|
||||||
|
|
||||||
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
|
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
|
||||||
if (PLUGIN_CHANNELS.includes(channelType)) {
|
if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||||
@@ -97,7 +98,7 @@ export function saveChannelConfig(
|
|||||||
...currentConfig.plugins.entries[channelType],
|
...currentConfig.plugins.entries[channelType],
|
||||||
enabled: config.enabled ?? true,
|
enabled: config.enabled ?? true,
|
||||||
};
|
};
|
||||||
writeOpenClawConfig(currentConfig);
|
await writeOpenClawConfig(currentConfig);
|
||||||
logger.info('Plugin channel config saved', {
|
logger.info('Plugin channel config saved', {
|
||||||
channelType,
|
channelType,
|
||||||
configFile: CONFIG_FILE,
|
configFile: CONFIG_FILE,
|
||||||
@@ -119,7 +120,6 @@ export function saveChannelConfig(
|
|||||||
const { guildId, channelId, ...restConfig } = config;
|
const { guildId, channelId, ...restConfig } = config;
|
||||||
transformedConfig = { ...restConfig };
|
transformedConfig = { ...restConfig };
|
||||||
|
|
||||||
// Add standard Discord config
|
|
||||||
transformedConfig.groupPolicy = 'allowlist';
|
transformedConfig.groupPolicy = 'allowlist';
|
||||||
transformedConfig.dm = { enabled: false };
|
transformedConfig.dm = { enabled: false };
|
||||||
transformedConfig.retry = {
|
transformedConfig.retry = {
|
||||||
@@ -129,21 +129,17 @@ export function saveChannelConfig(
|
|||||||
jitter: 0.1,
|
jitter: 0.1,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build guilds structure
|
|
||||||
if (guildId && typeof guildId === 'string' && guildId.trim()) {
|
if (guildId && typeof guildId === 'string' && guildId.trim()) {
|
||||||
const guildConfig: Record<string, unknown> = {
|
const guildConfig: Record<string, unknown> = {
|
||||||
users: ['*'],
|
users: ['*'],
|
||||||
requireMention: true,
|
requireMention: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add channels config
|
|
||||||
if (channelId && typeof channelId === 'string' && channelId.trim()) {
|
if (channelId && typeof channelId === 'string' && channelId.trim()) {
|
||||||
// Specific channel
|
|
||||||
guildConfig.channels = {
|
guildConfig.channels = {
|
||||||
[channelId.trim()]: { allow: true, requireMention: true }
|
[channelId.trim()]: { allow: true, requireMention: true }
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// All channels
|
|
||||||
guildConfig.channels = {
|
guildConfig.channels = {
|
||||||
'*': { allow: true, requireMention: true }
|
'*': { allow: true, requireMention: true }
|
||||||
};
|
};
|
||||||
@@ -166,8 +162,7 @@ export function saveChannelConfig(
|
|||||||
.filter(u => u.length > 0);
|
.filter(u => u.length > 0);
|
||||||
|
|
||||||
if (users.length > 0) {
|
if (users.length > 0) {
|
||||||
transformedConfig.allowFrom = users; // Use 'allowFrom' (correct key)
|
transformedConfig.allowFrom = users;
|
||||||
// transformedConfig.groupPolicy = 'allowlist'; // Default is allowlist
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,17 +171,16 @@ export function saveChannelConfig(
|
|||||||
if (channelType === 'feishu') {
|
if (channelType === 'feishu') {
|
||||||
const existingConfig = currentConfig.channels[channelType] || {};
|
const existingConfig = currentConfig.channels[channelType] || {};
|
||||||
transformedConfig.dmPolicy = transformedConfig.dmPolicy ?? existingConfig.dmPolicy ?? 'open';
|
transformedConfig.dmPolicy = transformedConfig.dmPolicy ?? existingConfig.dmPolicy ?? 'open';
|
||||||
|
|
||||||
let allowFrom = transformedConfig.allowFrom ?? existingConfig.allowFrom ?? ['*'];
|
let allowFrom = transformedConfig.allowFrom ?? existingConfig.allowFrom ?? ['*'];
|
||||||
if (!Array.isArray(allowFrom)) {
|
if (!Array.isArray(allowFrom)) {
|
||||||
allowFrom = [allowFrom];
|
allowFrom = [allowFrom];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If dmPolicy is open, OpenClaw schema requires '*' in allowFrom
|
|
||||||
if (transformedConfig.dmPolicy === 'open' && !allowFrom.includes('*')) {
|
if (transformedConfig.dmPolicy === 'open' && !allowFrom.includes('*')) {
|
||||||
allowFrom = [...allowFrom, '*'];
|
allowFrom = [...allowFrom, '*'];
|
||||||
}
|
}
|
||||||
|
|
||||||
transformedConfig.allowFrom = allowFrom;
|
transformedConfig.allowFrom = allowFrom;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +191,7 @@ export function saveChannelConfig(
|
|||||||
enabled: transformedConfig.enabled ?? true,
|
enabled: transformedConfig.enabled ?? true,
|
||||||
};
|
};
|
||||||
|
|
||||||
writeOpenClawConfig(currentConfig);
|
await writeOpenClawConfig(currentConfig);
|
||||||
logger.info('Channel config saved', {
|
logger.info('Channel config saved', {
|
||||||
channelType,
|
channelType,
|
||||||
configFile: CONFIG_FILE,
|
configFile: CONFIG_FILE,
|
||||||
@@ -208,42 +202,26 @@ export function saveChannelConfig(
|
|||||||
console.log(`Saved channel config for ${channelType}`);
|
console.log(`Saved channel config for ${channelType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getChannelConfig(channelType: string): Promise<ChannelConfigData | undefined> {
|
||||||
* Get channel configuration
|
const config = await readOpenClawConfig();
|
||||||
* @param channelType - The channel type
|
|
||||||
*/
|
|
||||||
export function getChannelConfig(channelType: string): ChannelConfigData | undefined {
|
|
||||||
const config = readOpenClawConfig();
|
|
||||||
return config.channels?.[channelType];
|
return config.channels?.[channelType];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getChannelFormValues(channelType: string): Promise<Record<string, string> | undefined> {
|
||||||
* Get channel configuration as form-friendly values.
|
const saved = await getChannelConfig(channelType);
|
||||||
* 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);
|
|
||||||
if (!saved) return undefined;
|
if (!saved) return undefined;
|
||||||
|
|
||||||
const values: Record<string, string> = {};
|
const values: Record<string, string> = {};
|
||||||
|
|
||||||
if (channelType === 'discord') {
|
if (channelType === 'discord') {
|
||||||
// token is stored at top level
|
|
||||||
if (saved.token && typeof saved.token === 'string') {
|
if (saved.token && typeof saved.token === 'string') {
|
||||||
values.token = saved.token;
|
values.token = saved.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract guildId and channelId from the nested guilds structure
|
|
||||||
const guilds = saved.guilds as Record<string, Record<string, unknown>> | undefined;
|
const guilds = saved.guilds as Record<string, Record<string, unknown>> | undefined;
|
||||||
if (guilds) {
|
if (guilds) {
|
||||||
const guildIds = Object.keys(guilds);
|
const guildIds = Object.keys(guilds);
|
||||||
if (guildIds.length > 0) {
|
if (guildIds.length > 0) {
|
||||||
values.guildId = guildIds[0];
|
values.guildId = guildIds[0];
|
||||||
|
|
||||||
const guildConfig = guilds[guildIds[0]];
|
const guildConfig = guilds[guildIds[0]];
|
||||||
const channels = guildConfig?.channels as Record<string, unknown> | undefined;
|
const channels = guildConfig?.channels as Record<string, unknown> | undefined;
|
||||||
if (channels) {
|
if (channels) {
|
||||||
@@ -255,19 +233,15 @@ export function getChannelFormValues(channelType: string): Record<string, string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (channelType === 'telegram') {
|
} else if (channelType === 'telegram') {
|
||||||
// Special handling for Telegram: convert allowFrom array to allowedUsers string
|
|
||||||
if (Array.isArray(saved.allowFrom)) {
|
if (Array.isArray(saved.allowFrom)) {
|
||||||
values.allowedUsers = saved.allowFrom.join(', ');
|
values.allowedUsers = saved.allowFrom.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also extract other string values
|
|
||||||
for (const [key, value] of Object.entries(saved)) {
|
for (const [key, value] of Object.entries(saved)) {
|
||||||
if (typeof value === 'string' && key !== 'enabled') {
|
if (typeof value === 'string' && key !== 'enabled') {
|
||||||
values[key] = value;
|
values[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For other channel types, extract all string values directly
|
|
||||||
for (const [key, value] of Object.entries(saved)) {
|
for (const [key, value] of Object.entries(saved)) {
|
||||||
if (typeof value === 'string' && key !== 'enabled') {
|
if (typeof value === 'string' && key !== 'enabled') {
|
||||||
values[key] = value;
|
values[key] = value;
|
||||||
@@ -278,31 +252,23 @@ export function getChannelFormValues(channelType: string): Record<string, string
|
|||||||
return Object.keys(values).length > 0 ? values : undefined;
|
return Object.keys(values).length > 0 ? values : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function deleteChannelConfig(channelType: string): Promise<void> {
|
||||||
* Delete channel configuration
|
const currentConfig = await readOpenClawConfig();
|
||||||
* @param channelType - The channel type
|
|
||||||
*/
|
|
||||||
export function deleteChannelConfig(channelType: string): void {
|
|
||||||
const currentConfig = readOpenClawConfig();
|
|
||||||
|
|
||||||
if (currentConfig.channels?.[channelType]) {
|
if (currentConfig.channels?.[channelType]) {
|
||||||
delete currentConfig.channels[channelType];
|
delete currentConfig.channels[channelType];
|
||||||
writeOpenClawConfig(currentConfig);
|
await writeOpenClawConfig(currentConfig);
|
||||||
console.log(`Deleted channel config for ${channelType}`);
|
console.log(`Deleted channel config for ${channelType}`);
|
||||||
} else if (PLUGIN_CHANNELS.includes(channelType)) {
|
} else if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||||
// Handle plugin channels (like whatsapp)
|
|
||||||
if (currentConfig.plugins?.entries?.[channelType]) {
|
if (currentConfig.plugins?.entries?.[channelType]) {
|
||||||
delete currentConfig.plugins.entries[channelType];
|
delete currentConfig.plugins.entries[channelType];
|
||||||
|
|
||||||
// Cleanup empty objects
|
|
||||||
if (Object.keys(currentConfig.plugins.entries).length === 0) {
|
if (Object.keys(currentConfig.plugins.entries).length === 0) {
|
||||||
delete currentConfig.plugins.entries;
|
delete currentConfig.plugins.entries;
|
||||||
}
|
}
|
||||||
if (currentConfig.plugins && Object.keys(currentConfig.plugins).length === 0) {
|
if (currentConfig.plugins && Object.keys(currentConfig.plugins).length === 0) {
|
||||||
delete currentConfig.plugins;
|
delete currentConfig.plugins;
|
||||||
}
|
}
|
||||||
|
await writeOpenClawConfig(currentConfig);
|
||||||
writeOpenClawConfig(currentConfig);
|
|
||||||
console.log(`Deleted plugin channel config for ${channelType}`);
|
console.log(`Deleted plugin channel config for ${channelType}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,10 +276,9 @@ export function deleteChannelConfig(channelType: string): void {
|
|||||||
// Special handling for WhatsApp credentials
|
// Special handling for WhatsApp credentials
|
||||||
if (channelType === 'whatsapp') {
|
if (channelType === 'whatsapp') {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
||||||
if (existsSync(whatsappDir)) {
|
if (await fileExists(whatsappDir)) {
|
||||||
rmSync(whatsappDir, { recursive: true, force: true });
|
await rm(whatsappDir, { recursive: true, force: true });
|
||||||
console.log('Deleted WhatsApp credentials directory');
|
console.log('Deleted WhatsApp credentials directory');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -322,11 +287,8 @@ export function deleteChannelConfig(channelType: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function listConfiguredChannels(): Promise<string[]> {
|
||||||
* List all configured channels
|
const config = await readOpenClawConfig();
|
||||||
*/
|
|
||||||
export function listConfiguredChannels(): string[] {
|
|
||||||
const config = readOpenClawConfig();
|
|
||||||
const channels: string[] = [];
|
const channels: string[] = [];
|
||||||
|
|
||||||
if (config.channels) {
|
if (config.channels) {
|
||||||
@@ -338,14 +300,17 @@ export function listConfiguredChannels(): string[] {
|
|||||||
// Check for WhatsApp credentials directory
|
// Check for WhatsApp credentials directory
|
||||||
try {
|
try {
|
||||||
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
||||||
if (existsSync(whatsappDir)) {
|
if (await fileExists(whatsappDir)) {
|
||||||
const entries = readdirSync(whatsappDir);
|
const entries = await readdir(whatsappDir);
|
||||||
// Check if there's at least one directory (session)
|
const hasSession = await (async () => {
|
||||||
const hasSession = entries.some((entry: string) => {
|
for (const entry of entries) {
|
||||||
try {
|
try {
|
||||||
return statSync(join(whatsappDir, entry)).isDirectory();
|
const s = await stat(join(whatsappDir, entry));
|
||||||
} catch { return false; }
|
if (s.isDirectory()) return true;
|
||||||
});
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
|
||||||
if (hasSession && !channels.includes('whatsapp')) {
|
if (hasSession && !channels.includes('whatsapp')) {
|
||||||
channels.push('whatsapp');
|
channels.push('whatsapp');
|
||||||
@@ -358,42 +323,28 @@ export function listConfiguredChannels(): string[] {
|
|||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
|
||||||
* Enable or disable a channel
|
const currentConfig = await readOpenClawConfig();
|
||||||
*/
|
|
||||||
export function setChannelEnabled(channelType: string, enabled: boolean): void {
|
|
||||||
const currentConfig = readOpenClawConfig();
|
|
||||||
|
|
||||||
// Plugin-based channels go under plugins.entries
|
|
||||||
if (PLUGIN_CHANNELS.includes(channelType)) {
|
if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||||
if (!currentConfig.plugins) {
|
if (!currentConfig.plugins) currentConfig.plugins = {};
|
||||||
currentConfig.plugins = {};
|
if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {};
|
||||||
}
|
if (!currentConfig.plugins.entries[channelType]) currentConfig.plugins.entries[channelType] = {};
|
||||||
if (!currentConfig.plugins.entries) {
|
|
||||||
currentConfig.plugins.entries = {};
|
|
||||||
}
|
|
||||||
if (!currentConfig.plugins.entries[channelType]) {
|
|
||||||
currentConfig.plugins.entries[channelType] = {};
|
|
||||||
}
|
|
||||||
currentConfig.plugins.entries[channelType].enabled = enabled;
|
currentConfig.plugins.entries[channelType].enabled = enabled;
|
||||||
writeOpenClawConfig(currentConfig);
|
await writeOpenClawConfig(currentConfig);
|
||||||
console.log(`Set plugin channel ${channelType} enabled: ${enabled}`);
|
console.log(`Set plugin channel ${channelType} enabled: ${enabled}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentConfig.channels) {
|
if (!currentConfig.channels) currentConfig.channels = {};
|
||||||
currentConfig.channels = {};
|
if (!currentConfig.channels[channelType]) currentConfig.channels[channelType] = {};
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentConfig.channels[channelType]) {
|
|
||||||
currentConfig.channels[channelType] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
currentConfig.channels[channelType].enabled = enabled;
|
currentConfig.channels[channelType].enabled = enabled;
|
||||||
writeOpenClawConfig(currentConfig);
|
await writeOpenClawConfig(currentConfig);
|
||||||
console.log(`Set channel ${channelType} enabled: ${enabled}`);
|
console.log(`Set channel ${channelType} enabled: ${enabled}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Validation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
@@ -404,17 +355,9 @@ export interface CredentialValidationResult {
|
|||||||
valid: boolean;
|
valid: boolean;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
/** Extra info returned from the API (e.g. bot username, guild name) */
|
|
||||||
details?: Record<string, string>;
|
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(
|
export async function validateChannelCredentials(
|
||||||
channelType: string,
|
channelType: string,
|
||||||
config: Record<string, string>
|
config: Record<string, string>
|
||||||
@@ -425,14 +368,10 @@ export async function validateChannelCredentials(
|
|||||||
case 'telegram':
|
case 'telegram':
|
||||||
return validateTelegramCredentials(config);
|
return validateTelegramCredentials(config);
|
||||||
default:
|
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.'] };
|
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(
|
async function validateDiscordCredentials(
|
||||||
config: Record<string, string>
|
config: Record<string, string>
|
||||||
): Promise<CredentialValidationResult> {
|
): Promise<CredentialValidationResult> {
|
||||||
@@ -443,12 +382,10 @@ async function validateDiscordCredentials(
|
|||||||
return { valid: false, errors: ['Bot token is required'], warnings: [] };
|
return { valid: false, errors: ['Bot token is required'], warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Validate bot token by calling GET /users/@me
|
|
||||||
try {
|
try {
|
||||||
const meResponse = await fetch('https://discord.com/api/v10/users/@me', {
|
const meResponse = await fetch('https://discord.com/api/v10/users/@me', {
|
||||||
headers: { Authorization: `Bot ${token}` },
|
headers: { Authorization: `Bot ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!meResponse.ok) {
|
if (!meResponse.ok) {
|
||||||
if (meResponse.status === 401) {
|
if (meResponse.status === 401) {
|
||||||
return { valid: false, errors: ['Invalid bot token. Please check and try again.'], warnings: [] };
|
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}`;
|
const msg = (errorData as { message?: string }).message || `Discord API error: ${meResponse.status}`;
|
||||||
return { valid: false, errors: [msg], warnings: [] };
|
return { valid: false, errors: [msg], warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const meData = (await meResponse.json()) as { username?: string; id?: string; bot?: boolean };
|
const meData = (await meResponse.json()) as { username?: string; id?: string; bot?: boolean };
|
||||||
if (!meData.bot) {
|
if (!meData.bot) {
|
||||||
return {
|
return { valid: false, errors: ['The provided token belongs to a user account, not a bot. Please use a bot token.'], warnings: [] };
|
||||||
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!.botUsername = meData.username || 'Unknown';
|
||||||
result.details!.botId = meData.id || '';
|
result.details!.botId = meData.id || '';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return { valid: false, errors: [`Connection error when validating bot token: ${error instanceof Error ? error.message : String(error)}`], warnings: [] };
|
||||||
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();
|
const guildId = config.guildId?.trim();
|
||||||
if (guildId) {
|
if (guildId) {
|
||||||
try {
|
try {
|
||||||
const guildResponse = await fetch(`https://discord.com/api/v10/guilds/${guildId}`, {
|
const guildResponse = await fetch(`https://discord.com/api/v10/guilds/${guildId}`, {
|
||||||
headers: { Authorization: `Bot ${token}` },
|
headers: { Authorization: `Bot ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!guildResponse.ok) {
|
if (!guildResponse.ok) {
|
||||||
if (guildResponse.status === 403 || guildResponse.status === 404) {
|
if (guildResponse.status === 403 || guildResponse.status === 404) {
|
||||||
result.errors.push(
|
result.errors.push(`Cannot access guild (server) with ID "${guildId}". Make sure the bot has been invited to this server.`);
|
||||||
`Cannot access guild (server) with ID "${guildId}". Make sure the bot has been invited to this server.`
|
|
||||||
);
|
|
||||||
result.valid = false;
|
result.valid = false;
|
||||||
} else {
|
} else {
|
||||||
result.errors.push(`Failed to verify guild ID: Discord API returned ${guildResponse.status}`);
|
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();
|
const channelId = config.channelId?.trim();
|
||||||
if (channelId) {
|
if (channelId) {
|
||||||
try {
|
try {
|
||||||
const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
|
const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
|
||||||
headers: { Authorization: `Bot ${token}` },
|
headers: { Authorization: `Bot ${token}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!channelResponse.ok) {
|
if (!channelResponse.ok) {
|
||||||
if (channelResponse.status === 403 || channelResponse.status === 404) {
|
if (channelResponse.status === 403 || channelResponse.status === 404) {
|
||||||
result.errors.push(
|
result.errors.push(`Cannot access channel with ID "${channelId}". Make sure the bot has permission to view this channel.`);
|
||||||
`Cannot access channel with ID "${channelId}". Make sure the bot has permission to view this channel.`
|
|
||||||
);
|
|
||||||
result.valid = false;
|
result.valid = false;
|
||||||
} else {
|
} else {
|
||||||
result.errors.push(`Failed to verify channel ID: Discord API returned ${channelResponse.status}`);
|
result.errors.push(`Failed to verify channel ID: Discord API returned ${channelResponse.status}`);
|
||||||
@@ -524,12 +444,8 @@ async function validateDiscordCredentials(
|
|||||||
} else {
|
} else {
|
||||||
const channelData = (await channelResponse.json()) as { name?: string; guild_id?: string };
|
const channelData = (await channelResponse.json()) as { name?: string; guild_id?: string };
|
||||||
result.details!.channelName = channelData.name || 'Unknown';
|
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) {
|
if (guildId && channelData.guild_id && channelData.guild_id !== guildId) {
|
||||||
result.errors.push(
|
result.errors.push(`Channel "${channelData.name}" does not belong to the specified guild. It belongs to a different server.`);
|
||||||
`Channel "${channelData.name}" does not belong to the specified guild. It belongs to a different server.`
|
|
||||||
);
|
|
||||||
result.valid = false;
|
result.valid = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -541,80 +457,52 @@ async function validateDiscordCredentials(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate Telegram bot token
|
|
||||||
*/
|
|
||||||
async function validateTelegramCredentials(
|
async function validateTelegramCredentials(
|
||||||
config: Record<string, string>
|
config: Record<string, string>
|
||||||
): Promise<CredentialValidationResult> {
|
): Promise<CredentialValidationResult> {
|
||||||
const botToken = config.botToken?.trim();
|
const botToken = config.botToken?.trim();
|
||||||
|
|
||||||
const allowedUsers = config.allowedUsers?.trim();
|
const allowedUsers = config.allowedUsers?.trim();
|
||||||
|
|
||||||
if (!botToken) {
|
if (!botToken) return { valid: false, errors: ['Bot token is required'], warnings: [] };
|
||||||
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 (!allowedUsers) {
|
|
||||||
return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://api.telegram.org/bot${botToken}/getMe`);
|
const response = await fetch(`https://api.telegram.org/bot${botToken}/getMe`);
|
||||||
const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } };
|
const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } };
|
||||||
|
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
return {
|
return { valid: true, errors: [], warnings: [], details: { botUsername: data.result?.username || 'Unknown' } };
|
||||||
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) {
|
} catch (error) {
|
||||||
return {
|
return { valid: false, errors: [`Connection error: ${error instanceof Error ? error.message : String(error)}`], warnings: [] };
|
||||||
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> {
|
export async function validateChannelConfig(channelType: string): Promise<ValidationResult> {
|
||||||
const { execSync } = await import('child_process');
|
const { exec } = await import('child_process');
|
||||||
|
|
||||||
const result: ValidationResult = {
|
const result: ValidationResult = { valid: true, errors: [], warnings: [] };
|
||||||
valid: true,
|
|
||||||
errors: [],
|
|
||||||
warnings: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get OpenClaw path
|
|
||||||
const openclawPath = getOpenClawResolvedDir();
|
const openclawPath = getOpenClawResolvedDir();
|
||||||
|
|
||||||
// Run openclaw doctor command to validate config
|
// Run openclaw doctor command to validate config (async to avoid
|
||||||
const output = execSync(
|
// blocking the main thread).
|
||||||
`node openclaw.mjs doctor --json 2>&1`,
|
const output = await new Promise<string>((resolve, reject) => {
|
||||||
{
|
exec(
|
||||||
cwd: openclawPath,
|
`node openclaw.mjs doctor --json 2>&1`,
|
||||||
encoding: 'utf-8',
|
{
|
||||||
timeout: 30000,
|
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');
|
const lines = output.split('\n');
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const lowerLine = line.toLowerCase();
|
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 = await readOpenClawConfig();
|
||||||
const config = readOpenClawConfig();
|
|
||||||
if (!config.channels?.[channelType]) {
|
if (!config.channels?.[channelType]) {
|
||||||
result.errors.push(`Channel ${channelType} is not configured`);
|
result.errors.push(`Channel ${channelType} is not configured`);
|
||||||
result.valid = false;
|
result.valid = false;
|
||||||
@@ -638,7 +525,6 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
|||||||
result.warnings.push(`Channel ${channelType} is disabled`);
|
result.warnings.push(`Channel ${channelType} is disabled`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel-specific validation
|
|
||||||
if (channelType === 'discord') {
|
if (channelType === 'discord') {
|
||||||
const discordConfig = config.channels?.discord;
|
const discordConfig = config.channels?.discord;
|
||||||
if (!discordConfig?.token) {
|
if (!discordConfig?.token) {
|
||||||
@@ -651,7 +537,6 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
|||||||
result.errors.push('Telegram: Bot token is required');
|
result.errors.push('Telegram: Bot token is required');
|
||||||
result.valid = false;
|
result.valid = false;
|
||||||
}
|
}
|
||||||
// Check allowed users (stored as allowFrom array)
|
|
||||||
const allowedUsers = telegramConfig?.allowFrom as string[] | undefined;
|
const allowedUsers = telegramConfig?.allowFrom as string[] | undefined;
|
||||||
if (!allowedUsers || allowedUsers.length === 0) {
|
if (!allowedUsers || allowedUsers.length === 0) {
|
||||||
result.errors.push('Telegram: Allowed User IDs are required');
|
result.errors.push('Telegram: Allowed User IDs are required');
|
||||||
@@ -666,7 +551,6 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(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')) {
|
if (errorMessage.includes('Unrecognized key') || errorMessage.includes('invalid config')) {
|
||||||
result.errors.push(errorMessage);
|
result.errors.push(errorMessage);
|
||||||
result.valid = false;
|
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.errors.push('OpenClaw not found. Please ensure OpenClaw is installed.');
|
||||||
result.valid = false;
|
result.valid = false;
|
||||||
} else {
|
} else {
|
||||||
// Doctor command might fail but config could still be valid
|
|
||||||
// Just log it and do basic validation
|
|
||||||
console.warn('Doctor command failed:', errorMessage);
|
console.warn('Doctor command failed:', errorMessage);
|
||||||
|
const config = await readOpenClawConfig();
|
||||||
const config = readOpenClawConfig();
|
|
||||||
if (config.channels?.[channelType]) {
|
if (config.channels?.[channelType]) {
|
||||||
result.valid = true;
|
result.valid = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -689,4 +570,4 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,13 @@
|
|||||||
* OpenClaw Gateway 2026.2.15+ requires a signed device identity in the
|
* OpenClaw Gateway 2026.2.15+ requires a signed device identity in the
|
||||||
* connect handshake to grant scopes (operator.read, operator.write, etc.).
|
* connect handshake to grant scopes (operator.read, operator.write, etc.).
|
||||||
* Without a device, the gateway strips all requested scopes.
|
* 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 crypto from 'crypto';
|
||||||
import fs from 'fs';
|
import { access, readFile, writeFile, mkdir, chmod } from 'fs/promises';
|
||||||
|
import { constants } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export interface DeviceIdentity {
|
export interface DeviceIdentity {
|
||||||
@@ -49,8 +53,21 @@ function fingerprintPublicKey(publicKeyPem: string): string {
|
|||||||
return crypto.createHash('sha256').update(raw).digest('hex');
|
return crypto.createHash('sha256').update(raw).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateIdentity(): DeviceIdentity {
|
/** Non-throwing async existence check. */
|
||||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
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 publicKeyPem = (publicKey.export({ type: 'spki', format: 'pem' }) as Buffer).toString();
|
||||||
const privateKeyPem = (privateKey.export({ type: 'pkcs8', format: 'pem' }) as Buffer).toString();
|
const privateKeyPem = (privateKey.export({ type: 'pkcs8', format: 'pem' }) as Buffer).toString();
|
||||||
return {
|
return {
|
||||||
@@ -63,11 +80,13 @@ function generateIdentity(): DeviceIdentity {
|
|||||||
/**
|
/**
|
||||||
* Load device identity from disk, or create and persist a new one.
|
* Load device identity from disk, or create and persist a new one.
|
||||||
* The identity file is stored at `filePath` with mode 0o600.
|
* 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 {
|
try {
|
||||||
if (fs.existsSync(filePath)) {
|
if (await fileExists(filePath)) {
|
||||||
const raw = fs.readFileSync(filePath, 'utf8');
|
const raw = await readFile(filePath, 'utf8');
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (
|
if (
|
||||||
parsed?.version === 1 &&
|
parsed?.version === 1 &&
|
||||||
@@ -78,7 +97,7 @@ export function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity {
|
|||||||
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
||||||
if (derivedId && derivedId !== parsed.deviceId) {
|
if (derivedId && derivedId !== parsed.deviceId) {
|
||||||
const updated = { ...parsed, deviceId: derivedId };
|
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: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
|
||||||
}
|
}
|
||||||
return { deviceId: parsed.deviceId, 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
|
// fall through to create a new identity
|
||||||
}
|
}
|
||||||
|
|
||||||
const identity = generateIdentity();
|
const identity = await generateIdentity();
|
||||||
const dir = path.dirname(filePath);
|
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() };
|
const stored = { version: 1, ...identity, createdAtMs: Date.now() };
|
||||||
fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
await writeFile(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
||||||
try { fs.chmodSync(filePath, 0o600); } catch { /* ignore */ }
|
try { await chmod(filePath, 0o600); } catch { /* ignore */ }
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ class DeviceOAuthManager extends EventEmitter {
|
|||||||
// so OpenClaw's gateway auto-refresher knows how to find it.
|
// so OpenClaw's gateway auto-refresher knows how to find it.
|
||||||
try {
|
try {
|
||||||
const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType;
|
const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType;
|
||||||
saveOAuthTokenToOpenClaw(tokenProviderId, {
|
await saveOAuthTokenToOpenClaw(tokenProviderId, {
|
||||||
access: token.access,
|
access: token.access,
|
||||||
refresh: token.refresh,
|
refresh: token.refresh,
|
||||||
expires: token.expires,
|
expires: token.expires,
|
||||||
@@ -230,7 +230,7 @@ class DeviceOAuthManager extends EventEmitter {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType;
|
const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType;
|
||||||
setOpenClawDefaultModelWithOverride(tokenProviderId, undefined, {
|
await setOpenClawDefaultModelWithOverride(tokenProviderId, undefined, {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
api: token.api,
|
api: token.api,
|
||||||
// Tells OpenClaw's anthropic adapter to use `Authorization: Bearer` instead of `x-api-key`
|
// Tells OpenClaw's anthropic adapter to use `Authorization: Bearer` instead of `x-api-key`
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Logger Utility
|
* 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 { app } from 'electron';
|
||||||
import { join } from 'path';
|
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
|
* Log levels
|
||||||
@@ -19,7 +25,11 @@ export enum LogLevel {
|
|||||||
/**
|
/**
|
||||||
* Current log level (can be changed at runtime)
|
* 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
|
* Log file path
|
||||||
@@ -33,11 +43,58 @@ let logDir: string | null = null;
|
|||||||
const RING_BUFFER_SIZE = 500;
|
const RING_BUFFER_SIZE = 500;
|
||||||
const recentLogs: string[] = [];
|
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()
|
* Initialize logger — safe to call before app.isReady()
|
||||||
*/
|
*/
|
||||||
export function initLogger(): void {
|
export function initLogger(): void {
|
||||||
try {
|
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');
|
logDir = join(app.getPath('userData'), 'logs');
|
||||||
|
|
||||||
if (!existsSync(logDir)) {
|
if (!existsSync(logDir)) {
|
||||||
@@ -47,7 +104,7 @@ export function initLogger(): void {
|
|||||||
const timestamp = new Date().toISOString().split('T')[0];
|
const timestamp = new Date().toISOString().split('T')[0];
|
||||||
logFilePath = join(logDir, `clawx-${timestamp}.log`);
|
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`;
|
const sessionHeader = `\n${'='.repeat(80)}\n[${new Date().toISOString()}] === ClawX Session Start (v${app.getVersion()}) ===\n${'='.repeat(80)}\n`;
|
||||||
appendFileSync(logFilePath, sessionHeader);
|
appendFileSync(logFilePath, sessionHeader);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -55,30 +112,22 @@ export function initLogger(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Level / path accessors ───────────────────────────────────────
|
||||||
* Set log level
|
|
||||||
*/
|
|
||||||
export function setLogLevel(level: LogLevel): void {
|
export function setLogLevel(level: LogLevel): void {
|
||||||
currentLevel = level;
|
currentLevel = level;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get log file directory path
|
|
||||||
*/
|
|
||||||
export function getLogDir(): string | null {
|
export function getLogDir(): string | null {
|
||||||
return logDir;
|
return logDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current log file path
|
|
||||||
*/
|
|
||||||
export function getLogFilePath(): string | null {
|
export function getLogFilePath(): string | null {
|
||||||
return logFilePath;
|
return logFilePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Formatting ───────────────────────────────────────────────────
|
||||||
* Format log message
|
|
||||||
*/
|
|
||||||
function formatMessage(level: string, message: string, ...args: unknown[]): string {
|
function formatMessage(level: string, message: string, ...args: unknown[]): string {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const formattedArgs = args.length > 0 ? ' ' + args.map(arg => {
|
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}`;
|
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 {
|
function writeLog(formatted: string): void {
|
||||||
// Ring buffer
|
// Ring buffer (always synchronous — in-memory only)
|
||||||
recentLogs.push(formatted);
|
recentLogs.push(formatted);
|
||||||
if (recentLogs.length > RING_BUFFER_SIZE) {
|
if (recentLogs.length > RING_BUFFER_SIZE) {
|
||||||
recentLogs.shift();
|
recentLogs.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// File
|
// Async file write via buffer
|
||||||
if (logFilePath) {
|
if (logFilePath) {
|
||||||
try {
|
writeBuffer.push(formatted + '\n');
|
||||||
appendFileSync(logFilePath, formatted + '\n');
|
if (writeBuffer.length >= FLUSH_SIZE_THRESHOLD) {
|
||||||
} catch {
|
// Buffer is large enough — flush immediately (non-blocking)
|
||||||
// Silently fail if we can't write to file
|
void flushBuffer();
|
||||||
|
} else if (!flushTimer) {
|
||||||
|
// Schedule a flush after a short delay
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = null;
|
||||||
|
void flushBuffer();
|
||||||
|
}, FLUSH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Public log methods ───────────────────────────────────────────
|
||||||
* Log debug message
|
|
||||||
*/
|
|
||||||
export function debug(message: string, ...args: unknown[]): void {
|
export function debug(message: string, ...args: unknown[]): void {
|
||||||
if (currentLevel <= LogLevel.DEBUG) {
|
if (currentLevel <= LogLevel.DEBUG) {
|
||||||
const formatted = formatMessage('DEBUG', message, ...args);
|
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 {
|
export function info(message: string, ...args: unknown[]): void {
|
||||||
if (currentLevel <= LogLevel.INFO) {
|
if (currentLevel <= LogLevel.INFO) {
|
||||||
const formatted = formatMessage('INFO', message, ...args);
|
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 {
|
export function warn(message: string, ...args: unknown[]): void {
|
||||||
if (currentLevel <= LogLevel.WARN) {
|
if (currentLevel <= LogLevel.WARN) {
|
||||||
const formatted = formatMessage('WARN', message, ...args);
|
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 {
|
export function error(message: string, ...args: unknown[]): void {
|
||||||
if (currentLevel <= LogLevel.ERROR) {
|
if (currentLevel <= LogLevel.ERROR) {
|
||||||
const formatted = formatMessage('ERROR', message, ...args);
|
const formatted = formatMessage('ERROR', message, ...args);
|
||||||
@@ -162,11 +209,8 @@ export function error(message: string, ...args: unknown[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Log retrieval (for UI / diagnostics) ─────────────────────────
|
||||||
* 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)
|
|
||||||
*/
|
|
||||||
export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
|
export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
|
||||||
const filtered = minLevel != null
|
const filtered = minLevel != null
|
||||||
? recentLogs.filter(line => {
|
? 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 {
|
export async function readLogFile(tailLines = 200): Promise<string> {
|
||||||
if (!logFilePath || !existsSync(logFilePath)) {
|
if (!logFilePath) return '(No log file found)';
|
||||||
return '(No log file found)';
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(logFilePath, 'utf-8');
|
const content = await readFile(logFilePath, 'utf-8');
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
if (lines.length <= tailLines) return content;
|
if (lines.length <= tailLines) return content;
|
||||||
return lines.slice(-tailLines).join('\n');
|
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 }> {
|
export async function listLogFiles(): Promise<Array<{ name: string; path: string; size: number; modified: string }>> {
|
||||||
if (!logDir || !existsSync(logDir)) return [];
|
if (!logDir) return [];
|
||||||
try {
|
try {
|
||||||
return readdirSync(logDir)
|
const files = await readdir(logDir);
|
||||||
.filter(f => f.endsWith('.log'))
|
const results: Array<{ name: string; path: string; size: number; modified: string }> = [];
|
||||||
.map(f => {
|
for (const f of files) {
|
||||||
const fullPath = join(logDir!, f);
|
if (!f.endsWith('.log')) continue;
|
||||||
const stat = statSync(fullPath);
|
const fullPath = join(logDir, f);
|
||||||
return {
|
const s = await stat(fullPath);
|
||||||
name: f,
|
results.push({
|
||||||
path: fullPath,
|
name: f,
|
||||||
size: stat.size,
|
path: fullPath,
|
||||||
modified: stat.mtime.toISOString(),
|
size: s.size,
|
||||||
};
|
modified: s.mtime.toISOString(),
|
||||||
})
|
});
|
||||||
.sort((a, b) => b.modified.localeCompare(a.modified));
|
}
|
||||||
|
return results.sort((a, b) => b.modified.localeCompare(a.modified));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
* OpenClaw Auth Profiles Utility
|
* OpenClaw Auth Profiles Utility
|
||||||
* Writes API keys to ~/.openclaw/agents/main/agent/auth-profiles.json
|
* Writes API keys to ~/.openclaw/agents/main/agent/auth-profiles.json
|
||||||
* so the OpenClaw Gateway can load them for AI provider calls.
|
* 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 { join } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import {
|
import {
|
||||||
@@ -15,18 +21,50 @@ import {
|
|||||||
const AUTH_STORE_VERSION = 1;
|
const AUTH_STORE_VERSION = 1;
|
||||||
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
|
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
|
||||||
|
|
||||||
/**
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
* Auth profile entry for an API key
|
|
||||||
*/
|
/** 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 {
|
interface AuthProfileEntry {
|
||||||
type: 'api_key';
|
type: 'api_key';
|
||||||
provider: string;
|
provider: string;
|
||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth profile entry for an OAuth token (matches OpenClaw plugin format)
|
|
||||||
*/
|
|
||||||
interface OAuthProfileEntry {
|
interface OAuthProfileEntry {
|
||||||
type: 'oauth';
|
type: 'oauth';
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -35,9 +73,6 @@ interface OAuthProfileEntry {
|
|||||||
expires: number;
|
expires: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth profiles store format
|
|
||||||
*/
|
|
||||||
interface AuthProfilesStore {
|
interface AuthProfilesStore {
|
||||||
version: number;
|
version: number;
|
||||||
profiles: Record<string, AuthProfileEntry | OAuthProfileEntry>;
|
profiles: Record<string, AuthProfileEntry | OAuthProfileEntry>;
|
||||||
@@ -45,90 +80,78 @@ interface AuthProfilesStore {
|
|||||||
lastGood?: Record<string, string>;
|
lastGood?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Auth Profiles I/O ────────────────────────────────────────────
|
||||||
* Get the path to the auth-profiles.json for a given agent
|
|
||||||
*/
|
|
||||||
function getAuthProfilesPath(agentId = 'main'): string {
|
function getAuthProfilesPath(agentId = 'main'): string {
|
||||||
return join(homedir(), '.openclaw', 'agents', agentId, 'agent', AUTH_PROFILE_FILENAME);
|
return join(homedir(), '.openclaw', 'agents', agentId, 'agent', AUTH_PROFILE_FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
|
||||||
* Read existing auth profiles store, or create an empty one
|
|
||||||
*/
|
|
||||||
function readAuthProfiles(agentId = 'main'): AuthProfilesStore {
|
|
||||||
const filePath = getAuthProfilesPath(agentId);
|
const filePath = getAuthProfilesPath(agentId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (existsSync(filePath)) {
|
const data = await readJsonFile<AuthProfilesStore>(filePath);
|
||||||
const raw = readFileSync(filePath, 'utf-8');
|
if (data?.version && data.profiles && typeof data.profiles === 'object') {
|
||||||
const data = JSON.parse(raw) as AuthProfilesStore;
|
return data;
|
||||||
// Validate basic structure
|
|
||||||
if (data.version && data.profiles && typeof data.profiles === 'object') {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to read auth-profiles.json, creating fresh store:', 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: {},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Promise<void> {
|
||||||
* Write auth profiles store to disk
|
await writeJsonFile(getAuthProfilesPath(agentId), store);
|
||||||
*/
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Agent Discovery ──────────────────────────────────────────────
|
||||||
* Discover all agent IDs that have an agent/ subdirectory.
|
|
||||||
*/
|
async function discoverAgentIds(): Promise<string[]> {
|
||||||
function discoverAgentIds(): string[] {
|
|
||||||
const agentsDir = join(homedir(), '.openclaw', 'agents');
|
const agentsDir = join(homedir(), '.openclaw', 'agents');
|
||||||
try {
|
try {
|
||||||
if (!existsSync(agentsDir)) return ['main'];
|
if (!(await fileExists(agentsDir))) return ['main'];
|
||||||
return readdirSync(agentsDir, { withFileTypes: true })
|
const entries: Dirent[] = await readdir(agentsDir, { withFileTypes: true });
|
||||||
.filter((d) => d.isDirectory() && existsSync(join(agentsDir, d.name, 'agent')))
|
const ids: string[] = [];
|
||||||
.map((d) => d.name);
|
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 {
|
} catch {
|
||||||
return ['main'];
|
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.
|
* 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,
|
provider: string,
|
||||||
token: { access: string; refresh: string; expires: number },
|
token: { access: string; refresh: string; expires: number },
|
||||||
agentId?: string
|
agentId?: string
|
||||||
): void {
|
): Promise<void> {
|
||||||
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
const agentIds = agentId ? [agentId] : await discoverAgentIds();
|
||||||
if (agentIds.length === 0) agentIds.push('main');
|
if (agentIds.length === 0) agentIds.push('main');
|
||||||
|
|
||||||
for (const id of agentIds) {
|
for (const id of agentIds) {
|
||||||
const store = readAuthProfiles(id);
|
const store = await readAuthProfiles(id);
|
||||||
const profileId = `${provider}:default`;
|
const profileId = `${provider}:default`;
|
||||||
|
|
||||||
const entry: OAuthProfileEntry = {
|
store.profiles[profileId] = {
|
||||||
type: 'oauth',
|
type: 'oauth',
|
||||||
provider,
|
provider,
|
||||||
access: token.access,
|
access: token.access,
|
||||||
@@ -136,8 +159,6 @@ export function saveOAuthTokenToOpenClaw(
|
|||||||
expires: token.expires,
|
expires: token.expires,
|
||||||
};
|
};
|
||||||
|
|
||||||
store.profiles[profileId] = entry;
|
|
||||||
|
|
||||||
if (!store.order) store.order = {};
|
if (!store.order) store.order = {};
|
||||||
if (!store.order[provider]) store.order[provider] = [];
|
if (!store.order[provider]) store.order[provider] = [];
|
||||||
if (!store.order[provider].includes(profileId)) {
|
if (!store.order[provider].includes(profileId)) {
|
||||||
@@ -147,9 +168,8 @@ export function saveOAuthTokenToOpenClaw(
|
|||||||
if (!store.lastGood) store.lastGood = {};
|
if (!store.lastGood) store.lastGood = {};
|
||||||
store.lastGood[provider] = profileId;
|
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(', ')})`);
|
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'
|
* @param agentId - Optional single agent ID to read from, defaults to 'main'
|
||||||
* @returns The OAuth token access string or null if not found
|
* @returns The OAuth token access string or null if not found
|
||||||
*/
|
*/
|
||||||
export function getOAuthTokenFromOpenClaw(
|
export async function getOAuthTokenFromOpenClaw(
|
||||||
provider: string,
|
provider: string,
|
||||||
agentId = 'main'
|
agentId = 'main'
|
||||||
): string | null {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const store = readAuthProfiles(agentId);
|
const store = await readAuthProfiles(agentId);
|
||||||
const profileId = `${provider}:default`;
|
const profileId = `${provider}:default`;
|
||||||
const profile = store.profiles[profileId];
|
const profile = store.profiles[profileId];
|
||||||
|
|
||||||
@@ -181,65 +201,36 @@ export function getOAuthTokenFromOpenClaw(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Save a provider API key to OpenClaw's auth-profiles.json
|
* 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,
|
provider: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
agentId?: string
|
agentId?: string
|
||||||
): void {
|
): Promise<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.
|
|
||||||
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||||
if (OAUTH_PROVIDERS.includes(provider) && !apiKey) {
|
if (OAUTH_PROVIDERS.includes(provider) && !apiKey) {
|
||||||
console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`);
|
console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
const agentIds = agentId ? [agentId] : await discoverAgentIds();
|
||||||
if (agentIds.length === 0) agentIds.push('main');
|
if (agentIds.length === 0) agentIds.push('main');
|
||||||
|
|
||||||
for (const id of agentIds) {
|
for (const id of agentIds) {
|
||||||
const store = readAuthProfiles(id);
|
const store = await readAuthProfiles(id);
|
||||||
|
|
||||||
// Profile ID follows OpenClaw convention: <provider>:default
|
|
||||||
const profileId = `${provider}:default`;
|
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) {
|
if (!store.order[provider]) store.order[provider] = [];
|
||||||
store.order = {};
|
|
||||||
}
|
|
||||||
if (!store.order[provider]) {
|
|
||||||
store.order[provider] = [];
|
|
||||||
}
|
|
||||||
if (!store.order[provider].includes(profileId)) {
|
if (!store.order[provider].includes(profileId)) {
|
||||||
store.order[provider].push(profileId);
|
store.order[provider].push(profileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set as last good
|
if (!store.lastGood) store.lastGood = {};
|
||||||
if (!store.lastGood) {
|
|
||||||
store.lastGood = {};
|
|
||||||
}
|
|
||||||
store.lastGood[provider] = profileId;
|
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(', ')})`);
|
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
|
* Remove a provider API key from OpenClaw auth-profiles.json
|
||||||
*/
|
*/
|
||||||
export function removeProviderKeyFromOpenClaw(
|
export async function removeProviderKeyFromOpenClaw(
|
||||||
provider: string,
|
provider: string,
|
||||||
agentId?: string
|
agentId?: string
|
||||||
): void {
|
): Promise<void> {
|
||||||
// OAuth providers have their credentials managed by OpenClaw plugins.
|
|
||||||
// Do NOT delete their auth-profiles entries.
|
|
||||||
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||||
if (OAUTH_PROVIDERS.includes(provider)) {
|
if (OAUTH_PROVIDERS.includes(provider)) {
|
||||||
console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`);
|
console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
const agentIds = agentId ? [agentId] : await discoverAgentIds();
|
||||||
if (agentIds.length === 0) agentIds.push('main');
|
if (agentIds.length === 0) agentIds.push('main');
|
||||||
|
|
||||||
for (const id of agentIds) {
|
for (const id of agentIds) {
|
||||||
const store = readAuthProfiles(id);
|
const store = await readAuthProfiles(id);
|
||||||
const profileId = `${provider}:default`;
|
const profileId = `${provider}:default`;
|
||||||
|
|
||||||
delete store.profiles[profileId];
|
delete store.profiles[profileId];
|
||||||
|
|
||||||
if (store.order?.[provider]) {
|
if (store.order?.[provider]) {
|
||||||
store.order[provider] = store.order[provider].filter((aid) => aid !== profileId);
|
store.order[provider] = store.order[provider].filter((aid) => aid !== profileId);
|
||||||
if (store.order[provider].length === 0) {
|
if (store.order[provider].length === 0) delete store.order[provider];
|
||||||
delete store.order[provider];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider];
|
||||||
|
|
||||||
if (store.lastGood?.[provider] === profileId) {
|
await writeAuthProfiles(store, id);
|
||||||
delete store.lastGood[provider];
|
|
||||||
}
|
|
||||||
|
|
||||||
writeAuthProfiles(store, id);
|
|
||||||
}
|
}
|
||||||
console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
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)
|
* 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
|
// 1. Remove from auth-profiles.json
|
||||||
const agentIds = discoverAgentIds();
|
const agentIds = await discoverAgentIds();
|
||||||
if (agentIds.length === 0) agentIds.push('main');
|
if (agentIds.length === 0) agentIds.push('main');
|
||||||
for (const id of agentIds) {
|
for (const id of agentIds) {
|
||||||
const store = readAuthProfiles(id);
|
const store = await readAuthProfiles(id);
|
||||||
const profileId = `${provider}:default`;
|
const profileId = `${provider}:default`;
|
||||||
if (store.profiles[profileId]) {
|
if (store.profiles[profileId]) {
|
||||||
delete 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.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Remove from models.json (per-agent model registry used by pi-ai directly)
|
// 2. Remove from models.json (per-agent model registry used by pi-ai directly)
|
||||||
for (const agentId of agentIds) {
|
for (const id of agentIds) {
|
||||||
const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json');
|
const modelsPath = join(homedir(), '.openclaw', 'agents', id, 'agent', 'models.json');
|
||||||
try {
|
try {
|
||||||
if (existsSync(modelsPath)) {
|
if (await fileExists(modelsPath)) {
|
||||||
const data = JSON.parse(readFileSync(modelsPath, 'utf-8')) as Record<string, unknown>;
|
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;
|
const providers = data.providers as Record<string, unknown> | undefined;
|
||||||
if (providers && providers[provider]) {
|
if (providers && providers[provider]) {
|
||||||
delete providers[provider];
|
delete providers[provider];
|
||||||
writeFileSync(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
console.log(`Removed models.json entry for provider "${provider}" (agent "${agentId}")`);
|
console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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
|
// 3. Remove from openclaw.json
|
||||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
|
||||||
try {
|
try {
|
||||||
if (existsSync(configPath)) {
|
const config = await readOpenClawJson();
|
||||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
let modified = false;
|
||||||
let modified = false;
|
|
||||||
|
|
||||||
// Disable plugin (for OAuth like qwen-portal-auth)
|
// Disable plugin (for OAuth like qwen-portal-auth)
|
||||||
if (config.plugins?.entries) {
|
const plugins = config.plugins as Record<string, unknown> | undefined;
|
||||||
const pluginName = `${provider}-auth`;
|
const entries = (plugins?.entries ?? {}) as Record<string, Record<string, unknown>>;
|
||||||
if (config.plugins.entries[pluginName]) {
|
const pluginName = `${provider}-auth`;
|
||||||
config.plugins.entries[pluginName].enabled = false;
|
if (entries[pluginName]) {
|
||||||
modified = true;
|
entries[pluginName].enabled = false;
|
||||||
console.log(`Disabled OpenClaw plugin: ${pluginName}`);
|
modified = true;
|
||||||
}
|
console.log(`Disabled OpenClaw plugin: ${pluginName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from models.providers
|
// Remove from models.providers
|
||||||
if (config.models?.providers?.[provider]) {
|
const models = config.models as Record<string, unknown> | undefined;
|
||||||
delete config.models.providers[provider];
|
const providers = (models?.providers ?? {}) as Record<string, unknown>;
|
||||||
modified = true;
|
if (providers[provider]) {
|
||||||
console.log(`Removed OpenClaw provider config: ${provider}`);
|
delete providers[provider];
|
||||||
}
|
modified = true;
|
||||||
|
console.log(`Removed OpenClaw provider config: ${provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (modified) {
|
if (modified) {
|
||||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
await writeOpenClawJson(config);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
|
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
|
||||||
@@ -361,37 +345,21 @@ export function removeProviderFromOpenClaw(provider: string): void {
|
|||||||
*/
|
*/
|
||||||
export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: string }>): Record<string, string> {
|
export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: string }>): Record<string, string> {
|
||||||
const env: Record<string, string> = {};
|
const env: Record<string, string> = {};
|
||||||
|
|
||||||
for (const { type, apiKey } of providers) {
|
for (const { type, apiKey } of providers) {
|
||||||
const envVar = getProviderEnvVar(type);
|
const envVar = getProviderEnvVar(type);
|
||||||
if (envVar && apiKey) {
|
if (envVar && apiKey) {
|
||||||
env[envVar] = apiKey;
|
env[envVar] = apiKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the OpenClaw config to use the given provider and model
|
* Update the OpenClaw config to use the given provider and model
|
||||||
* Writes to ~/.openclaw/openclaw.json
|
* 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 {
|
export async function setOpenClawDefaultModel(provider: string, modelOverride?: string): Promise<void> {
|
||||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
const config = await readOpenClawJson();
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = modelOverride || getProviderDefaultModel(provider);
|
const model = modelOverride || getProviderDefaultModel(provider);
|
||||||
if (!model) {
|
if (!model) {
|
||||||
@@ -404,7 +372,6 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
|||||||
: model;
|
: model;
|
||||||
|
|
||||||
// Set the default model for the agents
|
// 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 agents = (config.agents || {}) as Record<string, unknown>;
|
||||||
const defaults = (agents.defaults || {}) as Record<string, unknown>;
|
const defaults = (agents.defaults || {}) as Record<string, unknown>;
|
||||||
defaults.model = { primary: model };
|
defaults.model = { primary: model };
|
||||||
@@ -412,8 +379,6 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
|||||||
config.agents = agents;
|
config.agents = agents;
|
||||||
|
|
||||||
// Configure models.providers for providers that need explicit registration.
|
// 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);
|
const providerCfg = getProviderConfig(provider);
|
||||||
if (providerCfg) {
|
if (providerCfg) {
|
||||||
const models = (config.models || {}) as Record<string, unknown>;
|
const models = (config.models || {}) as Record<string, unknown>;
|
||||||
@@ -456,9 +421,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
|||||||
models.providers = providers;
|
models.providers = providers;
|
||||||
config.models = models;
|
config.models = models;
|
||||||
} else {
|
} else {
|
||||||
// Built-in provider: remove any stale models.providers entry that may
|
// Built-in provider: remove any stale models.providers entry
|
||||||
// have been written by an earlier version. Leaving it in place would
|
|
||||||
// override the native pi-ai catalog and can break streaming/auth.
|
|
||||||
const models = (config.models || {}) as Record<string, unknown>;
|
const models = (config.models || {}) as Record<string, unknown>;
|
||||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||||
if (providers[provider]) {
|
if (providers[provider]) {
|
||||||
@@ -471,18 +434,10 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
|||||||
|
|
||||||
// Ensure gateway mode is set
|
// Ensure gateway mode is set
|
||||||
const gateway = (config.gateway || {}) as Record<string, unknown>;
|
const gateway = (config.gateway || {}) as Record<string, unknown>;
|
||||||
if (!gateway.mode) {
|
if (!gateway.mode) gateway.mode = 'local';
|
||||||
gateway.mode = 'local';
|
|
||||||
}
|
|
||||||
config.gateway = gateway;
|
config.gateway = gateway;
|
||||||
|
|
||||||
// Ensure directory exists
|
await writeOpenClawJson(config);
|
||||||
const dir = join(configPath, '..');
|
|
||||||
if (!existsSync(dir)) {
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
||||||
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`);
|
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
|
* Register or update a provider's configuration in openclaw.json
|
||||||
* without changing the current default model.
|
* without changing the current default model.
|
||||||
*/
|
*/
|
||||||
export function syncProviderConfigToOpenClaw(
|
export async function syncProviderConfigToOpenClaw(
|
||||||
provider: string,
|
provider: string,
|
||||||
modelId: string | undefined,
|
modelId: string | undefined,
|
||||||
override: RuntimeProviderConfigOverride
|
override: RuntimeProviderConfigOverride
|
||||||
): void {
|
): Promise<void> {
|
||||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
const config = await readOpenClawJson();
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (override.baseUrl && override.api) {
|
if (override.baseUrl && override.api) {
|
||||||
const models = (config.models || {}) as Record<string, unknown>;
|
const models = (config.models || {}) as Record<string, unknown>;
|
||||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||||
|
|
||||||
const nextModels: Array<Record<string, unknown>> = [];
|
const nextModels: Array<Record<string, unknown>> = [];
|
||||||
if (modelId) {
|
if (modelId) nextModels.push({ id: modelId, name: modelId });
|
||||||
nextModels.push({ id: modelId, name: modelId });
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextProvider: Record<string, unknown> = {
|
const nextProvider: Record<string, unknown> = {
|
||||||
baseUrl: override.baseUrl,
|
baseUrl: override.baseUrl,
|
||||||
api: override.api,
|
api: override.api,
|
||||||
models: nextModels,
|
models: nextModels,
|
||||||
};
|
};
|
||||||
if (override.apiKeyEnv) {
|
if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv;
|
||||||
nextProvider.apiKey = override.apiKeyEnv;
|
|
||||||
}
|
|
||||||
if (override.headers && Object.keys(override.headers).length > 0) {
|
if (override.headers && Object.keys(override.headers).length > 0) {
|
||||||
nextProvider.headers = override.headers;
|
nextProvider.headers = override.headers;
|
||||||
}
|
}
|
||||||
@@ -543,40 +485,24 @@ export function syncProviderConfigToOpenClaw(
|
|||||||
// Ensure extension is enabled for oauth providers to prevent gateway wiping config
|
// Ensure extension is enabled for oauth providers to prevent gateway wiping config
|
||||||
if (provider === 'minimax-portal' || provider === 'qwen-portal') {
|
if (provider === 'minimax-portal' || provider === 'qwen-portal') {
|
||||||
const plugins = (config.plugins || {}) as Record<string, unknown>;
|
const plugins = (config.plugins || {}) as Record<string, unknown>;
|
||||||
const entries = (plugins.entries || {}) as Record<string, unknown>;
|
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
|
||||||
entries[`${provider}-auth`] = { enabled: true };
|
pEntries[`${provider}-auth`] = { enabled: true };
|
||||||
plugins.entries = entries;
|
plugins.entries = pEntries;
|
||||||
config.plugins = plugins;
|
config.plugins = plugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = join(configPath, '..');
|
await writeOpenClawJson(config);
|
||||||
if (!existsSync(dir)) {
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update OpenClaw model + provider config using runtime config values.
|
* 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,
|
provider: string,
|
||||||
modelOverride: string | undefined,
|
modelOverride: string | undefined,
|
||||||
override: RuntimeProviderConfigOverride
|
override: RuntimeProviderConfigOverride
|
||||||
): void {
|
): Promise<void> {
|
||||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
const config = await readOpenClawJson();
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = modelOverride || getProviderDefaultModel(provider);
|
const model = modelOverride || getProviderDefaultModel(provider);
|
||||||
if (!model) {
|
if (!model) {
|
||||||
@@ -598,23 +524,15 @@ export function setOpenClawDefaultModelWithOverride(
|
|||||||
const models = (config.models || {}) as Record<string, unknown>;
|
const models = (config.models || {}) as Record<string, unknown>;
|
||||||
const providers = (models.providers || {}) 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>> = [];
|
const nextModels: Array<Record<string, unknown>> = [];
|
||||||
if (modelId) {
|
if (modelId) nextModels.push({ id: modelId, name: modelId });
|
||||||
nextModels.push({ id: modelId, name: modelId });
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextProvider: Record<string, unknown> = {
|
const nextProvider: Record<string, unknown> = {
|
||||||
baseUrl: override.baseUrl,
|
baseUrl: override.baseUrl,
|
||||||
api: override.api,
|
api: override.api,
|
||||||
models: nextModels,
|
models: nextModels,
|
||||||
};
|
};
|
||||||
if (override.apiKeyEnv) {
|
if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv;
|
||||||
nextProvider.apiKey = override.apiKeyEnv;
|
|
||||||
}
|
|
||||||
if (override.headers && Object.keys(override.headers).length > 0) {
|
if (override.headers && Object.keys(override.headers).length > 0) {
|
||||||
nextProvider.headers = override.headers;
|
nextProvider.headers = override.headers;
|
||||||
}
|
}
|
||||||
@@ -628,48 +546,48 @@ export function setOpenClawDefaultModelWithOverride(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gateway = (config.gateway || {}) as Record<string, unknown>;
|
const gateway = (config.gateway || {}) as Record<string, unknown>;
|
||||||
if (!gateway.mode) {
|
if (!gateway.mode) gateway.mode = 'local';
|
||||||
gateway.mode = 'local';
|
|
||||||
}
|
|
||||||
config.gateway = gateway;
|
config.gateway = gateway;
|
||||||
|
|
||||||
// Ensure the extension plugin is marked as enabled in openclaw.json
|
// 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') {
|
if (provider === 'minimax-portal' || provider === 'qwen-portal') {
|
||||||
const plugins = (config.plugins || {}) as Record<string, unknown>;
|
const plugins = (config.plugins || {}) as Record<string, unknown>;
|
||||||
const entries = (plugins.entries || {}) as Record<string, unknown>;
|
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
|
||||||
entries[`${provider}-auth`] = { enabled: true };
|
pEntries[`${provider}-auth`] = { enabled: true };
|
||||||
plugins.entries = entries;
|
plugins.entries = pEntries;
|
||||||
config.plugins = plugins;
|
config.plugins = plugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dir = join(configPath, '..');
|
await writeOpenClawJson(config);
|
||||||
if (!existsSync(dir)) {
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
||||||
console.log(
|
console.log(
|
||||||
`Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)`
|
`Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a set of all active provider IDs configured in openclaw.json and auth-profiles.json.
|
* Get a set of all active provider IDs configured in openclaw.json.
|
||||||
* This is used to sync ClawX's local provider list with the actual OpenClaw engine state.
|
* 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 activeProviders = new Set<string>();
|
||||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
|
||||||
|
|
||||||
// 1. Read openclaw.json models.providers
|
|
||||||
try {
|
try {
|
||||||
if (existsSync(configPath)) {
|
const config = await readOpenClawJson();
|
||||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
||||||
const providers = config.models?.providers;
|
// 1. models.providers
|
||||||
if (providers && typeof providers === 'object') {
|
const providers = (config.models as Record<string, unknown> | undefined)?.providers;
|
||||||
for (const key of Object.keys(providers)) {
|
if (providers && typeof providers === 'object') {
|
||||||
activeProviders.add(key);
|
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);
|
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;
|
return activeProviders;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
|
||||||
/**
|
/**
|
||||||
* Write the ClawX gateway token into ~/.openclaw/openclaw.json so the
|
* Write the ClawX gateway token into ~/.openclaw/openclaw.json.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function syncGatewayTokenToConfig(token: string): void {
|
export async function syncGatewayTokenToConfig(token: string): Promise<void> {
|
||||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
const config = await readOpenClawJson();
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const gateway = (
|
const gateway = (
|
||||||
config.gateway && typeof config.gateway === 'object'
|
config.gateway && typeof config.gateway === 'object'
|
||||||
@@ -738,31 +622,15 @@ export function syncGatewayTokenToConfig(token: string): void {
|
|||||||
if (!gateway.mode) gateway.mode = 'local';
|
if (!gateway.mode) gateway.mode = 'local';
|
||||||
config.gateway = gateway;
|
config.gateway = gateway;
|
||||||
|
|
||||||
const dir = join(configPath, '..');
|
await writeOpenClawJson(config);
|
||||||
if (!existsSync(dir)) {
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
||||||
console.log('Synced gateway token to openclaw.json');
|
console.log('Synced gateway token to openclaw.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json with the
|
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json.
|
||||||
* "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.
|
|
||||||
*/
|
*/
|
||||||
export function syncBrowserConfigToOpenClaw(): void {
|
export async function syncBrowserConfigToOpenClaw(): Promise<void> {
|
||||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
const config = await readOpenClawJson();
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const browser = (
|
const browser = (
|
||||||
config.browser && typeof config.browser === 'object'
|
config.browser && typeof config.browser === 'object'
|
||||||
@@ -785,29 +653,14 @@ export function syncBrowserConfigToOpenClaw(): void {
|
|||||||
if (!changed) return;
|
if (!changed) return;
|
||||||
|
|
||||||
config.browser = browser;
|
config.browser = browser;
|
||||||
|
await writeOpenClawJson(config);
|
||||||
const dir = join(configPath, '..');
|
|
||||||
if (!existsSync(dir)) {
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
||||||
console.log('Synced browser config to openclaw.json');
|
console.log('Synced browser config to openclaw.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a provider entry in every discovered agent's models.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,
|
providerType: string,
|
||||||
entry: {
|
entry: {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
@@ -817,15 +670,13 @@ export function updateAgentModelProvider(
|
|||||||
/** When true, pi-ai sends Authorization: Bearer instead of x-api-key */
|
/** When true, pi-ai sends Authorization: Bearer instead of x-api-key */
|
||||||
authHeader?: boolean;
|
authHeader?: boolean;
|
||||||
}
|
}
|
||||||
): void {
|
): Promise<void> {
|
||||||
const agentIds = discoverAgentIds();
|
const agentIds = await discoverAgentIds();
|
||||||
for (const agentId of agentIds) {
|
for (const agentId of agentIds) {
|
||||||
const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json');
|
const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json');
|
||||||
let data: Record<string, unknown> = {};
|
let data: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
if (existsSync(modelsPath)) {
|
data = (await readJsonFile<Record<string, unknown>>(modelsPath)) ?? {};
|
||||||
data = JSON.parse(readFileSync(modelsPath, 'utf-8')) as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// corrupt / missing – start with an empty object
|
// corrupt / missing – start with an empty object
|
||||||
}
|
}
|
||||||
@@ -839,8 +690,6 @@ export function updateAgentModelProvider(
|
|||||||
? { ...providers[providerType] }
|
? { ...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)
|
const existingModels = Array.isArray(existing.models)
|
||||||
? (existing.models as Array<Record<string, unknown>>)
|
? (existing.models as Array<Record<string, unknown>>)
|
||||||
: [];
|
: [];
|
||||||
@@ -860,7 +709,7 @@ export function updateAgentModelProvider(
|
|||||||
data.providers = providers;
|
data.providers = providers;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
writeFileSync(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
await writeJsonFile(modelsPath, data);
|
||||||
console.log(`Updated models.json for agent "${agentId}" provider "${providerType}"`);
|
console.log(`Updated models.json for agent "${agentId}" provider "${providerType}"`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`Failed to update models.json for agent "${agentId}":`, 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 { join } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
@@ -7,6 +14,20 @@ import { getResourcesDir } from './paths';
|
|||||||
const CLAWX_BEGIN = '<!-- clawx:begin -->';
|
const CLAWX_BEGIN = '<!-- clawx:begin -->';
|
||||||
const CLAWX_END = '<!-- clawx:end -->';
|
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.
|
* Merge a ClawX context section into an existing file's content.
|
||||||
* If markers already exist, replaces the section in-place.
|
* 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';
|
return existing.trimEnd() + '\n\n' + wrapped + '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Workspace directory resolution ───────────────────────────────
|
||||||
* 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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all unique workspace directories from the openclaw config:
|
* Collect all unique workspace directories from the openclaw config:
|
||||||
* the defaults workspace, each agent's workspace, and any workspace-*
|
* the defaults workspace, each agent's workspace, and any workspace-*
|
||||||
* directories that already exist under ~/.openclaw/.
|
* directories that already exist under ~/.openclaw/.
|
||||||
*/
|
*/
|
||||||
function resolveAllWorkspaceDirs(): string[] {
|
async function resolveAllWorkspaceDirs(): Promise<string[]> {
|
||||||
const openclawDir = join(homedir(), '.openclaw');
|
const openclawDir = join(homedir(), '.openclaw');
|
||||||
const dirs = new Set<string>();
|
const dirs = new Set<string>();
|
||||||
|
|
||||||
const configPath = join(openclawDir, 'openclaw.json');
|
const configPath = join(openclawDir, 'openclaw.json');
|
||||||
try {
|
try {
|
||||||
if (existsSync(configPath)) {
|
if (await fileExists(configPath)) {
|
||||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
const config = JSON.parse(await readFile(configPath, 'utf-8'));
|
||||||
|
|
||||||
const defaultWs = config?.agents?.defaults?.workspace;
|
const defaultWs = config?.agents?.defaults?.workspace;
|
||||||
if (typeof defaultWs === 'string' && defaultWs.trim()) {
|
if (typeof defaultWs === 'string' && defaultWs.trim()) {
|
||||||
@@ -99,7 +79,8 @@ function resolveAllWorkspaceDirs(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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')) {
|
if (entry.isDirectory() && entry.name.startsWith('workspace')) {
|
||||||
dirs.add(join(openclawDir, entry.name));
|
dirs.add(join(openclawDir, entry.name));
|
||||||
}
|
}
|
||||||
@@ -115,49 +96,93 @@ function resolveAllWorkspaceDirs(): string[] {
|
|||||||
return [...dirs];
|
return [...dirs];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bootstrap file repair ────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronously merge ClawX context snippets into workspace bootstrap
|
* Detect and remove bootstrap .md files that contain only ClawX markers
|
||||||
* files that already exist on disk. Returns the number of target files
|
* with no meaningful OpenClaw content outside them.
|
||||||
* that were skipped because they don't exist yet.
|
|
||||||
*/
|
*/
|
||||||
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');
|
const contextDir = join(getResourcesDir(), 'context');
|
||||||
if (!existsSync(contextDir)) {
|
if (!(await fileExists(contextDir))) {
|
||||||
logger.debug('ClawX context directory not found, skipping context merge');
|
logger.debug('ClawX context directory not found, skipping context merge');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let files: string[];
|
let files: string[];
|
||||||
try {
|
try {
|
||||||
files = readdirSync(contextDir).filter((f) => f.endsWith('.clawx.md'));
|
files = (await readdir(contextDir)).filter((f) => f.endsWith('.clawx.md'));
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceDirs = resolveAllWorkspaceDirs();
|
const workspaceDirs = await resolveAllWorkspaceDirs();
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
|
||||||
for (const workspaceDir of workspaceDirs) {
|
for (const workspaceDir of workspaceDirs) {
|
||||||
if (!existsSync(workspaceDir)) {
|
await ensureDir(workspaceDir);
|
||||||
mkdirSync(workspaceDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const targetName = file.replace('.clawx.md', '.md');
|
const targetName = file.replace('.clawx.md', '.md');
|
||||||
const targetPath = join(workspaceDir, targetName);
|
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)`);
|
logger.debug(`Skipping ${targetName} in ${workspaceDir} (file does not exist yet, will be seeded by gateway)`);
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const section = readFileSync(join(contextDir, file), 'utf-8');
|
const section = await readFile(join(contextDir, file), 'utf-8');
|
||||||
const existing = readFileSync(targetPath, 'utf-8');
|
const existing = await readFile(targetPath, 'utf-8');
|
||||||
|
|
||||||
const merged = mergeClawXSection(existing, section);
|
const merged = mergeClawXSection(existing, section);
|
||||||
if (merged !== existing) {
|
if (merged !== existing) {
|
||||||
writeFileSync(targetPath, merged, 'utf-8');
|
await writeFile(targetPath, merged, 'utf-8');
|
||||||
logger.info(`Merged ClawX context into ${targetName} (${workspaceDir})`);
|
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
|
* Ensure ClawX context snippets are merged into the openclaw workspace
|
||||||
* bootstrap files. Reads `*.clawx.md` templates from resources/context/
|
* bootstrap files.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export async function ensureClawXContext(): Promise<void> {
|
export async function ensureClawXContext(): Promise<void> {
|
||||||
let skipped = mergeClawXContextOnce();
|
let skipped = await mergeClawXContextOnce();
|
||||||
if (skipped === 0) return;
|
if (skipped === 0) return;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
await new Promise((r) => setTimeout(r, RETRY_INTERVAL_MS));
|
await new Promise((r) => setTimeout(r, RETRY_INTERVAL_MS));
|
||||||
skipped = mergeClawXContextOnce();
|
skipped = await mergeClawXContextOnce();
|
||||||
if (skipped === 0) {
|
if (skipped === 0) {
|
||||||
logger.info(`ClawX context merge completed after ${attempt} retry(ies)`);
|
logger.info(`ClawX context merge completed after ${attempt} retry(ies)`);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export async function getAllProvidersWithKeyInfo(): Promise<
|
|||||||
> {
|
> {
|
||||||
const providers = await getAllProviders();
|
const providers = await getAllProviders();
|
||||||
const results: Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> = [];
|
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'
|
// We need to avoid deleting native ones like 'anthropic' or 'google'
|
||||||
// that don't need to exist in openclaw.json models.providers
|
// that don't need to exist in openclaw.json models.providers
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Skill Config Utilities
|
* Skill Config Utilities
|
||||||
* Direct read/write access to skill configuration in ~/.openclaw/openclaw.json
|
* 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 { join } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
|
|
||||||
@@ -23,15 +26,19 @@ interface OpenClawConfig {
|
|||||||
[key: string]: unknown;
|
[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
|
* Read the current OpenClaw config
|
||||||
*/
|
*/
|
||||||
function readConfig(): OpenClawConfig {
|
async function readConfig(): Promise<OpenClawConfig> {
|
||||||
if (!existsSync(OPENCLAW_CONFIG_PATH)) {
|
if (!(await fileExists(OPENCLAW_CONFIG_PATH))) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8');
|
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to read openclaw config:', err);
|
console.error('Failed to read openclaw config:', err);
|
||||||
@@ -42,28 +49,28 @@ function readConfig(): OpenClawConfig {
|
|||||||
/**
|
/**
|
||||||
* Write the OpenClaw config
|
* Write the OpenClaw config
|
||||||
*/
|
*/
|
||||||
function writeConfig(config: OpenClawConfig): void {
|
async function writeConfig(config: OpenClawConfig): Promise<void> {
|
||||||
const json = JSON.stringify(config, null, 2);
|
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
|
* Get skill config
|
||||||
*/
|
*/
|
||||||
export function getSkillConfig(skillKey: string): SkillEntry | undefined {
|
export async function getSkillConfig(skillKey: string): Promise<SkillEntry | undefined> {
|
||||||
const config = readConfig();
|
const config = await readConfig();
|
||||||
return config.skills?.entries?.[skillKey];
|
return config.skills?.entries?.[skillKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update skill config (apiKey and env)
|
* Update skill config (apiKey and env)
|
||||||
*/
|
*/
|
||||||
export function updateSkillConfig(
|
export async function updateSkillConfig(
|
||||||
skillKey: string,
|
skillKey: string,
|
||||||
updates: { apiKey?: string; env?: Record<string, string> }
|
updates: { apiKey?: string; env?: Record<string, string> }
|
||||||
): { success: boolean; error?: string } {
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const config = readConfig();
|
const config = await readConfig();
|
||||||
|
|
||||||
// Ensure skills.entries exists
|
// Ensure skills.entries exists
|
||||||
if (!config.skills) {
|
if (!config.skills) {
|
||||||
@@ -90,7 +97,6 @@ export function updateSkillConfig(
|
|||||||
if (updates.env !== undefined) {
|
if (updates.env !== undefined) {
|
||||||
const newEnv: Record<string, string> = {};
|
const newEnv: Record<string, string> = {};
|
||||||
|
|
||||||
// Process all keys from the update
|
|
||||||
for (const [key, value] of Object.entries(updates.env)) {
|
for (const [key, value] of Object.entries(updates.env)) {
|
||||||
const trimmedKey = key.trim();
|
const trimmedKey = key.trim();
|
||||||
if (!trimmedKey) continue;
|
if (!trimmedKey) continue;
|
||||||
@@ -99,10 +105,8 @@ export function updateSkillConfig(
|
|||||||
if (trimmedVal) {
|
if (trimmedVal) {
|
||||||
newEnv[trimmedKey] = 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) {
|
if (Object.keys(newEnv).length > 0) {
|
||||||
entry.env = newEnv;
|
entry.env = newEnv;
|
||||||
} else {
|
} else {
|
||||||
@@ -113,7 +117,7 @@ export function updateSkillConfig(
|
|||||||
// Save entry back
|
// Save entry back
|
||||||
config.skills.entries[skillKey] = entry;
|
config.skills.entries[skillKey] = entry;
|
||||||
|
|
||||||
writeConfig(config);
|
await writeConfig(config);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update skill config:', err);
|
console.error('Failed to update skill config:', err);
|
||||||
@@ -124,7 +128,7 @@ export function updateSkillConfig(
|
|||||||
/**
|
/**
|
||||||
* Get all skill configs (for syncing to frontend)
|
* Get all skill configs (for syncing to frontend)
|
||||||
*/
|
*/
|
||||||
export function getAllSkillConfigs(): Record<string, SkillEntry> {
|
export async function getAllSkillConfigs(): Promise<Record<string, SkillEntry>> {
|
||||||
const config = readConfig();
|
const config = await readConfig();
|
||||||
return config.skills?.entries || {};
|
return config.skills?.entries || {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawx",
|
"name": "clawx",
|
||||||
"version": "0.1.18",
|
"version": "0.1.19-alpha.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@whiskeysockets/baileys",
|
"@whiskeysockets/baileys",
|
||||||
@@ -107,4 +107,4 @@
|
|||||||
"zx": "^8.8.5"
|
"zx": "^8.8.5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
|
"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 }));
|
toast.success(t('toast.channelSaved', { name: meta.name }));
|
||||||
|
|
||||||
// Step 4: Restart the Gateway so it picks up the new channel config
|
// Gateway restart is now handled server-side via debouncedRestart()
|
||||||
// The Gateway watches the config file, but a restart ensures a clean start
|
// inside the channel:saveConfig IPC handler, so we don't need to
|
||||||
// especially when adding a channel for the first time.
|
// trigger it explicitly here. This avoids cascading restarts when
|
||||||
try {
|
// multiple config changes happen in quick succession (e.g. during
|
||||||
await window.electron.ipcRenderer.invoke('gateway:restart');
|
// the setup wizard).
|
||||||
toast.success(t('toast.channelConnecting', { name: meta.name }));
|
toast.success(t('toast.channelConnecting', { name: meta.name }));
|
||||||
} catch (restartError) {
|
|
||||||
console.warn('Gateway restart after channel config:', restartError);
|
|
||||||
toast.info(t('toast.restartManual'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brief delay so user can see the success state before dialog closes
|
// Brief delay so user can see the success state before dialog closes
|
||||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
|||||||
Reference in New Issue
Block a user