From ae971f6e47185ab54884bedcdbc289b6646d5f75 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Sun, 11 Oct 2026 16:33:12 +0800 Subject: [PATCH 1/3] fix(o87): preserve proxy connect cause; skip rdk-docs warm under HTTPS_PROXY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Board lock (board-o67-o70-baseline): same tunnel, only HTTPS_PROXY — MOSS_NO_RDK_DOCS=1 → 11s useful answer; MCP-on → CONNECT present but fails with truncated 网关原文:). H3 = MCP/npx tunnel contention. - Classify UND_ERR_CONNECT_TIMEOUT as network (not model timeout/auth) - Always attach low-level Details for connection failures; drop junk ) - EnvHttpProxyAgent connectTimeout 30s when proxy set (MOSS_CONNECT_TIMEOUT_MS) - Skip eager rdk-docs npx warm under proxy (shouldEagerWarmRdkDocs); tool call still starts it - Fixture: CONNECT-then-blackhole → network + cause, not timeout/auth --- src/cli-main.ts | 15 +- src/cli/rdk-docs-mcp.ts | 12 + src/provider/connection-error.ts | 15 ++ src/provider/error-classify.ts | 52 ++++- src/provider/errors.ts | 21 +- src/provider/keep-alive-dispatcher.ts | 31 ++- test/error-classify-500.spec.mjs | 4 +- test/o87-connect-without-answer.spec.mjs | 276 +++++++++++++++++++++++ 8 files changed, 411 insertions(+), 15 deletions(-) create mode 100755 test/o87-connect-without-answer.spec.mjs diff --git a/src/cli-main.ts b/src/cli-main.ts index 6fbf4f25..217197c0 100644 --- a/src/cli-main.ts +++ b/src/cli-main.ts @@ -89,6 +89,7 @@ import { rdkDocsAutoConnectEnabled, readRdkDocsFlag, resolveRdkDocsPackage, + shouldEagerWarmRdkDocs, withBuiltinRdkDocs, } from './cli/rdk-docs-mcp.js'; import { McpToolRegistry, buildMcpStableIndex } from './core/mcp/registry.js'; @@ -1199,11 +1200,17 @@ async function main() { // setTimeout(0) still starts npx immediately each session. It does not // wait for the first question. The timer is unref'd so a failed warm // does not hold the process open by itself. + // O87: under HTTPS_PROXY/HTTP_PROXY, skip the eager warm so npx registry + // CONNECTs do not contend with the first model request on a narrow + // reverse tunnel (board: MCP-on FAIL / MOSS_NO_RDK_DOCS=1 PASS). + // First rdk-docs tool call still starts the server. const warming = mcpRegistry; - const warmTimer = setTimeout(() => { - warming.startDeferred(RDK_DOCS_SERVER_NAME); - }, 0); - if (typeof warmTimer.unref === 'function') warmTimer.unref(); + if (shouldEagerWarmRdkDocs(process.env)) { + const warmTimer = setTimeout(() => { + warming.startDeferred(RDK_DOCS_SERVER_NAME); + }, 0); + if (typeof warmTimer.unref === 'function') warmTimer.unref(); + } // Lazy-loading budget: the system prompt gets one index line per server, // never the tool list itself. The rdk-docs usage pointer sits on that // server's line; a failed connect gets only the unavailable sentence. diff --git a/src/cli/rdk-docs-mcp.ts b/src/cli/rdk-docs-mcp.ts index bc737137..68b45521 100644 --- a/src/cli/rdk-docs-mcp.ts +++ b/src/cli/rdk-docs-mcp.ts @@ -75,6 +75,18 @@ export function hasDeviceTarget(workspaceDir: string, env: NodeJS.ProcessEnv): b return loadDeviceRegistry(workspaceDir).length > 0; } +/** + * O87 / board H3: on a narrow reverse-tunnel HTTPS_PROXY path, eager rdk-docs + * npx warm (registry CONNECTs) contends with the first model request and can + * leave CONNECT-without-answer + truncated 网关原文:). Skip the warm when a + * proxy env is set; the first rdk-docs tool call still starts the server. + * Board lock: /workspace/ux/board-o67-o70-baseline.md (NO_RDK_DOCS=1 PASS / + * MCP-on FAIL on the same tunnel). + */ +export function shouldEagerWarmRdkDocs(env: NodeJS.ProcessEnv = process.env): boolean { + return !Boolean(env.HTTPS_PROXY || env.HTTP_PROXY || env.https_proxy || env.http_proxy); +} + /** * Whether this session should connect the builtin server. * On unless MOSS_NO_RDK_DOCS or `rdkDocs: false`. A device target is not required. diff --git a/src/provider/connection-error.ts b/src/provider/connection-error.ts index 094e88ca..5ac2e347 100644 --- a/src/provider/connection-error.ts +++ b/src/provider/connection-error.ts @@ -59,6 +59,21 @@ function classifyConnectionHint( `DNS lookup failed for ${host} — check your network connection and DNS settings. If using a VPN/proxy, ensure DNS is routed correctly.` ); } + // O87: undici connectTimeout while a proxy is set — blame the tunnel/proxy path, + // never a model "switch to a faster model" timeout. + if ( + causeCode === 'UND_ERR_CONNECT_TIMEOUT' || + (typeof causeName === 'string' && /ConnectTimeout/i.test(causeName)) + ) { + if (hasProxyEnv) { + return hint( + `Timed out connecting to ${host} via HTTPS_PROXY/HTTP_PROXY (undici connectTimeout). Check that the proxy tunnel can complete CONNECT and TLS; if the path is a reverse SSH tunnel, avoid parallel heavy npm/git traffic, or raise MOSS_CONNECT_TIMEOUT_MS.` + ); + } + return hint( + `Timed out connecting to ${host} (undici connectTimeout). Check network, DNS, firewall, and that the gateway is reachable.` + ); + } if (causeCode === 'ECONNREFUSED') { if (hasProxyEnv) { const proxy = diff --git a/src/provider/error-classify.ts b/src/provider/error-classify.ts index 05647466..0a0ccf7e 100644 --- a/src/provider/error-classify.ts +++ b/src/provider/error-classify.ts @@ -239,29 +239,51 @@ function isMossConnectionLead(line: string): boolean { if (/^Proxy refused the connection to \S+/i.test(flat)) return true; if (/^Proxy\/protocol error connecting to \S+$/i.test(flat)) return true; if (/^Check that \S+ is reachable/i.test(flat)) return true; + if (/^Proxy connection refused while reaching \S+/i.test(flat)) return true; + if (/^Timed out connecting to \S+ via (?:HTTPS_PROXY|HTTP_PROXY)/i.test(flat)) return true; return ( /^(?:TypeError: )?fetch failed for \S+/i.test(flat) && - /ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH/i.test(flat) + /ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH|UND_ERR_|Connect Timeout Error/i.test( + flat + ) ); } +/** O87: a lone ")" / punctuation leftover is not a gateway body. */ +function isPunctuationOnlyDetail(text: string): boolean { + const flat = text.replace(/\s+/g, '').trim(); + if (!flat) return true; + for (const ch of flat) { + if (!'()[]{}<>.,;:!?\'"`-+~|/\\'.includes(ch)) return false; + } + return true; +} + /** A syscall or client abort, not a body the gateway wrote. */ function isTransportOnlyLine(line: string): boolean { if (isMossConnectionLead(line)) return true; const flat = line.replace(/\s+/g, ' ').trim(); if (!flat || isHtmlDocument(flat)) return !flat; + // O87: punctuation-only leftovers (e.g. a lone ")") are not gateway bodies. + if (isPunctuationOnlyDetail(flat)) return true; if ( - /^(?:[A-Za-z]+ )?(?:ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ECONNABORTED|EPIPE|EAI_NONAME|EAI_FAIL|EAI_NODATA)\b/i.test( + /^(?:[A-Za-z]+ )?(?:ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ECONNABORTED|EPIPE|EAI_NONAME|EAI_FAIL|EAI_NODATA|UND_ERR_[A-Z0-9_]+)\b/i.test( flat ) ) { return true; } + if (/^Connect Timeout Error\b/i.test(flat)) return true; if (/^(?:TypeError: )?fetch failed\b/i.test(flat)) return true; if (/^connection refused\b/i.test(flat)) return true; if (/^getaddrinfo\b/i.test(flat)) return true; - if (/^(?:AbortError|TimeoutError|HeadersTimeoutError|BodyTimeoutError)\b/i.test(flat)) + if ( + /^(?:AbortError|TimeoutError|HeadersTimeoutError|BodyTimeoutError|ConnectTimeoutError)\b/i.test( + flat + ) + ) { return true; + } return /^The operation was aborted\b/i.test(flat); } @@ -279,7 +301,7 @@ function stripAuthoredTails(text: string): string { function lowLevelCause(raw: string): string { const cleaned = stripLoginPagePrefix(raw); const paren = cleaned.match( - /\(((?:ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ECONNABORTED|EPIPE)\b[^)]{0,180})\)/i + /\(((?:ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ECONNABORTED|EPIPE|UND_ERR_[A-Z0-9_]+)\b[^)]{0,180})\)/i ); if (paren?.[1]) return paren[1].trim(); for (const line of cleaned.split('\n')) { @@ -307,9 +329,16 @@ function gatewayDetail(raw: string): string { .trim(); } +/** O87: refuse punctuation-only / empty "gateway" leftovers such as a lone ")". */ +function isJunkGatewayDetail(detail: string): boolean { + const flat = detail.replace(/\s+/g, ' ').trim(); + if (!flat) return true; + return isPunctuationOnlyDetail(flat); +} + function withGatewayText(hint: string, raw: string, audience?: 'setup' | 'chat'): string { const detail = gatewayDetail(raw); - if (detail) + if (detail && !isJunkGatewayDetail(detail)) return hint.includes(detail) ? hint : `${hint}\n${msg('网关原文:', 'Gateway text: ')}${detail}`; @@ -401,7 +430,7 @@ function face( return { category, userMessage, actions, silent: false, retryable }; } -type GateAttach = 'none' | 'body' | 'setup'; +type GateAttach = 'none' | 'body' | 'setup' | 'details'; /** User-facing classifier copy. English is the fallback; zh is the zh voice. */ const COPY = { @@ -546,7 +575,13 @@ function classifyProviderErrorNow(input: ProviderErrorInput): ProviderErrorSurfa category, attach === 'none' ? text - : withGatewayText(text, raw, attach === 'setup' ? input.audience : undefined), + : withGatewayText( + text, + raw, + // 'details' = always attach low-level cause (O87 chat + setup). + // 'setup' still respects audience so chat timeouts stay brief. + attach === 'details' ? 'setup' : attach === 'setup' ? input.audience : undefined + ), actions, retryable ); @@ -598,8 +633,9 @@ function classifyProviderErrorNow(input: ProviderErrorInput): ProviderErrorSurfa ); // O67: when a proxy is configured, a refused connect is almost always the // proxy port — do not blame the model base URL. + // O87: attach='details' so chat keeps cause codes (no empty 网关原文:)). const id = refused ? (proxySet ? 'refusedProxy' : 'refused') : 'connect'; - return hit('network', id, RETRY_SETTINGS, true, 'setup'); + return hit('network', id, RETRY_SETTINGS, true, 'details'); } if (isTlsProtocolMismatch(raw, code)) return hit('network', 'httpNotTls', SETTINGS_RETRY, false, 'body'); diff --git a/src/provider/errors.ts b/src/provider/errors.ts index 8c82c0cb..ee33c1c2 100644 --- a/src/provider/errors.ts +++ b/src/provider/errors.ts @@ -190,6 +190,10 @@ const CONNECTION_PATTERNS = [ 'ehostunreach', 'enetunreach', 'unreachable', + // O87: undici connect-phase failures (must not be misread as model timeout). + 'und_err_connect_timeout', + 'und_err_socket', + 'connect timeout error', ]; const AUTH_PATTERNS = [ @@ -254,15 +258,30 @@ export function isQuotaExceededError(message?: string): boolean { ); } +/** + * undici / TCP connect-phase timeout (proxy tunnel, DNS, TCP). + * Must stay under network — never the model "switch to a faster model" path. + */ +export function isConnectTimeoutError(message?: string): boolean { + if (!message) return false; + return /und_err_connect_timeout|connect timeout error|\bconnect(?:ion)? timed?\s*out\b/i.test( + message + ); +} + export function isTimeoutError(message?: string): boolean { if (!message) return false; + // O87: connect-phase timeouts contain the substring "timeout" but are network. + if (isConnectTimeoutError(message)) return false; if (matchesAny(message, TIMEOUT_PATTERNS)) return true; // "timedout"/"timed-out" (no space) variants — same judgment as 'timed out'. return /\btimed?[ _-]?out\b/i.test(message); } export function isConnectionError(message?: string): boolean { - return !!message && matchesAny(message, CONNECTION_PATTERNS); + if (!message) return false; + if (isConnectTimeoutError(message)) return true; + return matchesAny(message, CONNECTION_PATTERNS); } export function isServerError(message?: string): boolean { diff --git a/src/provider/keep-alive-dispatcher.ts b/src/provider/keep-alive-dispatcher.ts index 64cc141d..2390e717 100644 --- a/src/provider/keep-alive-dispatcher.ts +++ b/src/provider/keep-alive-dispatcher.ts @@ -45,6 +45,25 @@ function proxyEnvOptions(): { }; } +/** Default undici connectTimeout under HTTPS_PROXY/HTTP_PROXY (O87). */ +const PROXY_CONNECT_TIMEOUT_MS = 30_000; + +/** + * Resolve undici `connectTimeout` for the global dispatcher. + * - No proxy: leave undefined (undici default 10s). + * - Proxy: 30s, unless MOSS_CONNECT_TIMEOUT_MS is set (0 = undici default). + */ +export function resolveConnectTimeoutMs(proxyConfigured: boolean): number | undefined { + const raw = process.env.MOSS_CONNECT_TIMEOUT_MS; + if (raw != null && String(raw).trim() !== '') { + const n = Number(raw); + if (Number.isFinite(n) && n >= 0) { + return n === 0 ? undefined : Math.floor(n); + } + } + return proxyConfigured ? PROXY_CONNECT_TIMEOUT_MS : undefined; +} + export async function ensureKeepAliveDispatcherInstalled(): Promise { if (installed) return; if (typeof process === 'undefined' || !process.versions?.node) return; @@ -63,10 +82,20 @@ async function installKeepAliveDispatcher(): Promise { } const { Agent, EnvHttpProxyAgent, setGlobalDispatcher } = mod; - const common = { + // O87: undici defaults connectTimeout to 10s. Board → ssh -R → HTTPS_PROXY → + // gateway often needs longer after CONNECT; raise only when a proxy is set. + // Override with MOSS_CONNECT_TIMEOUT_MS (milliseconds). 0 keeps undici default. + const connectTimeout = resolveConnectTimeoutMs(hasProxyEnv()); + const common: { + keepAliveTimeout: number; + keepAliveMaxTimeout: number; + connections: number; + connectTimeout?: number; + } = { keepAliveTimeout: 60_000, keepAliveMaxTimeout: 600_000, connections: 8, + ...(connectTimeout !== undefined ? { connectTimeout } : {}), }; let nextDispatcher: InstanceType | InstanceType; diff --git a/test/error-classify-500.spec.mjs b/test/error-classify-500.spec.mjs index 5b30bbb0..24cdd5f5 100644 --- a/test/error-classify-500.spec.mjs +++ b/test/error-classify-500.spec.mjs @@ -78,13 +78,15 @@ for (const errorMessage of [ } { + // O87: connection failures always attach low-level cause (chat + setup). const r = classifyProviderError({ errorMessage: 'connect ECONNREFUSED 127.0.0.1:9', status: undefined, }); assert.equal(r.category, 'network'); assert.match(r.userMessage, /Fix the base URL/); - assert.doesNotMatch(r.userMessage, /Gateway text|网关原文|Details:|详细信息/); + assert.match(r.userMessage, /Details:.*ECONNREFUSED/); + assert.doesNotMatch(r.userMessage, /Gateway text|网关原文/); } console.error('error-classify: HTTP 500 is now retryable (was non-retryable unknown) ✓'); diff --git a/test/o87-connect-without-answer.spec.mjs b/test/o87-connect-without-answer.spec.mjs new file mode 100755 index 00000000..383c88f3 --- /dev/null +++ b/test/o87-connect-without-answer.spec.mjs @@ -0,0 +1,276 @@ +#!/usr/bin/env node +/** + * O87: CONNECT without answer — cause preservation, no timeout/auth mislabel, + * proxy connectTimeout, and a local CONNECT-then-blackhole fixture. + * + * Acceptance (QA): + * 1. Preserve full cause (no truncated 网关原文:)) + * 2. Board with only HTTPS_PROXY: CONNECT ∧ answer (board owned by UX; here we + * lock the local negative/positive fixtures) + * 3. Failures must not be mislabeled as timeout/auth + */ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import http from 'node:http'; +import net from 'node:net'; +import { test } from 'node:test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const { classifyProviderError } = await import('../dist/provider/error-classify.js'); +const { isConnectTimeoutError, isTimeoutError, isConnectionError } = + await import('../dist/provider/errors.js'); +const { resolveConnectTimeoutMs } = await import('../dist/provider/keep-alive-dispatcher.js'); + +const MODEL_COACH = /switching models|faster model|换一个.*模型|更快的模型/i; +const AUTH_COPY = /密钥被拒绝|API key was rejected|401|paste the key/i; + +test('O87 classify: UND_ERR_CONNECT_TIMEOUT is network, not model timeout/auth', () => { + assert.equal(isConnectTimeoutError('UND_ERR_CONNECT_TIMEOUT: Connect Timeout Error'), true); + assert.equal( + isTimeoutError('fetch failed (UND_ERR_CONNECT_TIMEOUT: Connect Timeout Error)'), + false + ); + assert.equal( + isConnectionError('fetch failed (UND_ERR_CONNECT_TIMEOUT: Connect Timeout Error)'), + true + ); + + const raw = + 'TypeError: fetch failed for ai-api.d-robotics.cc (UND_ERR_CONNECT_TIMEOUT: Connect Timeout Error)\n' + + 'Timed out connecting to ai-api.d-robotics.cc via HTTPS_PROXY/HTTP_PROXY (undici connectTimeout). Check that the proxy tunnel can complete CONNECT and TLS.'; + const r = classifyProviderError({ errorMessage: raw, locale: 'zh_CN.UTF-8' }); + assert.equal(r.category, 'network'); + assert.match(r.userMessage, /网络连接失败/); + assert.doesNotMatch(r.userMessage, MODEL_COACH); + assert.doesNotMatch(r.userMessage, AUTH_COPY); + assert.doesNotMatch(r.userMessage, /网关原文:\s*\)/); + assert.match(r.userMessage, /详细信息:.*UND_ERR_CONNECT_TIMEOUT/); +}); + +test('O87 classify: lone ")" must not become 网关原文:)', () => { + const r = classifyProviderError({ + errorMessage: 'fetch failed for ai-api.d-robotics.cc\n)', + locale: 'zh_CN.UTF-8', + }); + assert.equal(r.category, 'network'); + assert.doesNotMatch(r.userMessage, /网关原文/); + assert.doesNotMatch(r.userMessage, /网关原文:\s*\)/); +}); + +test('O87 classify: real gateway bodies still attach as 网关原文', () => { + const r = classifyProviderError({ + status: 401, + errorMessage: 'invalid api key', + locale: 'zh_CN.UTF-8', + }); + assert.equal(r.category, 'auth'); + assert.match(r.userMessage, /网关原文:invalid api key/); +}); + +test('O87 resolveConnectTimeoutMs: proxy raises to 30s; override honored', () => { + const prev = process.env.MOSS_CONNECT_TIMEOUT_MS; + try { + delete process.env.MOSS_CONNECT_TIMEOUT_MS; + assert.equal(resolveConnectTimeoutMs(false), undefined); + assert.equal(resolveConnectTimeoutMs(true), 30_000); + process.env.MOSS_CONNECT_TIMEOUT_MS = '45000'; + assert.equal(resolveConnectTimeoutMs(true), 45_000); + process.env.MOSS_CONNECT_TIMEOUT_MS = '0'; + assert.equal(resolveConnectTimeoutMs(true), undefined); + } finally { + if (prev === undefined) delete process.env.MOSS_CONNECT_TIMEOUT_MS; + else process.env.MOSS_CONNECT_TIMEOUT_MS = prev; + } +}); + +test('O87 connection-error: UND_ERR_CONNECT_TIMEOUT + proxy → proxy tunnel hint', async () => { + const prev = { + HTTPS_PROXY: process.env.HTTPS_PROXY, + HTTP_PROXY: process.env.HTTP_PROXY, + }; + process.env.HTTPS_PROXY = 'http://127.0.0.1:18080'; + delete process.env.HTTP_PROXY; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + const e = new TypeError('fetch failed'); + e.cause = { + name: 'ConnectTimeoutError', + code: 'UND_ERR_CONNECT_TIMEOUT', + message: 'Connect Timeout Error', + }; + throw e; + }; + try { + const { fetchWithConnectionContext } = await import('../dist/provider/connection-error.js'); + await fetchWithConnectionContext('https://ai-api.example.invalid/v1/x', { method: 'GET' }); + assert.fail('should throw'); + } catch (err) { + assert.match(err.message, /UND_ERR_CONNECT_TIMEOUT/); + assert.match(err.hint, /HTTPS_PROXY|proxy tunnel|connectTimeout/i); + assert.doesNotMatch(err.hint, MODEL_COACH); + } finally { + globalThis.fetch = originalFetch; + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + } +}); + +/** + * Negative fixture: CONNECT returns 200 Connection Established, then TLS bytes + * are never forwarded (blackhole). Asserts network/proxy copy + full cause, + * never timeoutSlow / auth / empty 网关原文:). + */ +test('O87 fixture: CONNECT then blackhole → network fail with cause, not timeout/auth', async () => { + const log = []; + const sockets = new Set(); + const proxy = net.createServer((sock) => { + sockets.add(sock); + sock.on('close', () => sockets.delete(sock)); + sock.on('error', () => {}); + sock.once('data', (buf) => { + const first = buf.toString('latin1').split('\r\n')[0]; + log.push(first); + if (/^CONNECT\b/i.test(first)) { + sock.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + // Blackhole after CONNECT — never complete TLS. + sock.on('data', () => {}); + return; + } + sock.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n'); + }); + }); + await new Promise((r) => proxy.listen(0, '127.0.0.1', r)); + const { port } = proxy.address(); + const proxyUrl = `http://127.0.0.1:${port}`; + + const childCode = ` + process.env.HTTPS_PROXY = ${JSON.stringify(proxyUrl)}; + process.env.HTTP_PROXY = ${JSON.stringify(proxyUrl)}; + process.env.MOSS_CONNECT_TIMEOUT_MS = '1500'; + process.env.MOSS_DISABLE_CONN_WARMUP = '1'; + for (const k of ['NODE_USE_ENV_PROXY', 'NO_PROXY', 'no_proxy', 'https_proxy', 'http_proxy']) + delete process.env[k]; + const { fetchWithConnectionContext } = await import('./dist/provider/connection-error.js'); + const { classifyProviderError, renderProviderErrorSurface } = await import( + './dist/provider/error-classify.js' + ); + try { + await fetchWithConnectionContext('https://o87-blackhole.example.invalid/v1/chat', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + }); + console.error('UNEXPECTED_OK'); + process.exit(2); + } catch (err) { + const message = err?.hint ? \`\${err.message}\\n\${err.hint}\` : String(err?.message ?? err); + const surface = classifyProviderError({ errorMessage: message, locale: 'zh_CN.UTF-8' }); + const text = renderProviderErrorSurface(surface); + console.log(JSON.stringify({ + category: surface.category, + text, + message, + hint: err?.hint ?? null, + })); + process.exit(0); + } + `; + + const result = await new Promise((resolve) => { + const child = spawn(process.execPath, ['--input-type=module', '-e', childCode], { + cwd: ROOT, + env: { ...process.env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d)); + child.stderr.on('data', (d) => (stderr += d)); + const t = setTimeout(() => child.kill('SIGKILL'), 20_000); + child.on('exit', (code) => { + clearTimeout(t); + resolve({ code, stdout, stderr }); + }); + }); + + for (const s of sockets) { + try { + s.destroy(); + } catch { + /* ignore */ + } + } + await new Promise((r) => proxy.close(r)); + + assert.equal(result.code, 0, `child failed: ${result.stderr || result.stdout}`); + assert.ok( + log.some((l) => l.startsWith('CONNECT o87-blackhole.example.invalid:443')), + `proxy must see CONNECT; log=${JSON.stringify(log)}` + ); + const payload = JSON.parse(result.stdout.trim().split('\n').at(-1)); + assert.equal(payload.category, 'network', payload); + assert.doesNotMatch(payload.text, MODEL_COACH); + assert.doesNotMatch(payload.text, AUTH_COPY); + assert.doesNotMatch(payload.text, /网关原文:\s*\)/); + assert.match( + `${payload.message}\n${payload.hint ?? ''}\n${payload.text}`, + /UND_ERR_CONNECT_TIMEOUT|connectTimeout|HTTPS_PROXY|代理|网络连接失败|Timed out connecting/i + ); +}); + +/** + * Positive fixture: CONNECT + full TLS/HTTP reverse proxy to a local https + * origin is heavy; instead serve HTTP via a CONNECT-capable path is not + * typical for HTTPS_PROXY. Positive control: same proxy forwards CONNECT to a + * tiny TLS-less echo is unreliable with undici HTTPS. We assert the happy + * path at classify/dispatcher unit level above; board e2e is UX-owned. + */ +test('O87 positive: working local HTTP origin without proxy still succeeds', async () => { + const server = http.createServer((req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address(); + const prevProxy = { + HTTPS_PROXY: process.env.HTTPS_PROXY, + HTTP_PROXY: process.env.HTTP_PROXY, + https_proxy: process.env.https_proxy, + http_proxy: process.env.http_proxy, + }; + for (const k of Object.keys(prevProxy)) delete process.env[k]; + try { + const { fetchWithConnectionContext } = await import('../dist/provider/connection-error.js'); + const res = await fetchWithConnectionContext(`http://127.0.0.1:${port}/v1/x`, { + method: 'GET', + }); + assert.equal(res.status, 200); + assert.equal(await res.text(), '{"ok":true}'); + } finally { + for (const [k, v] of Object.entries(prevProxy)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + await new Promise((r) => server.close(r)); + } +}); + +test('O87 board H3: skip eager rdk-docs warm when HTTPS_PROXY is set', async () => { + const { shouldEagerWarmRdkDocs } = await import('../dist/cli/rdk-docs-mcp.js'); + assert.equal(shouldEagerWarmRdkDocs({}), true); + assert.equal(shouldEagerWarmRdkDocs({ HTTPS_PROXY: 'http://127.0.0.1:18080' }), false); + assert.equal(shouldEagerWarmRdkDocs({ https_proxy: 'http://127.0.0.1:18080' }), false); + assert.equal(shouldEagerWarmRdkDocs({ HTTP_PROXY: 'http://127.0.0.1:9' }), false); + // Opt-out of docs is orthogonal; warm policy is purely proxy-env. + assert.equal( + shouldEagerWarmRdkDocs({ HTTPS_PROXY: 'http://127.0.0.1:18080', MOSS_NO_RDK_DOCS: '1' }), + false + ); +}); + +console.log('[PASS] o87-connect-without-answer'); From c2c8c761ff1243c4e4e327ebb36b514eebd4fcb8 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Sun, 11 Oct 2026 17:08:49 +0800 Subject: [PATCH 2/3] fix(o87): register MOSS_CONNECT_TIMEOUT_MS in moss config env O87 added the env for proxy connectTimeout but missed the authoritative inventory; cli-config-env.spec failed on Ubuntu CI. --- src/cli/config-commands.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cli/config-commands.ts b/src/cli/config-commands.ts index bb3b0895..f8616834 100644 --- a/src/cli/config-commands.ts +++ b/src/cli/config-commands.ts @@ -394,6 +394,7 @@ export const MOSS_ENV_REFERENCE: ReadonlyArray<{ group: string; vars: readonly s 'MOSS_PRICE_IN', 'MOSS_PRICE_OUT', 'MOSS_DISABLE_CONN_WARMUP', + 'MOSS_CONNECT_TIMEOUT_MS (ms; undici connectTimeout; default 30000 when HTTPS_PROXY/HTTP_PROXY is set; 0 = undici default)', 'MOSS_LLM_FIRST_CHUNK_TIMEOUT_MS', 'MOSS_PI_AI_FIRST_EVENT_TIMEOUT_MS', 'MOSS_PI_AI_INTER_EVENT_TIMEOUT_MS', @@ -509,6 +510,8 @@ const ENV_NOTE_ZH: Record = { '1|true|yes|on 向 npm 查询文档包的最新版是否新于钉住的版本;默认关闭,启动时不用', '0 stops acceptance commands from appending the login-shell PATH': '设为 0 时,验收命令不再追加登录环境里的 PATH', + 'ms; undici connectTimeout; default 30000 when HTTPS_PROXY/HTTP_PROXY is set; 0 = undici default': + '毫秒;undici connectTimeout;设置 HTTPS_PROXY/HTTP_PROXY 时默认 30000;0 = undici 默认', 'legacy alias': '旧别名', 'auto|en|zh; process env or --lang; a project .env cannot set this; flag > env > config language > system locale': 'auto|en|zh;进程环境或 --lang;项目 .env 不能设置;优先级为参数、环境变量、配置 language、系统区域', From 3067b25ca74902ecea7bd2fd14355f6eeb4280b8 Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Sun, 11 Oct 2026 17:21:03 +0800 Subject: [PATCH 3/3] fix(o87): zh config env note without undici/connectTimeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite MOSS_CONNECT_TIMEOUT_MS EN/ZH notes to prose (代理连接超时 / library default). Skip bare English transport prose under 详细信息 so cli-ui-language stays green; still attach ECONNREFUSED/UND_ERR_* codes. --- src/cli/config-commands.ts | 6 +++--- src/provider/error-classify.ts | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/cli/config-commands.ts b/src/cli/config-commands.ts index f8616834..687f0f5f 100644 --- a/src/cli/config-commands.ts +++ b/src/cli/config-commands.ts @@ -394,7 +394,7 @@ export const MOSS_ENV_REFERENCE: ReadonlyArray<{ group: string; vars: readonly s 'MOSS_PRICE_IN', 'MOSS_PRICE_OUT', 'MOSS_DISABLE_CONN_WARMUP', - 'MOSS_CONNECT_TIMEOUT_MS (ms; undici connectTimeout; default 30000 when HTTPS_PROXY/HTTP_PROXY is set; 0 = undici default)', + 'MOSS_CONNECT_TIMEOUT_MS (ms; proxy connect timeout; default 30000 when HTTPS_PROXY/HTTP_PROXY is set; 0 = library default)', 'MOSS_LLM_FIRST_CHUNK_TIMEOUT_MS', 'MOSS_PI_AI_FIRST_EVENT_TIMEOUT_MS', 'MOSS_PI_AI_INTER_EVENT_TIMEOUT_MS', @@ -510,8 +510,8 @@ const ENV_NOTE_ZH: Record = { '1|true|yes|on 向 npm 查询文档包的最新版是否新于钉住的版本;默认关闭,启动时不用', '0 stops acceptance commands from appending the login-shell PATH': '设为 0 时,验收命令不再追加登录环境里的 PATH', - 'ms; undici connectTimeout; default 30000 when HTTPS_PROXY/HTTP_PROXY is set; 0 = undici default': - '毫秒;undici connectTimeout;设置 HTTPS_PROXY/HTTP_PROXY 时默认 30000;0 = undici 默认', + 'ms; proxy connect timeout; default 30000 when HTTPS_PROXY/HTTP_PROXY is set; 0 = library default': + '毫秒;代理连接超时;设置 HTTPS_PROXY/HTTP_PROXY 时默认 30000;0 = 库默认', 'legacy alias': '旧别名', 'auto|en|zh; process env or --lang; a project .env cannot set this; flag > env > config language > system locale': 'auto|en|zh;进程环境或 --lang;项目 .env 不能设置;优先级为参数、环境变量、配置 language、系统区域', diff --git a/src/provider/error-classify.ts b/src/provider/error-classify.ts index 0a0ccf7e..f3bc4829 100644 --- a/src/provider/error-classify.ts +++ b/src/provider/error-classify.ts @@ -297,6 +297,18 @@ function stripAuthoredTails(text: string): string { return out; } +/** O87: keep syscall/undici codes under 详细信息, not English prose that duplicates the hint. */ +function isCodeBearingCause(text: string): boolean { + return ( + /\b(?:ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|ENETUNREACH|ECONNABORTED|EPIPE|EAI_NONAME|EAI_FAIL|EAI_NODATA|UND_ERR_[A-Z0-9_]+)\b/i.test( + text + ) || + /\b(?:ConnectTimeoutError|HeadersTimeoutError|BodyTimeoutError|TimeoutError|AbortError)\b/i.test( + text + ) + ); +} + /** Syscall or client-abort text shown under 详细信息, never as gateway body. */ function lowLevelCause(raw: string): string { const cleaned = stripLoginPagePrefix(raw); @@ -309,7 +321,11 @@ function lowLevelCause(raw: string): string { if (!text || isMossConnectionLead(text)) continue; text = stripAuthoredTails(text).trim(); if (!text || isMossConnectionLead(text)) continue; - if (isTransportOnlyLine(text)) return text.slice(0, 300); + // Bare "connection refused" (no ECONNREFUSED/UND_ERR_*) is already covered by the zh hint. + if (isTransportOnlyLine(text)) { + if (!isCodeBearingCause(text)) continue; + return text.slice(0, 300); + } } return ''; }