fix conflict
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
73
electron/utils/provider-keys.ts
Normal file
73
electron/utils/provider-keys.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user