refactor IPC (#341)
This commit is contained in:
committed by
GitHub
Unverified
parent
c03d92e9a2
commit
3d804a9f5e
@@ -228,9 +228,11 @@ export class GatewayManager extends EventEmitter {
|
||||
}> = new Map();
|
||||
private deviceIdentity: DeviceIdentity | null = null;
|
||||
private restartDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private reloadDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private lifecycleEpoch = 0;
|
||||
private deferredRestartPending = false;
|
||||
private restartInFlight: Promise<void> | null = null;
|
||||
private externalShutdownSupported: boolean | null = null;
|
||||
|
||||
constructor(config?: Partial<ReconnectConfig>) {
|
||||
super();
|
||||
@@ -259,6 +261,11 @@ 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);
|
||||
}
|
||||
|
||||
private formatExit(code: number | null, signal: NodeJS.Signals | null): string {
|
||||
if (code !== null) return `code=${code}`;
|
||||
if (signal) return `signal=${signal}`;
|
||||
@@ -272,6 +279,14 @@ export class GatewayManager extends EventEmitter {
|
||||
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
|
||||
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) return { level: 'drop', normalized: msg };
|
||||
if (msg.includes('closed before connect') && msg.includes('token mismatch')) return { level: 'drop', normalized: msg };
|
||||
// During renderer refresh / transport switching, loopback websocket probes can time out
|
||||
// while the gateway is reloading. This is expected and not actionable.
|
||||
if (msg.includes('[ws] handshake timeout') && msg.includes('remote=127.0.0.1')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
if (msg.includes('[ws] closed before connect') && msg.includes('remote=127.0.0.1')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
|
||||
// Downgrade frequent non-fatal noise.
|
||||
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
|
||||
@@ -536,11 +551,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,6 +669,71 @@ export class GatewayManager extends EventEmitter {
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.isRestartDeferred()) {
|
||||
this.markDeferredRestart('reload');
|
||||
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
|
||||
*/
|
||||
@@ -668,6 +754,10 @@ export class GatewayManager extends EventEmitter {
|
||||
clearTimeout(this.restartDebounceTimer);
|
||||
this.restartDebounceTimer = null;
|
||||
}
|
||||
if (this.reloadDebounceTimer) {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
this.reloadDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,25 @@ import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { getRecentTokenUsageHistory } from '../utils/token-usage';
|
||||
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;
|
||||
};
|
||||
};
|
||||
import {
|
||||
getOpenClawProviderKeyForType,
|
||||
getOAuthApiKeyEnv,
|
||||
@@ -136,6 +155,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);
|
||||
|
||||
@@ -143,7 +165,7 @@ export function registerIpcHandlers(
|
||||
registerClawHubHandlers(clawHubService);
|
||||
|
||||
// OpenClaw handlers
|
||||
registerOpenClawHandlers();
|
||||
registerOpenClawHandlers(gatewayManager);
|
||||
|
||||
// Provider handlers
|
||||
registerProviderHandlers(gatewayManager);
|
||||
@@ -191,6 +213,701 @@ 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);
|
||||
const ock = getOpenClawProviderKey(config.type, config.id);
|
||||
|
||||
if (apiKey !== undefined) {
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await storeApiKey(config.id, trimmedKey);
|
||||
try {
|
||||
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
||||
} catch (err) {
|
||||
console.warn('Failed to save key to OpenClaw auth-profiles:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const meta = getProviderConfig(config.type);
|
||||
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
|
||||
|
||||
if (api) {
|
||||
await syncProviderConfigToOpenClaw(ock, config.model, {
|
||||
baseUrl: config.baseUrl || meta?.baseUrl,
|
||||
api,
|
||||
apiKeyEnv: meta?.apiKeyEnv,
|
||||
headers: meta?.headers,
|
||||
});
|
||||
|
||||
if (config.type === 'custom' || config.type === 'ollama') {
|
||||
const resolvedKey = apiKey !== undefined
|
||||
? (apiKey.trim() || null)
|
||||
: await getApiKey(config.id);
|
||||
if (resolvedKey && config.baseUrl) {
|
||||
const modelId = config.model;
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: config.baseUrl,
|
||||
api: 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: resolvedKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Scheduling Gateway restart after saving provider "${ock}" config`);
|
||||
gatewayManager.debouncedRestart();
|
||||
}
|
||||
} 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 {
|
||||
const ock = getOpenClawProviderKey(existing.type, providerId);
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
logger.info(`Scheduling Gateway restart after deleting provider "${ock}"`);
|
||||
gatewayManager.debouncedRestart();
|
||||
} 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 {
|
||||
const fallbackModels = await getProviderFallbackModelRefs(nextConfig);
|
||||
const meta = getProviderConfig(nextConfig.type);
|
||||
const api = nextConfig.type === 'custom' || nextConfig.type === 'ollama' ? 'openai-completions' : meta?.api;
|
||||
|
||||
if (api) {
|
||||
await syncProviderConfigToOpenClaw(ock, nextConfig.model, {
|
||||
baseUrl: nextConfig.baseUrl || meta?.baseUrl,
|
||||
api,
|
||||
apiKeyEnv: meta?.apiKeyEnv,
|
||||
headers: meta?.headers,
|
||||
});
|
||||
|
||||
if (nextConfig.type === 'custom' || nextConfig.type === 'ollama') {
|
||||
const resolvedKey = apiKey !== undefined
|
||||
? (apiKey.trim() || null)
|
||||
: await getApiKey(providerId);
|
||||
if (resolvedKey && nextConfig.baseUrl) {
|
||||
const modelId = nextConfig.model;
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: nextConfig.baseUrl,
|
||||
api: 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: resolvedKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProviderId = await getDefaultProvider();
|
||||
if (defaultProviderId === providerId) {
|
||||
const modelOverride = nextConfig.model ? `${ock}/${nextConfig.model}` : undefined;
|
||||
if (nextConfig.type !== 'custom' && nextConfig.type !== 'ollama') {
|
||||
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
|
||||
} else {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: nextConfig.baseUrl,
|
||||
api: 'openai-completions',
|
||||
}, fallbackModels);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Scheduling Gateway restart after updating provider "${ock}" config`);
|
||||
gatewayManager.debouncedRestart();
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync openclaw config after provider update:', err);
|
||||
}
|
||||
|
||||
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 {
|
||||
const ock = getOpenClawProviderKey(provider.type, providerId);
|
||||
const providerKey = await getApiKey(providerId);
|
||||
const fallbackModels = await getProviderFallbackModelRefs(provider);
|
||||
const OAUTH_PROVIDER_TYPES = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||
const isOAuthProvider = OAUTH_PROVIDER_TYPES.includes(provider.type) && !providerKey;
|
||||
|
||||
if (!isOAuthProvider) {
|
||||
const modelOverride = provider.model
|
||||
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
|
||||
: undefined;
|
||||
if (provider.type === 'custom' || provider.type === 'ollama') {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: provider.baseUrl,
|
||||
api: 'openai-completions',
|
||||
}, fallbackModels);
|
||||
} else {
|
||||
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
|
||||
}
|
||||
if (providerKey) {
|
||||
await saveProviderKeyToOpenClaw(ock, providerKey);
|
||||
}
|
||||
} else {
|
||||
const defaultBaseUrl = provider.type === 'minimax-portal'
|
||||
? 'https://api.minimax.io/anthropic'
|
||||
: (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1');
|
||||
const api: 'anthropic-messages' | 'openai-completions' =
|
||||
(provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
|
||||
? 'anthropic-messages'
|
||||
: 'openai-completions';
|
||||
|
||||
let baseUrl = provider.baseUrl || defaultBaseUrl;
|
||||
if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) {
|
||||
baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
|
||||
}
|
||||
|
||||
const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
|
||||
? 'minimax-portal'
|
||||
: provider.type;
|
||||
|
||||
await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), {
|
||||
baseUrl,
|
||||
api,
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
}, fallbackModels);
|
||||
|
||||
try {
|
||||
const defaultModelId = provider.model?.split('/').pop();
|
||||
await updateAgentModelProvider(targetProviderKey, {
|
||||
baseUrl,
|
||||
api,
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [],
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(provider.type === 'custom' || provider.type === 'ollama') &&
|
||||
providerKey &&
|
||||
provider.baseUrl
|
||||
) {
|
||||
const modelId = provider.model;
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: provider.baseUrl,
|
||||
api: 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: providerKey,
|
||||
});
|
||||
}
|
||||
|
||||
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||
logger.info(`Scheduling Gateway restart after provider switch to "${ock}"`);
|
||||
gatewayManager.debouncedRestart();
|
||||
}
|
||||
} 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)
|
||||
@@ -493,6 +1210,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();
|
||||
@@ -543,6 +1268,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
|
||||
@@ -700,7 +1491,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');
|
||||
@@ -813,9 +1604,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,
|
||||
@@ -823,12 +1617,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);
|
||||
@@ -862,6 +1656,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);
|
||||
@@ -884,6 +1684,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);
|
||||
|
||||
@@ -21,6 +21,7 @@ const electronAPI = {
|
||||
'gateway:stop',
|
||||
'gateway:restart',
|
||||
'gateway:rpc',
|
||||
'gateway:httpProxy',
|
||||
'gateway:health',
|
||||
'gateway:getControlUiUrl',
|
||||
// OpenClaw
|
||||
@@ -41,6 +42,7 @@ const electronAPI = {
|
||||
'app:platform',
|
||||
'app:quit',
|
||||
'app:relaunch',
|
||||
'app:request',
|
||||
// Window controls
|
||||
'window:minimize',
|
||||
'window:maximize',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -136,6 +136,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);
|
||||
}
|
||||
|
||||
@@ -819,6 +828,20 @@ 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.
|
||||
|
||||
@@ -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