fix conflict

This commit is contained in:
paisley
2026-03-08 12:45:00 +08:00
54 changed files with 3069 additions and 210 deletions

View File

@@ -93,6 +93,8 @@ export class GatewayManager extends EventEmitter {
private readonly connectionMonitor = new GatewayConnectionMonitor();
private readonly lifecycleController = new GatewayLifecycleController();
private readonly restartController = new GatewayRestartController();
private reloadDebounceTimer: NodeJS.Timeout | null = null;
private externalShutdownSupported: boolean | null = null;
constructor(config?: Partial<ReconnectConfig>) {
super();
@@ -142,6 +144,10 @@ export class GatewayManager extends EventEmitter {
return sanitized;
}
private isUnsupportedShutdownError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /unknown method:\s*shutdown/i.test(message);
}
/**
* Get current Gateway status
*/
@@ -284,11 +290,17 @@ export class GatewayManager extends EventEmitter {
// If this manager is attached to an external gateway process, ask it to shut down
// over protocol before closing the socket.
if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN) {
if (!this.ownsProcess && this.ws?.readyState === WebSocket.OPEN && this.externalShutdownSupported !== false) {
try {
await this.rpc('shutdown', undefined, 5000);
this.externalShutdownSupported = true;
} catch (error) {
logger.warn('Failed to request shutdown for externally managed Gateway:', error);
if (this.isUnsupportedShutdownError(error)) {
this.externalShutdownSupported = false;
logger.info('External Gateway does not support "shutdown"; skipping shutdown RPC for future stops');
} else {
logger.warn('Failed to request shutdown for externally managed Gateway:', error);
}
}
}
@@ -377,6 +389,77 @@ export class GatewayManager extends EventEmitter {
});
}
/**
* Ask the Gateway process to reload config in-place when possible.
* Falls back to restart on unsupported platforms or signaling failures.
*/
async reload(): Promise<void> {
if (this.restartController.isRestartDeferred({
state: this.status.state,
startLock: this.startLock,
})) {
this.restartController.markDeferredRestart('reload', {
state: this.status.state,
startLock: this.startLock,
});
return;
}
if (!this.process?.pid || this.status.state !== 'running') {
logger.warn('Gateway reload requested while not running; falling back to restart');
await this.restart();
return;
}
if (process.platform === 'win32') {
logger.debug('Windows detected, falling back to Gateway restart for reload');
await this.restart();
return;
}
const connectedForMs = this.status.connectedAt
? Date.now() - this.status.connectedAt
: Number.POSITIVE_INFINITY;
// Avoid signaling a process that just came up; it will already read latest config.
if (connectedForMs < 8000) {
logger.info(`Gateway connected ${connectedForMs}ms ago, skipping reload signal`);
return;
}
try {
process.kill(this.process.pid, 'SIGUSR1');
logger.info(`Sent SIGUSR1 to Gateway for config reload (pid=${this.process.pid})`);
// Some gateway builds do not handle SIGUSR1 as an in-process reload.
// If process state doesn't recover quickly, fall back to restart.
await new Promise((resolve) => setTimeout(resolve, 1500));
if (this.status.state !== 'running' || !this.process?.pid) {
logger.warn('Gateway did not stay running after reload signal, falling back to restart');
await this.restart();
}
} catch (error) {
logger.warn('Gateway reload signal failed, falling back to restart:', error);
await this.restart();
}
}
/**
* Debounced reload — coalesces multiple rapid config-change events into one
* in-process reload when possible.
*/
debouncedReload(delayMs = 1200): void {
if (this.reloadDebounceTimer) {
clearTimeout(this.reloadDebounceTimer);
}
logger.debug(`Gateway reload debounced (will fire in ${delayMs}ms)`);
this.reloadDebounceTimer = setTimeout(() => {
this.reloadDebounceTimer = null;
void this.reload().catch((err) => {
logger.warn('Debounced Gateway reload failed:', err);
});
}, delayMs);
}
/**
* Clear all active timers
*/
@@ -387,6 +470,10 @@ export class GatewayManager extends EventEmitter {
}
this.connectionMonitor.clear();
this.restartController.clearDebounceTimer();
if (this.reloadDebounceTimer) {
clearTimeout(this.reloadDebounceTimer);
this.reloadDebounceTimer = null;
}
}
/**

View File

@@ -49,6 +49,25 @@ import {
syncUpdatedProviderToRuntime,
} from '../services/providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
import { appUpdater } from './updater';
type AppRequest = {
id?: string;
module: string;
action: string;
payload?: unknown;
};
type AppResponse = {
id?: string;
ok: boolean;
data?: unknown;
error?: {
code: 'VALIDATION' | 'PERMISSION' | 'TIMEOUT' | 'GATEWAY' | 'INTERNAL' | 'UNSUPPORTED';
message: string;
details?: unknown;
};
};
/**
* Register all IPC handlers
@@ -58,6 +77,9 @@ export function registerIpcHandlers(
clawHubService: ClawHubService,
mainWindow: BrowserWindow
): void {
// Unified request protocol (non-breaking: legacy channels remain available)
registerUnifiedRequestHandlers(gatewayManager);
// Gateway handlers
registerGatewayHandlers(gatewayManager, mainWindow);
@@ -65,7 +87,7 @@ export function registerIpcHandlers(
registerClawHubHandlers(clawHubService);
// OpenClaw handlers
registerOpenClawHandlers();
registerOpenClawHandlers(gatewayManager);
// Provider handlers
registerProviderHandlers(gatewayManager);
@@ -113,6 +135,545 @@ export function registerIpcHandlers(
registerFileHandlers();
}
function mapAppErrorCode(error: unknown): AppResponse['error']['code'] {
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('timeout')) return 'TIMEOUT';
if (msg.includes('permission') || msg.includes('denied') || msg.includes('forbidden')) return 'PERMISSION';
if (msg.includes('gateway')) return 'GATEWAY';
if (msg.includes('invalid') || msg.includes('required')) return 'VALIDATION';
return 'INTERNAL';
}
function isProxyKey(key: keyof AppSettings): boolean {
return (
key === 'proxyEnabled' ||
key === 'proxyServer' ||
key === 'proxyHttpServer' ||
key === 'proxyHttpsServer' ||
key === 'proxyAllServer' ||
key === 'proxyBypassRules'
);
}
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
const handleProxySettingsChange = async () => {
const settings = await getAllSettings();
await applyProxySettings(settings);
if (gatewayManager.getStatus().state === 'running') {
await gatewayManager.restart();
}
};
ipcMain.handle('app:request', async (_, request: AppRequest): Promise<AppResponse> => {
if (!request || typeof request.module !== 'string' || typeof request.action !== 'string') {
return {
id: request?.id,
ok: false,
error: { code: 'VALIDATION', message: 'Invalid app request format' },
};
}
try {
let data: unknown;
switch (request.module) {
case 'app': {
if (request.action === 'version') data = app.getVersion();
else if (request.action === 'name') data = app.getName();
else if (request.action === 'platform') data = process.platform;
else {
return {
id: request.id,
ok: false,
error: {
code: 'UNSUPPORTED',
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
},
};
}
break;
}
case 'provider': {
if (request.action === 'list') {
data = await getAllProvidersWithKeyInfo();
break;
}
if (request.action === 'get') {
const payload = request.payload as { providerId?: string } | string | undefined;
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
if (!providerId) throw new Error('Invalid provider.get payload');
data = await getProvider(providerId);
break;
}
if (request.action === 'getDefault') {
data = await getDefaultProvider();
break;
}
if (request.action === 'hasApiKey') {
const payload = request.payload as { providerId?: string } | string | undefined;
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
if (!providerId) throw new Error('Invalid provider.hasApiKey payload');
data = await hasApiKey(providerId);
break;
}
if (request.action === 'getApiKey') {
const payload = request.payload as { providerId?: string } | string | undefined;
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
if (!providerId) throw new Error('Invalid provider.getApiKey payload');
data = await getApiKey(providerId);
break;
}
if (request.action === 'validateKey') {
const payload = request.payload as
| { providerId?: string; apiKey?: string; options?: { baseUrl?: string } }
| [string, string, { baseUrl?: string }?]
| undefined;
const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId;
const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey;
const options = Array.isArray(payload) ? payload[2] : payload?.options;
if (!providerId || typeof apiKey !== 'string') {
throw new Error('Invalid provider.validateKey payload');
}
const provider = await getProvider(providerId);
const providerType = provider?.type || providerId;
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
const resolvedBaseUrl = options?.baseUrl || provider?.baseUrl || registryBaseUrl;
data = await validateApiKeyWithProvider(providerType, apiKey, { baseUrl: resolvedBaseUrl });
break;
}
if (request.action === 'save') {
const payload = request.payload as
| { config?: ProviderConfig; apiKey?: string }
| [ProviderConfig, string?]
| undefined;
const config = Array.isArray(payload) ? payload[0] : payload?.config;
const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey;
if (!config) throw new Error('Invalid provider.save payload');
try {
await saveProvider(config);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await storeApiKey(config.id, trimmedKey);
}
}
try {
await syncSavedProviderToRuntime(config, apiKey, gatewayManager);
} catch (err) {
console.warn('Failed to sync openclaw provider config:', err);
}
data = { success: true };
} catch (error) {
data = { success: false, error: String(error) };
}
break;
}
if (request.action === 'delete') {
const payload = request.payload as { providerId?: string } | string | undefined;
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
if (!providerId) throw new Error('Invalid provider.delete payload');
try {
const existing = await getProvider(providerId);
await deleteProvider(providerId);
if (existing?.type) {
try {
await syncDeletedProviderToRuntime(existing, providerId, gatewayManager);
} catch (err) {
console.warn('Failed to completely remove provider from OpenClaw:', err);
}
}
data = { success: true };
} catch (error) {
data = { success: false, error: String(error) };
}
break;
}
if (request.action === 'setApiKey') {
const payload = request.payload as
| { providerId?: string; apiKey?: string }
| [string, string]
| undefined;
const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId;
const apiKey = Array.isArray(payload) ? payload[1] : payload?.apiKey;
if (!providerId || typeof apiKey !== 'string') throw new Error('Invalid provider.setApiKey payload');
try {
await storeApiKey(providerId, apiKey);
const provider = await getProvider(providerId);
const providerType = provider?.type || providerId;
const ock = getOpenClawProviderKey(providerType, providerId);
try {
await saveProviderKeyToOpenClaw(ock, apiKey);
} catch (err) {
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
}
data = { success: true };
} catch (error) {
data = { success: false, error: String(error) };
}
break;
}
if (request.action === 'updateWithKey') {
const payload = request.payload as
| { providerId?: string; updates?: Partial<ProviderConfig>; apiKey?: string }
| [string, Partial<ProviderConfig>, string?]
| undefined;
const providerId = Array.isArray(payload) ? payload[0] : payload?.providerId;
const updates = Array.isArray(payload) ? payload[1] : payload?.updates;
const apiKey = Array.isArray(payload) ? payload[2] : payload?.apiKey;
if (!providerId || !updates) throw new Error('Invalid provider.updateWithKey payload');
const existing = await getProvider(providerId);
if (!existing) {
data = { success: false, error: 'Provider not found' };
break;
}
const previousKey = await getApiKey(providerId);
const previousOck = getOpenClawProviderKey(existing.type, providerId);
try {
const nextConfig: ProviderConfig = {
...existing,
...updates,
updatedAt: new Date().toISOString(),
};
const ock = getOpenClawProviderKey(nextConfig.type, providerId);
await saveProvider(nextConfig);
if (apiKey !== undefined) {
const trimmedKey = apiKey.trim();
if (trimmedKey) {
await storeApiKey(providerId, trimmedKey);
await saveProviderKeyToOpenClaw(ock, trimmedKey);
} else {
await deleteApiKey(providerId);
await removeProviderFromOpenClaw(ock);
}
}
try {
await syncUpdatedProviderToRuntime(nextConfig, apiKey, gatewayManager);
} catch (err) {
console.warn('Failed to sync openclaw config after provider update:', err);
}
data = { success: true };
} catch (error) {
try {
await saveProvider(existing);
if (previousKey) {
await storeApiKey(providerId, previousKey);
await saveProviderKeyToOpenClaw(previousOck, previousKey);
} else {
await deleteApiKey(providerId);
await removeProviderFromOpenClaw(previousOck);
}
} catch (rollbackError) {
console.warn('Failed to rollback provider updateWithKey:', rollbackError);
}
data = { success: false, error: String(error) };
}
break;
}
if (request.action === 'deleteApiKey') {
const payload = request.payload as { providerId?: string } | string | undefined;
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
if (!providerId) throw new Error('Invalid provider.deleteApiKey payload');
try {
await deleteApiKey(providerId);
const provider = await getProvider(providerId);
const providerType = provider?.type || providerId;
const ock = getOpenClawProviderKey(providerType, providerId);
try {
if (ock) {
await removeProviderFromOpenClaw(ock);
}
} catch (err) {
console.warn('Failed to completely remove provider from OpenClaw:', err);
}
data = { success: true };
} catch (error) {
data = { success: false, error: String(error) };
}
break;
}
if (request.action === 'setDefault') {
const payload = request.payload as { providerId?: string } | string | undefined;
const providerId = typeof payload === 'string' ? payload : payload?.providerId;
if (!providerId) throw new Error('Invalid provider.setDefault payload');
try {
await setDefaultProvider(providerId);
const provider = await getProvider(providerId);
if (provider) {
try {
await syncDefaultProviderToRuntime(providerId, gatewayManager);
} catch (err) {
console.warn('Failed to set OpenClaw default model:', err);
}
}
data = { success: true };
} catch (error) {
data = { success: false, error: String(error) };
}
break;
}
return {
id: request.id,
ok: false,
error: {
code: 'UNSUPPORTED',
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
},
};
}
case 'update': {
if (request.action === 'status') {
data = appUpdater.getStatus();
break;
}
if (request.action === 'version') {
data = appUpdater.getCurrentVersion();
break;
}
if (request.action === 'check') {
try {
await appUpdater.checkForUpdates();
data = { success: true, status: appUpdater.getStatus() };
} catch (error) {
data = { success: false, error: String(error), status: appUpdater.getStatus() };
}
break;
}
if (request.action === 'download') {
try {
await appUpdater.downloadUpdate();
data = { success: true };
} catch (error) {
data = { success: false, error: String(error) };
}
break;
}
if (request.action === 'install') {
appUpdater.quitAndInstall();
data = { success: true };
break;
}
if (request.action === 'setChannel') {
const payload = request.payload as { channel?: 'stable' | 'beta' | 'dev' } | 'stable' | 'beta' | 'dev' | undefined;
const channel = typeof payload === 'string' ? payload : payload?.channel;
if (!channel) throw new Error('Invalid update.setChannel payload');
appUpdater.setChannel(channel);
data = { success: true };
break;
}
if (request.action === 'setAutoDownload') {
const payload = request.payload as { enable?: boolean } | boolean | undefined;
const enable = typeof payload === 'boolean' ? payload : payload?.enable;
if (typeof enable !== 'boolean') throw new Error('Invalid update.setAutoDownload payload');
appUpdater.setAutoDownload(enable);
data = { success: true };
break;
}
if (request.action === 'cancelAutoInstall') {
appUpdater.cancelAutoInstall();
data = { success: true };
break;
}
return {
id: request.id,
ok: false,
error: {
code: 'UNSUPPORTED',
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
},
};
}
case 'cron': {
if (request.action === 'list') {
const result = await gatewayManager.rpc('cron.list', { includeDisabled: true });
const jobs = (result as { jobs?: GatewayCronJob[] })?.jobs ?? [];
data = jobs.map(transformCronJob);
break;
}
if (request.action === 'create') {
const payload = request.payload as
| { input?: { name: string; message: string; schedule: string; enabled?: boolean } }
| [{ name: string; message: string; schedule: string; enabled?: boolean }]
| { name: string; message: string; schedule: string; enabled?: boolean }
| undefined;
const input = Array.isArray(payload)
? payload[0]
: ('input' in (payload ?? {}) ? (payload as { input: { name: string; message: string; schedule: string; enabled?: boolean } }).input : payload);
if (!input) throw new Error('Invalid cron.create payload');
const gatewayInput = {
name: input.name,
schedule: { kind: 'cron', expr: input.schedule },
payload: { kind: 'agentTurn', message: input.message },
enabled: input.enabled ?? true,
wakeMode: 'next-heartbeat',
sessionTarget: 'isolated',
delivery: { mode: 'none' },
};
const created = await gatewayManager.rpc('cron.add', gatewayInput);
data = created && typeof created === 'object' ? transformCronJob(created as GatewayCronJob) : created;
break;
}
if (request.action === 'update') {
const payload = request.payload as
| { id?: string; input?: Record<string, unknown> }
| [string, Record<string, unknown>]
| undefined;
const id = Array.isArray(payload) ? payload[0] : payload?.id;
const input = Array.isArray(payload) ? payload[1] : payload?.input;
if (!id || !input) throw new Error('Invalid cron.update payload');
const patch = { ...input };
if (typeof patch.schedule === 'string') patch.schedule = { kind: 'cron', expr: patch.schedule };
if (typeof patch.message === 'string') {
patch.payload = { kind: 'agentTurn', message: patch.message };
delete patch.message;
}
data = await gatewayManager.rpc('cron.update', { id, patch });
break;
}
if (request.action === 'delete') {
const payload = request.payload as { id?: string } | string | undefined;
const id = typeof payload === 'string' ? payload : payload?.id;
if (!id) throw new Error('Invalid cron.delete payload');
data = await gatewayManager.rpc('cron.remove', { id });
break;
}
if (request.action === 'toggle') {
const payload = request.payload as { id?: string; enabled?: boolean } | [string, boolean] | undefined;
const id = Array.isArray(payload) ? payload[0] : payload?.id;
const enabled = Array.isArray(payload) ? payload[1] : payload?.enabled;
if (!id || typeof enabled !== 'boolean') throw new Error('Invalid cron.toggle payload');
data = await gatewayManager.rpc('cron.update', { id, patch: { enabled } });
break;
}
if (request.action === 'trigger') {
const payload = request.payload as { id?: string } | string | undefined;
const id = typeof payload === 'string' ? payload : payload?.id;
if (!id) throw new Error('Invalid cron.trigger payload');
data = await gatewayManager.rpc('cron.run', { id, mode: 'force' });
break;
}
return {
id: request.id,
ok: false,
error: {
code: 'UNSUPPORTED',
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
},
};
}
case 'usage': {
if (request.action === 'recentTokenHistory') {
const payload = request.payload as { limit?: number } | number | undefined;
const limit = typeof payload === 'number' ? payload : payload?.limit;
const safeLimit = typeof limit === 'number' && Number.isFinite(limit)
? Math.max(Math.floor(limit), 1)
: undefined;
data = await getRecentTokenUsageHistory(safeLimit);
break;
}
return {
id: request.id,
ok: false,
error: {
code: 'UNSUPPORTED',
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
},
};
}
case 'settings': {
if (request.action === 'getAll') {
data = await getAllSettings();
break;
}
if (request.action === 'get') {
const payload = request.payload as { key?: keyof AppSettings } | [keyof AppSettings] | undefined;
const key = Array.isArray(payload) ? payload[0] : payload?.key;
if (!key) throw new Error('Invalid settings.get payload');
data = await getSetting(key);
break;
}
if (request.action === 'set') {
const payload = request.payload as
| { key?: keyof AppSettings; value?: AppSettings[keyof AppSettings] }
| [keyof AppSettings, AppSettings[keyof AppSettings]]
| undefined;
const key = Array.isArray(payload) ? payload[0] : payload?.key;
const value = Array.isArray(payload) ? payload[1] : payload?.value;
if (!key) throw new Error('Invalid settings.set payload');
await setSetting(key, value as never);
if (isProxyKey(key)) {
await handleProxySettingsChange();
}
data = { success: true };
break;
}
if (request.action === 'setMany') {
const patch = (request.payload ?? {}) as Partial<AppSettings>;
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
for (const [key, value] of entries) {
await setSetting(key, value as never);
}
if (entries.some(([key]) => isProxyKey(key))) {
await handleProxySettingsChange();
}
data = { success: true };
break;
}
if (request.action === 'reset') {
await resetSettings();
const settings = await getAllSettings();
await handleProxySettingsChange();
data = { success: true, settings };
break;
}
return {
id: request.id,
ok: false,
error: {
code: 'UNSUPPORTED',
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
},
};
}
default:
return {
id: request.id,
ok: false,
error: {
code: 'UNSUPPORTED',
message: `APP_REQUEST_UNSUPPORTED:${request.module}.${request.action}`,
},
};
}
return { id: request.id, ok: true, data };
} catch (error) {
return {
id: request.id,
ok: false,
error: {
code: mapAppErrorCode(error),
message: error instanceof Error ? error.message : String(error),
},
};
}
});
}
/**
* Skill config IPC handlers
* Direct read/write to ~/.openclaw/openclaw.json (bypasses Gateway RPC)
@@ -415,6 +976,14 @@ function registerGatewayHandlers(
gatewayManager: GatewayManager,
mainWindow: BrowserWindow
): void {
type GatewayHttpProxyRequest = {
path?: string;
method?: string;
headers?: Record<string, string>;
body?: unknown;
timeoutMs?: number;
};
// Get Gateway status
ipcMain.handle('gateway:status', () => {
return gatewayManager.getStatus();
@@ -465,6 +1034,72 @@ function registerGatewayHandlers(
}
});
// Gateway HTTP proxy
// Renderer must not call gateway HTTP directly (CORS); all HTTP traffic
// should go through this main-process proxy.
ipcMain.handle('gateway:httpProxy', async (_, request: GatewayHttpProxyRequest) => {
try {
const status = gatewayManager.getStatus();
const port = status.port || 18789;
const path = request?.path && request.path.startsWith('/') ? request.path : '/';
const method = (request?.method || 'GET').toUpperCase();
const timeoutMs =
typeof request?.timeoutMs === 'number' && request.timeoutMs > 0
? request.timeoutMs
: 15000;
const token = await getSetting('gatewayToken');
const headers: Record<string, string> = {
...(request?.headers ?? {}),
};
if (!headers.Authorization && !headers.authorization && token) {
headers.Authorization = `Bearer ${token}`;
}
let body: string | undefined;
if (request?.body !== undefined && request?.body !== null) {
body = typeof request.body === 'string' ? request.body : JSON.stringify(request.body);
if (!headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'application/json';
}
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await proxyAwareFetch(`http://127.0.0.1:${port}${path}`, {
method,
headers,
body,
signal: controller.signal,
});
clearTimeout(timer);
const contentType = (response.headers.get('content-type') || '').toLowerCase();
if (contentType.includes('application/json')) {
const json = await response.json();
return {
success: true,
status: response.status,
ok: response.ok,
json,
};
}
const text = await response.text();
return {
success: true,
status: response.status,
ok: response.ok,
text,
};
} catch (error) {
return {
success: false,
error: String(error),
};
}
});
// Chat send with media — reads staged files from disk and builds attachments.
// Raster images (png/jpg/gif/webp) are inlined as base64 vision attachments.
// All other files are referenced by path in the message text so the model
@@ -622,7 +1257,7 @@ function registerGatewayHandlers(
* OpenClaw-related IPC handlers
* For checking package status and channel configuration
*/
function registerOpenClawHandlers(): void {
function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
const targetManifest = join(targetDir, 'openclaw.plugin.json');
@@ -735,9 +1370,12 @@ function registerOpenClawHandlers(): void {
};
}
await saveChannelConfig(channelType, config);
logger.info(
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); Gateway handles channel config reload/restart internally`
);
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`);
gatewayManager.debouncedReload();
} else {
logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`);
}
return {
success: true,
pluginInstalled: installResult.installed,
@@ -745,12 +1383,12 @@ function registerOpenClawHandlers(): void {
};
}
await saveChannelConfig(channelType, config);
// Do not force stop/start here. Recent Gateway builds detect channel config
// changes and perform an internal service restart; forcing another restart
// from Electron can race with reconnect and kill the newly spawned process.
logger.info(
`Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); waiting for Gateway internal channel reload`
);
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway reload after channel:saveConfig (${channelType})`);
gatewayManager.debouncedReload();
} else {
logger.info(`Gateway is stopped; skip immediate reload after channel:saveConfig (${channelType})`);
}
return { success: true };
} catch (error) {
console.error('Failed to save channel config:', error);
@@ -784,6 +1422,12 @@ function registerOpenClawHandlers(): void {
ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => {
try {
await deleteChannelConfig(channelType);
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway reload after channel:deleteConfig (${channelType})`);
gatewayManager.debouncedReload();
} else {
logger.info(`Gateway is stopped; skip immediate reload after channel:deleteConfig (${channelType})`);
}
return { success: true };
} catch (error) {
console.error('Failed to delete channel config:', error);
@@ -806,6 +1450,12 @@ function registerOpenClawHandlers(): void {
ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => {
try {
await setChannelEnabled(channelType, enabled);
if (gatewayManager.getStatus().state !== 'stopped') {
logger.info(`Scheduling Gateway reload after channel:setEnabled (${channelType}, enabled=${enabled})`);
gatewayManager.debouncedReload();
} else {
logger.info(`Gateway is stopped; skip immediate reload after channel:setEnabled (${channelType})`);
}
return { success: true };
} catch (error) {
console.error('Failed to set channel enabled:', error);
@@ -1773,4 +2423,3 @@ function registerSessionHandlers(): void {
}
});
}

View File

@@ -14,6 +14,16 @@ const electronAPI = {
ipcRenderer: {
invoke: (channel: string, ...args: unknown[]) => {
const validChannels = [
// Gateway
'gateway:status',
'gateway:isConnected',
'gateway:start',
'gateway:stop',
'gateway:restart',
'gateway:rpc',
'gateway:httpProxy',
'gateway:health',
'gateway:getControlUiUrl',
// OpenClaw
'openclaw:status',
'openclaw:isReady',
@@ -32,11 +42,19 @@ const electronAPI = {
'app:platform',
'app:quit',
'app:relaunch',
'app:request',
// Window controls
'window:minimize',
'window:maximize',
'window:close',
'window:isMaximized',
// Settings
'settings:get',
'settings:set',
'settings:setMany',
'settings:getAll',
'settings:reset',
'usage:recentTokenHistory',
// Update
'update:status',
'update:version',
@@ -46,9 +64,73 @@ const electronAPI = {
'update:setChannel',
'update:setAutoDownload',
'update:cancelAutoInstall',
// Env
'env:getConfig',
'env:setApiKey',
'env:deleteApiKey',
// Provider
'provider:list',
'provider:get',
'provider:save',
'provider:delete',
'provider:setApiKey',
'provider:updateWithKey',
'provider:deleteApiKey',
'provider:hasApiKey',
'provider:getApiKey',
'provider:setDefault',
'provider:getDefault',
'provider:validateKey',
'provider:requestOAuth',
'provider:cancelOAuth',
// Cron
'cron:list',
'cron:create',
'cron:update',
'cron:delete',
'cron:toggle',
'cron:trigger',
// Channel Config
'channel:saveConfig',
'channel:getConfig',
'channel:getFormValues',
'channel:deleteConfig',
'channel:listConfigured',
'channel:setEnabled',
'channel:validate',
'channel:validate',
'channel:validateCredentials',
// WhatsApp
'channel:requestWhatsAppQr',
'channel:cancelWhatsAppQr',
// ClawHub
'clawhub:search',
'clawhub:install',
'clawhub:uninstall',
'clawhub:list',
'clawhub:openSkillReadme',
// UV
'uv:check',
'uv:install-all',
// Skill config (direct file access)
'skill:updateConfig',
'skill:getConfig',
'skill:getAllConfigs',
// Logs
'log:getRecent',
'log:readFile',
'log:getFilePath',
'log:getDir',
'log:listFiles',
// File staging & media
'file:stage',
'file:stageBuffer',
'media:getThumbnails',
'media:saveImage',
// Chat send with media (reads staged files in main process)
'chat:sendWithMedia',
// Session management
'session:delete',
// OpenClaw extras
'openclaw:getDir',
'openclaw:getConfigDir',
@@ -68,6 +150,16 @@ const electronAPI = {
*/
on: (channel: string, callback: (...args: unknown[]) => void) => {
const validChannels = [
'gateway:status-changed',
'gateway:message',
'gateway:notification',
'gateway:channel-status',
'gateway:chat-message',
'channel:whatsapp-qr',
'channel:whatsapp-success',
'channel:whatsapp-error',
'gateway:exit',
'gateway:error',
'navigate',
'update:status-changed',
'update:checking',
@@ -77,6 +169,10 @@ const electronAPI = {
'update:downloaded',
'update:error',
'update:auto-install-countdown',
'cron:updated',
'oauth:code',
'oauth:success',
'oauth:error',
'openclaw:cli-installed',
];
@@ -101,6 +197,13 @@ const electronAPI = {
*/
once: (channel: string, callback: (...args: unknown[]) => void) => {
const validChannels = [
'gateway:status-changed',
'gateway:message',
'gateway:notification',
'gateway:channel-status',
'gateway:chat-message',
'gateway:exit',
'gateway:error',
'navigate',
'update:status-changed',
'update:checking',
@@ -110,6 +213,9 @@ const electronAPI = {
'update:downloaded',
'update:error',
'update:auto-install-countdown',
'oauth:code',
'oauth:success',
'oauth:error',
];
if (validChannels.includes(channel)) {

View File

@@ -39,6 +39,7 @@ export interface PluginsConfig {
export interface OpenClawConfig {
channels?: Record<string, ChannelConfigData>;
plugins?: PluginsConfig;
commands?: Record<string, unknown>;
[key: string]: unknown;
}
@@ -71,6 +72,14 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
await ensureConfigDir();
try {
// Enable graceful in-process reload authorization for SIGUSR1 flows.
const commands =
config.commands && typeof config.commands === 'object'
? { ...(config.commands as Record<string, unknown>) }
: {};
commands.restart = true;
config.commands = commands;
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
} catch (error) {
logger.error('Failed to write OpenClaw config', error);

View File

@@ -17,14 +17,14 @@ import {
getProviderDefaultModel,
getProviderConfig,
} from './provider-registry';
import {
OPENCLAW_PROVIDER_KEY_MOONSHOT,
isOAuthProviderType,
isOpenClawOAuthPluginProviderKey,
} from './provider-keys';
const AUTH_STORE_VERSION = 1;
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn', 'google-gemini-cli'];
function shouldEnableOAuthPlugin(provider: string): boolean {
return provider === 'minimax-portal' || provider === 'qwen-portal' || provider === 'google-gemini-cli';
}
function getOAuthPluginId(provider: string): string {
return `${provider}-auth`;
@@ -142,6 +142,15 @@ async function readOpenClawJson(): Promise<Record<string, unknown>> {
}
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
// Ensure SIGUSR1 graceful reload is authorized by OpenClaw config.
const commands = (
config.commands && typeof config.commands === 'object'
? { ...(config.commands as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
commands.restart = true;
config.commands = commands;
await writeJsonFile(OPENCLAW_CONFIG_PATH, config);
}
@@ -220,7 +229,7 @@ export async function saveProviderKeyToOpenClaw(
apiKey: string,
agentId?: string
): Promise<void> {
if (OAUTH_PROVIDERS.includes(provider) && !apiKey) {
if (isOAuthProviderType(provider) && !apiKey) {
console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`);
return;
}
@@ -254,7 +263,7 @@ export async function removeProviderKeyFromOpenClaw(
provider: string,
agentId?: string
): Promise<void> {
if (OAUTH_PROVIDERS.includes(provider)) {
if (isOAuthProviderType(provider)) {
console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`);
return;
}
@@ -375,6 +384,7 @@ export async function setOpenClawDefaultModel(
fallbackModels: string[] = []
): Promise<void> {
const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
const rawModel = modelOverride || getProviderDefaultModel(provider);
const model = rawModel
@@ -407,6 +417,7 @@ export async function setOpenClawDefaultModel(
if (providerCfg) {
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
const removedLegacyMoonshot = removeLegacyMoonshotProviderEntry(provider, providers);
const existingProvider =
providers[provider] && typeof providers[provider] === 'object'
@@ -443,6 +454,9 @@ export async function setOpenClawDefaultModel(
}
providers[provider] = providerEntry;
console.log(`Configured models.providers.${provider} with baseUrl=${providerCfg.baseUrl}, model=${modelId}`);
if (removedLegacyMoonshot) {
console.log('Removed legacy models.providers.moonshot alias entry');
}
models.providers = providers;
config.models = models;
@@ -475,6 +489,32 @@ interface RuntimeProviderConfigOverride {
authHeader?: boolean;
}
function removeLegacyMoonshotProviderEntry(
_provider: string,
_providers: Record<string, unknown>
): boolean {
return false;
}
function ensureMoonshotKimiWebSearchCnBaseUrl(config: Record<string, unknown>, provider: string): void {
if (provider !== OPENCLAW_PROVIDER_KEY_MOONSHOT) return;
const tools = (config.tools || {}) as Record<string, unknown>;
const web = (tools.web || {}) as Record<string, unknown>;
const search = (web.search || {}) as Record<string, unknown>;
const kimi = (search.kimi && typeof search.kimi === 'object' && !Array.isArray(search.kimi))
? (search.kimi as Record<string, unknown>)
: {};
// Prefer env/auth-profiles for key resolution; stale inline kimi.apiKey can cause persistent 401.
delete kimi.apiKey;
kimi.baseUrl = 'https://api.moonshot.cn/v1';
search.kimi = kimi;
web.search = search;
tools.web = web;
config.tools = tools;
}
/**
* Register or update a provider's configuration in openclaw.json
* without changing the current default model.
@@ -485,10 +525,12 @@ export async function syncProviderConfigToOpenClaw(
override: RuntimeProviderConfigOverride
): Promise<void> {
const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
if (override.baseUrl && override.api) {
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
removeLegacyMoonshotProviderEntry(provider, providers);
const nextModels: Array<Record<string, unknown>> = [];
if (modelId) nextModels.push({ id: modelId, name: modelId });
@@ -509,7 +551,7 @@ export async function syncProviderConfigToOpenClaw(
}
// Ensure extension is enabled for oauth providers to prevent gateway wiping config
if (shouldEnableOAuthPlugin(provider)) {
if (isOpenClawOAuthPluginProviderKey(provider)) {
const plugins = (config.plugins || {}) as Record<string, unknown>;
const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : [];
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
@@ -536,6 +578,7 @@ export async function setOpenClawDefaultModelWithOverride(
fallbackModels: string[] = []
): Promise<void> {
const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
const rawModel = modelOverride || getProviderDefaultModel(provider);
const model = rawModel
@@ -565,6 +608,7 @@ export async function setOpenClawDefaultModelWithOverride(
if (override.baseUrl && override.api) {
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
removeLegacyMoonshotProviderEntry(provider, providers);
const nextModels: Array<Record<string, unknown>> = [];
for (const candidateModelId of [modelId, ...fallbackModelIds]) {
@@ -596,7 +640,7 @@ export async function setOpenClawDefaultModelWithOverride(
config.gateway = gateway;
// Ensure the extension plugin is marked as enabled in openclaw.json
if (shouldEnableOAuthPlugin(provider)) {
if (isOpenClawOAuthPluginProviderKey(provider)) {
const plugins = (config.plugins || {}) as Record<string, unknown>;
const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : [];
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
@@ -810,6 +854,42 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
}
}
// ── commands section ───────────────────────────────────────────
// Required for SIGUSR1 in-process reload authorization.
const commands = (
config.commands && typeof config.commands === 'object'
? { ...(config.commands as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
if (commands.restart !== true) {
commands.restart = true;
config.commands = commands;
modified = true;
console.log('[sanitize] Enabling commands.restart for graceful reload support');
}
// ── tools.web.search.kimi ─────────────────────────────────────
// OpenClaw web_search(kimi) prioritizes tools.web.search.kimi.apiKey over
// environment/auth-profiles. A stale inline key can cause persistent 401s.
// When ClawX-managed moonshot provider exists, prefer centralized key
// resolution and strip the inline key.
const providers = ((config.models as Record<string, unknown> | undefined)?.providers as Record<string, unknown> | undefined) || {};
if (providers[OPENCLAW_PROVIDER_KEY_MOONSHOT]) {
const tools = (config.tools as Record<string, unknown> | undefined) || {};
const web = (tools.web as Record<string, unknown> | undefined) || {};
const search = (web.search as Record<string, unknown> | undefined) || {};
const kimi = (search.kimi as Record<string, unknown> | undefined) || {};
if ('apiKey' in kimi) {
console.log('[sanitize] Removing stale key "tools.web.search.kimi.apiKey" from openclaw.json');
delete kimi.apiKey;
search.kimi = kimi;
web.search = search;
tools.web = web;
config.tools = tools;
modified = true;
}
}
if (modified) {
await writeOpenClawJson(config);
console.log('[sanitize] openclaw.json sanitized successfully');

View File

@@ -0,0 +1,73 @@
const MULTI_INSTANCE_PROVIDER_TYPES = new Set(['custom', 'ollama']);
export const OPENCLAW_PROVIDER_KEY_MINIMAX = 'minimax-portal';
export const OPENCLAW_PROVIDER_KEY_QWEN = 'qwen-portal';
export const OPENCLAW_PROVIDER_KEY_MOONSHOT = 'moonshot';
export const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'] as const;
export const OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEYS = [
OPENCLAW_PROVIDER_KEY_MINIMAX,
OPENCLAW_PROVIDER_KEY_QWEN,
] as const;
const OAUTH_PROVIDER_TYPE_SET = new Set<string>(OAUTH_PROVIDER_TYPES);
const OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEY_SET = new Set<string>(OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEYS);
const PROVIDER_KEY_ALIASES: Record<string, string> = {
'minimax-portal-cn': OPENCLAW_PROVIDER_KEY_MINIMAX,
};
export function getOpenClawProviderKeyForType(type: string, providerId: string): string {
if (MULTI_INSTANCE_PROVIDER_TYPES.has(type)) {
const suffix = providerId.replace(/-/g, '').slice(0, 8);
return `${type}-${suffix}`;
}
return PROVIDER_KEY_ALIASES[type] ?? type;
}
export function isOAuthProviderType(type: string): boolean {
return OAUTH_PROVIDER_TYPE_SET.has(type);
}
export function isMiniMaxProviderType(type: string): boolean {
return type === OPENCLAW_PROVIDER_KEY_MINIMAX || type === 'minimax-portal-cn';
}
export function getOAuthProviderTargetKey(type: string): string | undefined {
if (!isOAuthProviderType(type)) return undefined;
return isMiniMaxProviderType(type) ? OPENCLAW_PROVIDER_KEY_MINIMAX : OPENCLAW_PROVIDER_KEY_QWEN;
}
export function getOAuthProviderApi(type: string): 'anthropic-messages' | 'openai-completions' | undefined {
if (!isOAuthProviderType(type)) return undefined;
return isMiniMaxProviderType(type) ? 'anthropic-messages' : 'openai-completions';
}
export function getOAuthProviderDefaultBaseUrl(type: string): string | undefined {
if (!isOAuthProviderType(type)) return undefined;
if (type === OPENCLAW_PROVIDER_KEY_MINIMAX) return 'https://api.minimax.io/anthropic';
if (type === 'minimax-portal-cn') return 'https://api.minimaxi.com/anthropic';
return 'https://portal.qwen.ai/v1';
}
export function normalizeOAuthBaseUrl(type: string, baseUrl?: string): string | undefined {
if (!baseUrl) return undefined;
if (isMiniMaxProviderType(type)) {
return baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
}
return baseUrl;
}
export function usesOAuthAuthHeader(providerKey: string): boolean {
return providerKey === OPENCLAW_PROVIDER_KEY_MINIMAX;
}
export function getOAuthApiKeyEnv(providerKey: string): string | undefined {
if (providerKey === OPENCLAW_PROVIDER_KEY_MINIMAX) return 'minimax-oauth';
if (providerKey === OPENCLAW_PROVIDER_KEY_QWEN) return 'qwen-oauth';
return undefined;
}
export function isOpenClawOAuthPluginProviderKey(provider: string): boolean {
return OPENCLAW_OAUTH_PLUGIN_PROVIDER_KEY_SET.has(provider);
}

View File

@@ -32,6 +32,12 @@ export function getProviderEnvVar(type: string): string | undefined {
return getSharedProviderEnvVar(type) ?? EXTRA_ENV_ONLY_PROVIDERS[type]?.envVar;
}
/** Get all environment variable names for a provider type (primary first). */
export function getProviderEnvVars(type: string): string[] {
const envVar = getProviderEnvVar(type);
return envVar ? [envVar] : [];
}
/** Get the default model string for a provider type */
export function getProviderDefaultModel(type: string): string | undefined {
return getSharedProviderDefaultModel(type);

View File

@@ -23,6 +23,7 @@ import {
getProviderSecret,
setProviderSecret,
} from '../services/secrets/secret-store';
import { getOpenClawProviderKeyForType } from './provider-keys';
/**
* Provider configuration
@@ -276,9 +277,7 @@ export async function getAllProvidersWithKeyInfo(): Promise<
// e.g. provider.id "custom-a1b2c3d4-..." → strip hyphens → "customa1b2c3d4..." → slice(0,8) → "customa1"
// → openClawKey = "custom-customa1"
// This must match getOpenClawProviderKey() in ipc-handlers.ts exactly.
const openClawKey = (provider.type === 'custom' || provider.type === 'ollama')
? `${provider.type}-${provider.id.replace(/-/g, '').slice(0, 8)}`
: provider.type === 'minimax-portal-cn' ? 'minimax-portal' : provider.type;
const openClawKey = getOpenClawProviderKeyForType(provider.type, provider.id);
if (!isBuiltin && !activeOpenClawProviders.has(provider.type) && !activeOpenClawProviders.has(provider.id) && !activeOpenClawProviders.has(openClawKey)) {
console.log(`[Sync] Provider ${provider.id} (${provider.type}) missing from OpenClaw, dropping from ClawX UI`);
await deleteProvider(provider.id);

View File

@@ -36,6 +36,7 @@ export interface AppSettings {
proxyHttpsServer: string;
proxyAllServer: string;
proxyBypassRules: string;
gatewayTransportPreference: 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only';
// Update
updateChannel: 'stable' | 'beta' | 'dev';
@@ -73,6 +74,7 @@ const defaults: AppSettings = {
proxyHttpsServer: '',
proxyAllServer: '',
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
gatewayTransportPreference: 'ws-first',
// Update
updateChannel: 'stable',