Stabilize gateway reload/restart behavior and remove doctor --json dependency (#504)

This commit is contained in:
Lingxuan Zuo
2026-03-16 09:47:04 +08:00
committed by GitHub
Unverified
parent 89bda3c7af
commit 7f3408559d
19 changed files with 843 additions and 62 deletions

View File

@@ -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();

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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({

View File

@@ -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);
}