Stabilize gateway reload/restart behavior and remove doctor --json dependency (#504)
This commit is contained in:
committed by
GitHub
Unverified
parent
89bda3c7af
commit
7f3408559d
@@ -25,7 +25,9 @@ function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): voi
|
||||
void reason;
|
||||
}
|
||||
|
||||
const FORCE_RESTART_CHANNELS = new Set(['dingtalk', 'wecom', 'feishu', 'whatsapp']);
|
||||
// Keep reload-first for feishu to avoid restart storms when channel auth/network is flaky.
|
||||
// GatewayManager.reload() already falls back to restart when reload is unhealthy.
|
||||
const FORCE_RESTART_CHANNELS = new Set(['dingtalk', 'wecom', 'whatsapp']);
|
||||
|
||||
function scheduleGatewayChannelSaveRefresh(
|
||||
ctx: HostApiContext,
|
||||
|
||||
@@ -43,6 +43,12 @@ import { GatewayConnectionMonitor } from './connection-monitor';
|
||||
import { GatewayLifecycleController, LifecycleSupersededError } from './lifecycle-controller';
|
||||
import { launchGatewayProcess } from './process-launcher';
|
||||
import { GatewayRestartController } from './restart-controller';
|
||||
import { GatewayRestartGovernor } from './restart-governor';
|
||||
import {
|
||||
DEFAULT_GATEWAY_RELOAD_POLICY,
|
||||
loadGatewayReloadPolicy,
|
||||
type GatewayReloadPolicy,
|
||||
} from './reload-policy';
|
||||
import { classifyGatewayStderrMessage, recordGatewayStartupStderrLine } from './startup-stderr';
|
||||
import { runGatewayStartupSequence } from './startup-orchestrator';
|
||||
|
||||
@@ -94,12 +100,15 @@ export class GatewayManager extends EventEmitter {
|
||||
private readonly connectionMonitor = new GatewayConnectionMonitor();
|
||||
private readonly lifecycleController = new GatewayLifecycleController();
|
||||
private readonly restartController = new GatewayRestartController();
|
||||
private readonly restartGovernor = new GatewayRestartGovernor();
|
||||
private reloadDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private reloadPolicy: GatewayReloadPolicy = { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
private reloadPolicyLoadedAt = 0;
|
||||
private reloadPolicyRefreshPromise: Promise<void> | null = null;
|
||||
private externalShutdownSupported: boolean | null = null;
|
||||
private lastRestartAt = 0;
|
||||
private reconnectAttemptsTotal = 0;
|
||||
private reconnectSuccessTotal = 0;
|
||||
private static readonly RESTART_COOLDOWN_MS = 2500;
|
||||
private static readonly RELOAD_POLICY_REFRESH_MS = 15_000;
|
||||
|
||||
constructor(config?: Partial<ReconnectConfig>) {
|
||||
super();
|
||||
@@ -109,6 +118,9 @@ export class GatewayManager extends EventEmitter {
|
||||
this.emit('status', status);
|
||||
},
|
||||
onTransition: (previousState, nextState) => {
|
||||
if (nextState === 'running') {
|
||||
this.restartGovernor.onRunning();
|
||||
}
|
||||
this.restartController.flushDeferredRestart(
|
||||
`status:${previousState}->${nextState}`,
|
||||
{
|
||||
@@ -186,6 +198,7 @@ export class GatewayManager extends EventEmitter {
|
||||
logger.info(`Gateway start requested (port=${this.status.port})`);
|
||||
this.lastSpawnSummary = null;
|
||||
this.shouldReconnect = true;
|
||||
await this.refreshReloadPolicy(true);
|
||||
|
||||
// Lazily load device identity (async file I/O + key generation).
|
||||
// Must happen before connect() which uses the identity for the handshake.
|
||||
@@ -353,18 +366,27 @@ export class GatewayManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const sinceLastRestart = now - this.lastRestartAt;
|
||||
if (sinceLastRestart < GatewayManager.RESTART_COOLDOWN_MS) {
|
||||
logger.info(
|
||||
`Gateway restart skipped due to cooldown (${sinceLastRestart}ms < ${GatewayManager.RESTART_COOLDOWN_MS}ms)`,
|
||||
const decision = this.restartGovernor.decide();
|
||||
if (!decision.allow) {
|
||||
const observability = this.restartGovernor.getObservability();
|
||||
logger.warn(
|
||||
`[gateway-restart-governor] restart suppressed reason=${decision.reason} retryAfterMs=${decision.retryAfterMs} ` +
|
||||
`suppressed=${observability.suppressed_total} executed=${observability.executed_total} circuitOpenUntil=${observability.circuit_open_until}`,
|
||||
);
|
||||
const props = {
|
||||
reason: decision.reason,
|
||||
retry_after_ms: decision.retryAfterMs,
|
||||
gateway_restart_suppressed_total: observability.suppressed_total,
|
||||
gateway_restart_executed_total: observability.executed_total,
|
||||
gateway_restart_circuit_open_until: observability.circuit_open_until,
|
||||
};
|
||||
trackMetric('gateway.restart.suppressed', props);
|
||||
captureTelemetryEvent('gateway_restart_suppressed', props);
|
||||
return;
|
||||
}
|
||||
|
||||
const pidBefore = this.status.pid;
|
||||
logger.info(`[gateway-refresh] mode=restart requested pidBefore=${pidBefore ?? 'n/a'}`);
|
||||
this.lastRestartAt = now;
|
||||
this.restartInFlight = (async () => {
|
||||
await this.stop();
|
||||
await this.start();
|
||||
@@ -372,8 +394,18 @@ export class GatewayManager extends EventEmitter {
|
||||
|
||||
try {
|
||||
await this.restartInFlight;
|
||||
this.restartGovernor.recordExecuted();
|
||||
const observability = this.restartGovernor.getObservability();
|
||||
const props = {
|
||||
gateway_restart_executed_total: observability.executed_total,
|
||||
gateway_restart_suppressed_total: observability.suppressed_total,
|
||||
gateway_restart_circuit_open_until: observability.circuit_open_until,
|
||||
};
|
||||
trackMetric('gateway.restart.executed', props);
|
||||
captureTelemetryEvent('gateway_restart_executed', props);
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=restart result=applied pidBefore=${pidBefore ?? 'n/a'} pidAfter=${this.status.pid ?? 'n/a'}`,
|
||||
`[gateway-refresh] mode=restart result=applied pidBefore=${pidBefore ?? 'n/a'} pidAfter=${this.status.pid ?? 'n/a'} ` +
|
||||
`suppressed=${observability.suppressed_total} executed=${observability.executed_total} circuitOpenUntil=${observability.circuit_open_until}`,
|
||||
);
|
||||
} finally {
|
||||
this.restartInFlight = null;
|
||||
@@ -413,6 +445,16 @@ export class GatewayManager extends EventEmitter {
|
||||
* Falls back to restart on unsupported platforms or signaling failures.
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
await this.refreshReloadPolicy();
|
||||
|
||||
if (this.reloadPolicy.mode === 'off' || this.reloadPolicy.mode === 'restart') {
|
||||
logger.info(
|
||||
`[gateway-refresh] mode=reload result=policy_forced_restart policy=${this.reloadPolicy.mode}`,
|
||||
);
|
||||
await this.restart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.restartController.isRestartDeferred({
|
||||
state: this.status.state,
|
||||
startLock: this.startLock,
|
||||
@@ -481,17 +523,51 @@ export class GatewayManager extends EventEmitter {
|
||||
* Debounced reload — coalesces multiple rapid config-change events into one
|
||||
* in-process reload when possible.
|
||||
*/
|
||||
debouncedReload(delayMs = 1200): void {
|
||||
debouncedReload(delayMs?: number): void {
|
||||
void this.refreshReloadPolicy();
|
||||
const effectiveDelay = delayMs ?? this.reloadPolicy.debounceMs;
|
||||
if (this.reloadPolicy.mode === 'off' || this.reloadPolicy.mode === 'restart') {
|
||||
logger.debug(
|
||||
`Gateway reload policy=${this.reloadPolicy.mode}; routing debouncedReload to debouncedRestart (${effectiveDelay}ms)`,
|
||||
);
|
||||
this.debouncedRestart(effectiveDelay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reloadDebounceTimer) {
|
||||
clearTimeout(this.reloadDebounceTimer);
|
||||
}
|
||||
logger.debug(`Gateway reload debounced (will fire in ${delayMs}ms)`);
|
||||
logger.debug(`Gateway reload debounced (will fire in ${effectiveDelay}ms)`);
|
||||
this.reloadDebounceTimer = setTimeout(() => {
|
||||
this.reloadDebounceTimer = null;
|
||||
void this.reload().catch((err) => {
|
||||
logger.warn('Debounced Gateway reload failed:', err);
|
||||
});
|
||||
}, delayMs);
|
||||
}, effectiveDelay);
|
||||
}
|
||||
|
||||
private async refreshReloadPolicy(force = false): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (!force && now - this.reloadPolicyLoadedAt < GatewayManager.RELOAD_POLICY_REFRESH_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reloadPolicyRefreshPromise) {
|
||||
await this.reloadPolicyRefreshPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.reloadPolicyRefreshPromise = (async () => {
|
||||
const nextPolicy = await loadGatewayReloadPolicy();
|
||||
this.reloadPolicy = nextPolicy;
|
||||
this.reloadPolicyLoadedAt = Date.now();
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.reloadPolicyRefreshPromise;
|
||||
} finally {
|
||||
this.reloadPolicyRefreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
63
electron/gateway/reload-policy.ts
Normal file
63
electron/gateway/reload-policy.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export type GatewayReloadMode = 'hybrid' | 'reload' | 'restart' | 'off';
|
||||
|
||||
export type GatewayReloadPolicy = {
|
||||
mode: GatewayReloadMode;
|
||||
debounceMs: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_GATEWAY_RELOAD_POLICY: GatewayReloadPolicy = {
|
||||
mode: 'hybrid',
|
||||
debounceMs: 1200,
|
||||
};
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
const MAX_DEBOUNCE_MS = 60_000;
|
||||
|
||||
function normalizeMode(value: unknown): GatewayReloadMode {
|
||||
if (value === 'off' || value === 'reload' || value === 'restart' || value === 'hybrid') {
|
||||
return value;
|
||||
}
|
||||
return DEFAULT_GATEWAY_RELOAD_POLICY.mode;
|
||||
}
|
||||
|
||||
function normalizeDebounceMs(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return DEFAULT_GATEWAY_RELOAD_POLICY.debounceMs;
|
||||
}
|
||||
const rounded = Math.round(value);
|
||||
if (rounded < 0) return 0;
|
||||
if (rounded > MAX_DEBOUNCE_MS) return MAX_DEBOUNCE_MS;
|
||||
return rounded;
|
||||
}
|
||||
|
||||
export function parseGatewayReloadPolicy(config: unknown): GatewayReloadPolicy {
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
}
|
||||
const root = config as Record<string, unknown>;
|
||||
const gateway = (root.gateway && typeof root.gateway === 'object'
|
||||
? root.gateway
|
||||
: {}) as Record<string, unknown>;
|
||||
const reload = (gateway.reload && typeof gateway.reload === 'object'
|
||||
? gateway.reload
|
||||
: {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
mode: normalizeMode(reload.mode),
|
||||
debounceMs: normalizeDebounceMs(reload.debounceMs),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadGatewayReloadPolicy(): Promise<GatewayReloadPolicy> {
|
||||
try {
|
||||
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||
return parseGatewayReloadPolicy(JSON.parse(raw));
|
||||
} catch {
|
||||
return { ...DEFAULT_GATEWAY_RELOAD_POLICY };
|
||||
}
|
||||
}
|
||||
|
||||
145
electron/gateway/restart-governor.ts
Normal file
145
electron/gateway/restart-governor.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
export type RestartDecision =
|
||||
| { allow: true }
|
||||
| {
|
||||
allow: false;
|
||||
reason: 'circuit_open' | 'budget_exceeded' | 'cooldown_active';
|
||||
retryAfterMs: number;
|
||||
};
|
||||
|
||||
type RestartGovernorOptions = {
|
||||
maxRestartsPerWindow: number;
|
||||
windowMs: number;
|
||||
baseCooldownMs: number;
|
||||
maxCooldownMs: number;
|
||||
circuitOpenMs: number;
|
||||
stableResetMs: number;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: RestartGovernorOptions = {
|
||||
maxRestartsPerWindow: 4,
|
||||
windowMs: 10 * 60 * 1000,
|
||||
baseCooldownMs: 2500,
|
||||
maxCooldownMs: 2 * 60 * 1000,
|
||||
circuitOpenMs: 10 * 60 * 1000,
|
||||
stableResetMs: 2 * 60 * 1000,
|
||||
};
|
||||
|
||||
export class GatewayRestartGovernor {
|
||||
private readonly options: RestartGovernorOptions;
|
||||
private restartTimestamps: number[] = [];
|
||||
private circuitOpenUntil = 0;
|
||||
private consecutiveRestarts = 0;
|
||||
private lastRestartAt = 0;
|
||||
private lastRunningAt = 0;
|
||||
private suppressedTotal = 0;
|
||||
private executedTotal = 0;
|
||||
private static readonly MAX_COUNTER = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
constructor(options?: Partial<RestartGovernorOptions>) {
|
||||
this.options = { ...DEFAULT_OPTIONS, ...options };
|
||||
}
|
||||
|
||||
onRunning(now = Date.now()): void {
|
||||
this.lastRunningAt = now;
|
||||
}
|
||||
|
||||
decide(now = Date.now()): RestartDecision {
|
||||
this.pruneOld(now);
|
||||
this.maybeResetConsecutive(now);
|
||||
|
||||
if (now < this.circuitOpenUntil) {
|
||||
this.suppressedTotal = this.incrementCounter(this.suppressedTotal);
|
||||
return {
|
||||
allow: false,
|
||||
reason: 'circuit_open',
|
||||
retryAfterMs: this.circuitOpenUntil - now,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.restartTimestamps.length >= this.options.maxRestartsPerWindow) {
|
||||
this.circuitOpenUntil = now + this.options.circuitOpenMs;
|
||||
this.suppressedTotal = this.incrementCounter(this.suppressedTotal);
|
||||
return {
|
||||
allow: false,
|
||||
reason: 'budget_exceeded',
|
||||
retryAfterMs: this.options.circuitOpenMs,
|
||||
};
|
||||
}
|
||||
|
||||
const requiredCooldown = this.getCooldownMs();
|
||||
if (this.lastRestartAt > 0) {
|
||||
const sinceLast = now - this.lastRestartAt;
|
||||
if (sinceLast < requiredCooldown) {
|
||||
this.suppressedTotal = this.incrementCounter(this.suppressedTotal);
|
||||
return {
|
||||
allow: false,
|
||||
reason: 'cooldown_active',
|
||||
retryAfterMs: requiredCooldown - sinceLast,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { allow: true };
|
||||
}
|
||||
|
||||
recordExecuted(now = Date.now()): void {
|
||||
this.executedTotal = this.incrementCounter(this.executedTotal);
|
||||
this.lastRestartAt = now;
|
||||
this.consecutiveRestarts += 1;
|
||||
this.restartTimestamps.push(now);
|
||||
this.pruneOld(now);
|
||||
}
|
||||
|
||||
getCounters(): { executedTotal: number; suppressedTotal: number } {
|
||||
return {
|
||||
executedTotal: this.executedTotal,
|
||||
suppressedTotal: this.suppressedTotal,
|
||||
};
|
||||
}
|
||||
|
||||
getObservability(): {
|
||||
suppressed_total: number;
|
||||
executed_total: number;
|
||||
circuit_open_until: number;
|
||||
} {
|
||||
return {
|
||||
suppressed_total: this.suppressedTotal,
|
||||
executed_total: this.executedTotal,
|
||||
circuit_open_until: this.circuitOpenUntil,
|
||||
};
|
||||
}
|
||||
|
||||
private getCooldownMs(): number {
|
||||
const factor = Math.pow(2, Math.max(0, this.consecutiveRestarts));
|
||||
return Math.min(this.options.baseCooldownMs * factor, this.options.maxCooldownMs);
|
||||
}
|
||||
|
||||
private maybeResetConsecutive(now: number): void {
|
||||
if (this.lastRunningAt <= 0) return;
|
||||
if (now - this.lastRunningAt >= this.options.stableResetMs) {
|
||||
this.consecutiveRestarts = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private pruneOld(now: number): void {
|
||||
// Detect time rewind (system clock moved backwards) and clear all
|
||||
// time-based guard state to avoid stale lockouts.
|
||||
if (this.restartTimestamps.length > 0 && now < this.restartTimestamps[this.restartTimestamps.length - 1]) {
|
||||
this.restartTimestamps = [];
|
||||
this.circuitOpenUntil = 0;
|
||||
this.lastRestartAt = 0;
|
||||
this.lastRunningAt = 0;
|
||||
this.consecutiveRestarts = 0;
|
||||
return;
|
||||
}
|
||||
const threshold = now - this.options.windowMs;
|
||||
while (this.restartTimestamps.length > 0 && this.restartTimestamps[0] < threshold) {
|
||||
this.restartTimestamps.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private incrementCounter(current: number): number {
|
||||
if (current >= GatewayRestartGovernor.MAX_COUNTER) return 0;
|
||||
return current + 1;
|
||||
}
|
||||
}
|
||||
@@ -1355,7 +1355,9 @@ function registerGatewayHandlers(
|
||||
* For checking package status and channel configuration
|
||||
*/
|
||||
function registerOpenClawHandlers(gatewayManager: GatewayManager): void {
|
||||
const forceRestartChannels = new Set(['dingtalk', 'wecom', 'feishu', 'whatsapp']);
|
||||
// Keep reload-first for feishu to avoid restart storms when channel auth/network is flaky.
|
||||
// GatewayManager.reload() already falls back to restart when reload is unhealthy.
|
||||
const forceRestartChannels = new Set(['dingtalk', 'wecom', 'whatsapp']);
|
||||
|
||||
const scheduleGatewayChannelRestart = (reason: string): void => {
|
||||
if (gatewayManager.getStatus().state !== 'stopped') {
|
||||
|
||||
@@ -716,6 +716,65 @@ export interface ValidationResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const DOCTOR_PARSER_FALLBACK_HINT =
|
||||
'Doctor output could not be confidently interpreted; falling back to local channel config checks.';
|
||||
|
||||
type DoctorValidationParseResult = {
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
undetermined: boolean;
|
||||
};
|
||||
|
||||
export function parseDoctorValidationOutput(channelType: string, output: string): DoctorValidationParseResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const normalizedChannelType = channelType.toLowerCase();
|
||||
const normalizedOutput = output.trim();
|
||||
|
||||
if (!normalizedOutput) {
|
||||
return {
|
||||
errors,
|
||||
warnings: [DOCTOR_PARSER_FALLBACK_HINT],
|
||||
undetermined: true,
|
||||
};
|
||||
}
|
||||
|
||||
const lines = output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const channelLines = lines.filter((line) => line.toLowerCase().includes(normalizedChannelType));
|
||||
let classifiedCount = 0;
|
||||
|
||||
for (const line of channelLines) {
|
||||
const lowerLine = line.toLowerCase();
|
||||
if (lowerLine.includes('error') || lowerLine.includes('unrecognized key')) {
|
||||
errors.push(line);
|
||||
classifiedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (lowerLine.includes('warning')) {
|
||||
warnings.push(line);
|
||||
classifiedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (channelLines.length === 0 || classifiedCount === 0) {
|
||||
warnings.push(DOCTOR_PARSER_FALLBACK_HINT);
|
||||
return {
|
||||
errors,
|
||||
warnings,
|
||||
undetermined: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
warnings,
|
||||
undetermined: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CredentialValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
@@ -853,34 +912,41 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
||||
|
||||
// Run openclaw doctor command to validate config (async to avoid
|
||||
// blocking the main thread).
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
exec(
|
||||
`node openclaw.mjs doctor --json 2>&1`,
|
||||
{
|
||||
cwd: openclawPath,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
windowsHide: true,
|
||||
},
|
||||
(err, stdout) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stdout);
|
||||
},
|
||||
);
|
||||
});
|
||||
const runDoctor = async (command: string): Promise<string> =>
|
||||
await new Promise<string>((resolve, reject) => {
|
||||
exec(
|
||||
command,
|
||||
{
|
||||
cwd: openclawPath,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
windowsHide: true,
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
const combined = `${stdout || ''}${stderr || ''}`;
|
||||
if (err) {
|
||||
const next = new Error(combined || err.message);
|
||||
reject(next);
|
||||
return;
|
||||
}
|
||||
resolve(combined);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
const lowerLine = line.toLowerCase();
|
||||
if (lowerLine.includes(channelType) && lowerLine.includes('error')) {
|
||||
result.errors.push(line.trim());
|
||||
result.valid = false;
|
||||
} else if (lowerLine.includes(channelType) && lowerLine.includes('warning')) {
|
||||
result.warnings.push(line.trim());
|
||||
} else if (lowerLine.includes('unrecognized key') && lowerLine.includes(channelType)) {
|
||||
result.errors.push(line.trim());
|
||||
result.valid = false;
|
||||
}
|
||||
const output = await runDoctor(`node openclaw.mjs doctor 2>&1`);
|
||||
|
||||
const parsedDoctor = parseDoctorValidationOutput(channelType, output);
|
||||
result.errors.push(...parsedDoctor.errors);
|
||||
result.warnings.push(...parsedDoctor.warnings);
|
||||
if (parsedDoctor.errors.length > 0) {
|
||||
result.valid = false;
|
||||
}
|
||||
if (parsedDoctor.undetermined) {
|
||||
logger.warn('Doctor output parsing fell back to local channel checks', {
|
||||
channelType,
|
||||
hint: DOCTOR_PARSER_FALLBACK_HINT,
|
||||
});
|
||||
}
|
||||
|
||||
const config = await readOpenClawConfig();
|
||||
|
||||
@@ -22,6 +22,7 @@ import { logger } from './logger';
|
||||
import { saveProvider, getProvider, ProviderConfig } from './secure-storage';
|
||||
import { getProviderDefaultModel } from './provider-registry';
|
||||
import { isOpenClawPresent } from './paths';
|
||||
import { proxyAwareFetch } from './proxy-fetch';
|
||||
import {
|
||||
loginMiniMaxPortalOAuth,
|
||||
type MiniMaxOAuthToken,
|
||||
@@ -47,6 +48,17 @@ class DeviceOAuthManager extends EventEmitter {
|
||||
private active: boolean = false;
|
||||
private mainWindow: BrowserWindow | null = null;
|
||||
|
||||
private async runWithProxyAwareFetch<T>(task: () => Promise<T>): Promise<T> {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = ((input: string | URL, init?: RequestInit) =>
|
||||
proxyAwareFetch(input, init)) as typeof fetch;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
setWindow(window: BrowserWindow) {
|
||||
this.mainWindow = window;
|
||||
}
|
||||
@@ -109,7 +121,7 @@ class DeviceOAuthManager extends EventEmitter {
|
||||
}
|
||||
const provider = this.activeProvider!;
|
||||
|
||||
const token: MiniMaxOAuthToken = await loginMiniMaxPortalOAuth({
|
||||
const token: MiniMaxOAuthToken = await this.runWithProxyAwareFetch(() => loginMiniMaxPortalOAuth({
|
||||
region,
|
||||
openUrl: async (url) => {
|
||||
logger.info(`[DeviceOAuth] MiniMax opening browser: ${url}`);
|
||||
@@ -133,7 +145,7 @@ class DeviceOAuthManager extends EventEmitter {
|
||||
update: (msg) => logger.info(`[DeviceOAuth] MiniMax progress: ${msg}`),
|
||||
stop: (msg) => logger.info(`[DeviceOAuth] MiniMax progress done: ${msg ?? ''}`),
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
if (!this.active) return;
|
||||
|
||||
@@ -159,7 +171,7 @@ class DeviceOAuthManager extends EventEmitter {
|
||||
}
|
||||
const provider = this.activeProvider!;
|
||||
|
||||
const token: QwenOAuthToken = await loginQwenPortalOAuth({
|
||||
const token: QwenOAuthToken = await this.runWithProxyAwareFetch(() => loginQwenPortalOAuth({
|
||||
openUrl: async (url) => {
|
||||
logger.info(`[DeviceOAuth] Qwen opening browser: ${url}`);
|
||||
shell.openExternal(url).catch((err) =>
|
||||
@@ -179,7 +191,7 @@ class DeviceOAuthManager extends EventEmitter {
|
||||
update: (msg) => logger.info(`[DeviceOAuth] Qwen progress: ${msg}`),
|
||||
stop: (msg) => logger.info(`[DeviceOAuth] Qwen progress done: ${msg ?? ''}`),
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
if (!this.active) return;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkS
|
||||
import { createServer } from 'node:http';
|
||||
import { delimiter, dirname, join } from 'node:path';
|
||||
import { getClawXConfigDir } from './paths';
|
||||
import { proxyAwareFetch } from './proxy-fetch';
|
||||
|
||||
const CLIENT_ID_KEYS = ['OPENCLAW_GEMINI_OAUTH_CLIENT_ID', 'GEMINI_CLI_OAUTH_CLIENT_ID'];
|
||||
const CLIENT_SECRET_KEYS = [
|
||||
@@ -243,7 +244,7 @@ async function installViaNpm(onProgress?: (msg: string) => void): Promise<boolea
|
||||
async function installViaDirectDownload(onProgress?: (msg: string) => void): Promise<boolean> {
|
||||
try {
|
||||
onProgress?.('Downloading Gemini OAuth helper...');
|
||||
const metaRes = await fetch('https://registry.npmjs.org/@google/gemini-cli-core/latest');
|
||||
const metaRes = await proxyAwareFetch('https://registry.npmjs.org/@google/gemini-cli-core/latest');
|
||||
if (!metaRes.ok) {
|
||||
onProgress?.(`Failed to fetch Gemini package metadata: ${metaRes.status}`);
|
||||
return false;
|
||||
@@ -256,7 +257,7 @@ async function installViaDirectDownload(onProgress?: (msg: string) => void): Pro
|
||||
return false;
|
||||
}
|
||||
|
||||
const tarRes = await fetch(tarballUrl);
|
||||
const tarRes = await proxyAwareFetch(tarballUrl);
|
||||
if (!tarRes.ok) {
|
||||
onProgress?.(`Failed to download Gemini package: ${tarRes.status}`);
|
||||
return false;
|
||||
@@ -440,7 +441,7 @@ async function waitForLocalCallback(params: {
|
||||
|
||||
async function getUserEmail(accessToken: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(USERINFO_URL, {
|
||||
const response = await proxyAwareFetch(USERINFO_URL, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -489,7 +490,7 @@ async function pollOperation(
|
||||
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
|
||||
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers });
|
||||
const response = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers });
|
||||
if (!response.ok) {
|
||||
continue;
|
||||
}
|
||||
@@ -530,7 +531,7 @@ async function discoverProject(accessToken: string): Promise<string> {
|
||||
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
|
||||
} = {};
|
||||
|
||||
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
|
||||
const response = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(loadBody),
|
||||
@@ -583,7 +584,7 @@ async function discoverProject(accessToken: string): Promise<string> {
|
||||
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
|
||||
}
|
||||
|
||||
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
|
||||
const onboardResponse = await proxyAwareFetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(onboardBody),
|
||||
@@ -638,7 +639,7 @@ async function exchangeCodeForTokens(
|
||||
body.set('client_secret', clientSecret);
|
||||
}
|
||||
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
const response = await proxyAwareFetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
import { proxyAwareFetch } from './proxy-fetch';
|
||||
|
||||
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||
const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
|
||||
@@ -206,7 +207,7 @@ async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
): Promise<{ access: string; refresh: string; expires: number }> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
const response = await proxyAwareFetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getUvMirrorEnv } from './uv-env';
|
||||
|
||||
const OPENCLAW_DOCTOR_TIMEOUT_MS = 60_000;
|
||||
const MAX_DOCTOR_OUTPUT_BYTES = 10 * 1024 * 1024;
|
||||
const OPENCLAW_DOCTOR_ARGS = ['doctor', '--json'];
|
||||
const OPENCLAW_DOCTOR_ARGS = ['doctor'];
|
||||
const OPENCLAW_DOCTOR_FIX_ARGS = ['doctor', '--fix', '--yes', '--non-interactive'];
|
||||
|
||||
export type OpenClawDoctorMode = 'diagnose' | 'fix';
|
||||
@@ -65,10 +65,12 @@ function getBundledBinPath(): string {
|
||||
: path.join(process.cwd(), 'resources', 'bin', target);
|
||||
}
|
||||
|
||||
async function runDoctorCommand(mode: OpenClawDoctorMode): Promise<OpenClawDoctorResult> {
|
||||
async function runDoctorCommandWithArgs(
|
||||
mode: OpenClawDoctorMode,
|
||||
args: string[],
|
||||
): Promise<OpenClawDoctorResult> {
|
||||
const openclawDir = getOpenClawDir();
|
||||
const entryScript = getOpenClawEntryPath();
|
||||
const args = mode === 'fix' ? OPENCLAW_DOCTOR_FIX_ARGS : OPENCLAW_DOCTOR_ARGS;
|
||||
const command = `openclaw ${args.join(' ')}`;
|
||||
const startedAt = Date.now();
|
||||
|
||||
@@ -194,9 +196,9 @@ async function runDoctorCommand(mode: OpenClawDoctorMode): Promise<OpenClawDocto
|
||||
}
|
||||
|
||||
export async function runOpenClawDoctor(): Promise<OpenClawDoctorResult> {
|
||||
return await runDoctorCommand('diagnose');
|
||||
return await runDoctorCommandWithArgs('diagnose', OPENCLAW_DOCTOR_ARGS);
|
||||
}
|
||||
|
||||
export async function runOpenClawDoctorFix(): Promise<OpenClawDoctorResult> {
|
||||
return await runDoctorCommand('fix');
|
||||
return await runDoctorCommandWithArgs('fix', OPENCLAW_DOCTOR_FIX_ARGS);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user