Add built-in proxy settings for Electron and Gateway (#239)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
c09b45832b
commit
e40f4b2163
@@ -88,8 +88,9 @@ export class ClawHubService {
|
||||
|
||||
const isWin = process.platform === 'win32';
|
||||
const useShell = isWin && !this.useNodeRunner;
|
||||
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
|
||||
const env = {
|
||||
...process.env,
|
||||
...baseEnv,
|
||||
CI: 'true',
|
||||
FORCE_COLOR: '0',
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
appendNodeRequireToNodeOptions,
|
||||
quoteForCmd,
|
||||
} from '../utils/paths';
|
||||
import { getSetting } from '../utils/store';
|
||||
import { getAllSettings, getSetting } from '../utils/store';
|
||||
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
|
||||
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
|
||||
import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol';
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
type DeviceIdentity,
|
||||
} from '../utils/device-identity';
|
||||
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
|
||||
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
|
||||
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
||||
import { shouldAttemptConfigAutoRepair } from './startup-recovery';
|
||||
|
||||
/**
|
||||
@@ -871,7 +873,9 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
|
||||
// Get or generate gateway token
|
||||
const gatewayToken = await getSetting('gatewayToken');
|
||||
const appSettings = await getAllSettings();
|
||||
const gatewayToken = appSettings.gatewayToken;
|
||||
await syncProxyConfigToOpenClaw(appSettings);
|
||||
|
||||
// Strip stale/invalid keys from openclaw.json that would cause the
|
||||
// Gateway's strict config validation to reject the file on startup
|
||||
@@ -989,17 +993,21 @@ export class GatewayManager extends EventEmitter {
|
||||
}
|
||||
|
||||
const uvEnv = await getUvMirrorEnv();
|
||||
const proxyEnv = buildProxyEnv(appSettings);
|
||||
const resolvedProxy = resolveProxySettings(appSettings);
|
||||
logger.info(
|
||||
`Starting Gateway process (mode=${mode}, port=${this.status.port}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount})`
|
||||
`Starting Gateway process (mode=${mode}, port=${this.status.port}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, proxy=${appSettings.proxyEnabled ? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}` : 'disabled'})`
|
||||
);
|
||||
this.lastSpawnSummary = `mode=${mode}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}"`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
|
||||
const spawnEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
...baseEnv,
|
||||
PATH: finalPath,
|
||||
...providerEnv,
|
||||
...uvEnv,
|
||||
...proxyEnv,
|
||||
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
|
||||
OPENCLAW_SKIP_CHANNELS: '',
|
||||
CLAWDBOT_SKIP_CHANNELS: '',
|
||||
|
||||
@@ -17,6 +17,8 @@ import { ClawHubService } from '../gateway/clawhub';
|
||||
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
||||
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
|
||||
import { isQuitting, setQuitting } from './app-state';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { getSetting } from '../utils/store';
|
||||
import { ensureBuiltinSkillsInstalled } from '../utils/skill-config';
|
||||
|
||||
// Disable GPU hardware acceleration globally for maximum stability across
|
||||
@@ -128,6 +130,9 @@ async function initialize(): Promise<void> {
|
||||
// Warm up network optimization (non-blocking)
|
||||
void warmupNetworkOptimization();
|
||||
|
||||
// Apply persisted proxy settings before creating windows or network requests.
|
||||
await applyProxySettings();
|
||||
|
||||
// Set application menu
|
||||
createMenu();
|
||||
|
||||
@@ -195,13 +200,18 @@ async function initialize(): Promise<void> {
|
||||
});
|
||||
|
||||
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
|
||||
try {
|
||||
logger.debug('Auto-starting Gateway...');
|
||||
await gatewayManager.start();
|
||||
logger.info('Gateway auto-start succeeded');
|
||||
} catch (error) {
|
||||
logger.error('Gateway auto-start failed:', error);
|
||||
mainWindow?.webContents.send('gateway:error', String(error));
|
||||
const gatewayAutoStart = await getSetting('gatewayAutoStart');
|
||||
if (gatewayAutoStart) {
|
||||
try {
|
||||
logger.debug('Auto-starting Gateway...');
|
||||
await gatewayManager.start();
|
||||
logger.info('Gateway auto-start succeeded');
|
||||
} catch (error) {
|
||||
logger.error('Gateway auto-start failed:', error);
|
||||
mainWindow?.webContents.send('gateway:error', String(error));
|
||||
}
|
||||
} else {
|
||||
logger.info('Gateway auto-start disabled in settings');
|
||||
}
|
||||
|
||||
// Merge ClawX context snippets into the workspace bootstrap files.
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '../utils/secure-storage';
|
||||
import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir, ensureDir } from '../utils/paths';
|
||||
import { getOpenClawCliCommand } from '../utils/openclaw-cli';
|
||||
import { getSetting } from '../utils/store';
|
||||
import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../utils/store';
|
||||
import {
|
||||
saveProviderKeyToOpenClaw,
|
||||
removeProviderFromOpenClaw,
|
||||
@@ -49,6 +49,8 @@ import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/
|
||||
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
||||
import { getProviderConfig } from '../utils/provider-registry';
|
||||
import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
import { getRecentTokenUsageHistory } from '../utils/token-usage';
|
||||
|
||||
/**
|
||||
@@ -100,6 +102,9 @@ export function registerIpcHandlers(
|
||||
// App handlers
|
||||
registerAppHandlers();
|
||||
|
||||
// Settings handlers
|
||||
registerSettingsHandlers(gatewayManager);
|
||||
|
||||
// UV handlers
|
||||
registerUvHandlers();
|
||||
|
||||
@@ -1478,7 +1483,7 @@ async function performProviderValidationRequest(
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'GET', url, headers);
|
||||
const response = await fetch(url, { headers });
|
||||
const response = await proxyAwareFetch(url, { headers });
|
||||
logValidationStatus(providerLabel, response.status);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return classifyAuthResponse(response.status, data);
|
||||
@@ -1553,7 +1558,7 @@ async function performChatCompletionsProbe(
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||
const response = await fetch(url, {
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -1755,6 +1760,67 @@ function registerAppHandlers(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function registerSettingsHandlers(gatewayManager: GatewayManager): void {
|
||||
const handleProxySettingsChange = async () => {
|
||||
const settings = await getAllSettings();
|
||||
await applyProxySettings(settings);
|
||||
if (gatewayManager.getStatus().state === 'running') {
|
||||
await gatewayManager.restart();
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.handle('settings:get', async (_, key: keyof AppSettings) => {
|
||||
return await getSetting(key);
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:getAll', async () => {
|
||||
return await getAllSettings();
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set', async (_, key: keyof AppSettings, value: AppSettings[keyof AppSettings]) => {
|
||||
await setSetting(key, value as never);
|
||||
|
||||
if (
|
||||
key === 'proxyEnabled' ||
|
||||
key === 'proxyServer' ||
|
||||
key === 'proxyHttpServer' ||
|
||||
key === 'proxyHttpsServer' ||
|
||||
key === 'proxyAllServer' ||
|
||||
key === 'proxyBypassRules'
|
||||
) {
|
||||
await handleProxySettingsChange();
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:setMany', async (_, patch: 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]) =>
|
||||
key === 'proxyEnabled' ||
|
||||
key === 'proxyServer' ||
|
||||
key === 'proxyHttpServer' ||
|
||||
key === 'proxyHttpsServer' ||
|
||||
key === 'proxyAllServer' ||
|
||||
key === 'proxyBypassRules'
|
||||
)) {
|
||||
await handleProxySettingsChange();
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:reset', async () => {
|
||||
await resetSettings();
|
||||
const settings = await getAllSettings();
|
||||
await handleProxySettingsChange();
|
||||
return { success: true, settings };
|
||||
});
|
||||
}
|
||||
function registerUsageHandlers(): void {
|
||||
ipcMain.handle('usage:recentTokenHistory', async (_, limit?: number) => {
|
||||
const safeLimit = typeof limit === 'number' && Number.isFinite(limit)
|
||||
|
||||
22
electron/main/proxy.ts
Normal file
22
electron/main/proxy.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { session } from 'electron';
|
||||
import { getAllSettings, type AppSettings } from '../utils/store';
|
||||
import { buildElectronProxyConfig } from '../utils/proxy';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export async function applyProxySettings(
|
||||
partialSettings?: Pick<AppSettings, 'proxyEnabled' | 'proxyServer' | 'proxyBypassRules'>
|
||||
): Promise<void> {
|
||||
const settings = partialSettings ?? await getAllSettings();
|
||||
const config = buildElectronProxyConfig(settings);
|
||||
|
||||
await session.defaultSession.setProxy(config);
|
||||
try {
|
||||
await session.defaultSession.closeAllConnections();
|
||||
} catch (error) {
|
||||
logger.debug('Failed to close existing connections after proxy update:', error);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Applied Electron proxy (${config.mode}${config.proxyRules ? `, server=${config.proxyRules}` : ''}${config.proxyBypassRules ? `, bypass=${config.proxyBypassRules}` : ''})`
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ const electronAPI = {
|
||||
// Settings
|
||||
'settings:get',
|
||||
'settings:set',
|
||||
'settings:setMany',
|
||||
'settings:getAll',
|
||||
'settings:reset',
|
||||
'usage:recentTokenHistory',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getOpenClawResolvedDir } from './paths';
|
||||
import * as logger from './logger';
|
||||
import { proxyAwareFetch } from './proxy-fetch';
|
||||
|
||||
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
||||
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
|
||||
@@ -497,7 +498,7 @@ async function validateTelegramCredentials(
|
||||
if (!allowedUsers) return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] };
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.telegram.org/bot${botToken}/getMe`);
|
||||
const response = await proxyAwareFetch(`https://api.telegram.org/bot${botToken}/getMe`);
|
||||
const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } };
|
||||
if (data.ok) {
|
||||
return { valid: true, errors: [], warnings: [], details: { botUsername: data.result?.username || 'Unknown' } };
|
||||
|
||||
43
electron/utils/openclaw-proxy.ts
Normal file
43
electron/utils/openclaw-proxy.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { readOpenClawConfig, writeOpenClawConfig } from './channel-config';
|
||||
import { resolveProxySettings, type ProxySettings } from './proxy';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* Sync ClawX global proxy settings into OpenClaw channel config where the
|
||||
* upstream runtime expects an explicit per-channel proxy knob.
|
||||
*/
|
||||
export async function syncProxyConfigToOpenClaw(settings: ProxySettings): Promise<void> {
|
||||
const config = await readOpenClawConfig();
|
||||
const telegramConfig = config.channels?.telegram;
|
||||
|
||||
if (!telegramConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveProxySettings(settings);
|
||||
const nextProxy = settings.proxyEnabled
|
||||
? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy)
|
||||
: '';
|
||||
const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : '';
|
||||
|
||||
if (!nextProxy && !currentProxy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.channels) {
|
||||
config.channels = {};
|
||||
}
|
||||
|
||||
config.channels.telegram = {
|
||||
...telegramConfig,
|
||||
};
|
||||
|
||||
if (nextProxy) {
|
||||
config.channels.telegram.proxy = nextProxy;
|
||||
} else {
|
||||
delete config.channels.telegram.proxy;
|
||||
}
|
||||
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`);
|
||||
}
|
||||
21
electron/utils/proxy-fetch.ts
Normal file
21
electron/utils/proxy-fetch.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Use Electron's network stack when available so requests honor
|
||||
* session.defaultSession.setProxy(...). Fall back to the Node global fetch
|
||||
* for non-Electron test environments.
|
||||
*/
|
||||
|
||||
export async function proxyAwareFetch(
|
||||
input: string | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
if (process.versions.electron) {
|
||||
try {
|
||||
const { net } = await import('electron');
|
||||
return await net.fetch(input, init);
|
||||
} catch {
|
||||
// Fall through to the global fetch.
|
||||
}
|
||||
}
|
||||
|
||||
return await fetch(input, init);
|
||||
}
|
||||
122
electron/utils/proxy.ts
Normal file
122
electron/utils/proxy.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Proxy helpers shared by the Electron main process and Gateway launcher.
|
||||
*/
|
||||
|
||||
export interface ProxySettings {
|
||||
proxyEnabled: boolean;
|
||||
proxyServer: string;
|
||||
proxyHttpServer: string;
|
||||
proxyHttpsServer: string;
|
||||
proxyAllServer: string;
|
||||
proxyBypassRules: string;
|
||||
}
|
||||
|
||||
export interface ResolvedProxySettings {
|
||||
httpProxy: string;
|
||||
httpsProxy: string;
|
||||
allProxy: string;
|
||||
bypassRules: string;
|
||||
}
|
||||
|
||||
export interface ElectronProxyConfig {
|
||||
mode: 'direct' | 'fixed_servers';
|
||||
proxyRules?: string;
|
||||
proxyBypassRules?: string;
|
||||
}
|
||||
|
||||
function trimValue(value: string | undefined | null): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept bare host:port values from users and normalize them to a valid URL.
|
||||
* Electron accepts scheme-less proxy rules in some cases, but child-process
|
||||
* env vars are more reliable when they are full URLs.
|
||||
*/
|
||||
export function normalizeProxyServer(proxyServer: string): string {
|
||||
const value = trimValue(proxyServer);
|
||||
if (!value) return '';
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return value;
|
||||
return `http://${value}`;
|
||||
}
|
||||
|
||||
export function resolveProxySettings(settings: ProxySettings): ResolvedProxySettings {
|
||||
const legacyProxy = normalizeProxyServer(settings.proxyServer);
|
||||
const allProxy = normalizeProxyServer(settings.proxyAllServer);
|
||||
const httpProxy = normalizeProxyServer(settings.proxyHttpServer) || legacyProxy || allProxy;
|
||||
const httpsProxy = normalizeProxyServer(settings.proxyHttpsServer) || legacyProxy || allProxy;
|
||||
|
||||
return {
|
||||
httpProxy,
|
||||
httpsProxy,
|
||||
allProxy: allProxy || legacyProxy,
|
||||
bypassRules: trimValue(settings.proxyBypassRules),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildElectronProxyConfig(settings: ProxySettings): ElectronProxyConfig {
|
||||
if (!settings.proxyEnabled) {
|
||||
return { mode: 'direct' };
|
||||
}
|
||||
|
||||
const resolved = resolveProxySettings(settings);
|
||||
const rules: string[] = [];
|
||||
|
||||
if (resolved.httpProxy) {
|
||||
rules.push(`http=${resolved.httpProxy}`);
|
||||
}
|
||||
if (resolved.httpsProxy) {
|
||||
rules.push(`https=${resolved.httpsProxy}`);
|
||||
}
|
||||
|
||||
// Fallback rule for protocols like ws/wss or when users only configured ALL_PROXY.
|
||||
const fallbackProxy = resolved.allProxy || resolved.httpsProxy || resolved.httpProxy;
|
||||
if (fallbackProxy) {
|
||||
rules.push(fallbackProxy);
|
||||
}
|
||||
|
||||
if (rules.length === 0) {
|
||||
return { mode: 'direct' };
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'fixed_servers',
|
||||
proxyRules: rules.join(';'),
|
||||
...(resolved.bypassRules ? { proxyBypassRules: resolved.bypassRules } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProxyEnv(settings: ProxySettings): Record<string, string> {
|
||||
const blank = {
|
||||
HTTP_PROXY: '',
|
||||
HTTPS_PROXY: '',
|
||||
ALL_PROXY: '',
|
||||
http_proxy: '',
|
||||
https_proxy: '',
|
||||
all_proxy: '',
|
||||
NO_PROXY: '',
|
||||
no_proxy: '',
|
||||
};
|
||||
|
||||
if (!settings.proxyEnabled) {
|
||||
return blank;
|
||||
}
|
||||
|
||||
const resolved = resolveProxySettings(settings);
|
||||
const noProxy = resolved.bypassRules
|
||||
.split(/[,\n;]/)
|
||||
.map((rule) => rule.trim())
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
|
||||
return {
|
||||
HTTP_PROXY: resolved.httpProxy,
|
||||
HTTPS_PROXY: resolved.httpsProxy,
|
||||
ALL_PROXY: resolved.allProxy,
|
||||
http_proxy: resolved.httpProxy,
|
||||
https_proxy: resolved.httpsProxy,
|
||||
all_proxy: resolved.allProxy,
|
||||
NO_PROXY: noProxy,
|
||||
no_proxy: noProxy,
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,12 @@ export interface AppSettings {
|
||||
gatewayAutoStart: boolean;
|
||||
gatewayPort: number;
|
||||
gatewayToken: string;
|
||||
proxyEnabled: boolean;
|
||||
proxyServer: string;
|
||||
proxyHttpServer: string;
|
||||
proxyHttpsServer: string;
|
||||
proxyAllServer: string;
|
||||
proxyBypassRules: string;
|
||||
|
||||
// Update
|
||||
updateChannel: 'stable' | 'beta' | 'dev';
|
||||
@@ -61,6 +67,12 @@ const defaults: AppSettings = {
|
||||
gatewayAutoStart: true,
|
||||
gatewayPort: 18789,
|
||||
gatewayToken: generateToken(),
|
||||
proxyEnabled: false,
|
||||
proxyServer: '',
|
||||
proxyHttpServer: '',
|
||||
proxyHttpsServer: '',
|
||||
proxyAllServer: '',
|
||||
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
|
||||
|
||||
// Update
|
||||
updateChannel: 'stable',
|
||||
|
||||
Reference in New Issue
Block a user