diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fcfc80a..b0c47941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and the versioning follows [Semantic Versioning](https://semver.org/). ### ✨ Added +- Proxy settings now take a list of **direct connections**: hosts that bypass the proxy and connect straight out, so an internal code platform, its git remote, or a self-hosted model stays reachable while everything else still goes through the proxy. Uses the familiar `NO_PROXY` syntax (a domain covers its subdomains), and applies to every outbound path at once — REST, git and the LLM call. - A review that fails because the model is unavailable now says so and tells you what to do — with a local CLI provider (claude / codex) the model comes from that CLI's own configuration, so it has to be changed there. ### 🔧 Fixed diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index cba14dd4..22781768 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -9,6 +9,7 @@ ### ✨ 新增 +- 代理设置新增**直连地址**列表:列出的地址跳过代理直接连接,内网代码平台、它的 git 远端或自建模型服务因此保持可达,其余流量照常走代理。沿用通行的 `NO_PROXY` 写法(填域名同时覆盖子域),并对所有出站路径一并生效——REST、git 与 LLM 调用。 - 因模型不可用而失败的评审现在会明确说明,并给出处理方式——使用本地 CLI 供应商(claude / codex)时,模型来自该 CLI 自身的配置,需要在那里更换。 ### 🔧 修复 diff --git a/apps/desktop/src/main/controllers/config.ts b/apps/desktop/src/main/controllers/config.ts index 54f9da13..9e9fc30c 100644 --- a/apps/desktop/src/main/controllers/config.ts +++ b/apps/desktop/src/main/controllers/config.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto'; import { nativeTheme } from 'electron'; -import { editorThemeNativeSource } from '@meebox/shared'; +import { editorThemeNativeSource, normalizeNoProxy } from '@meebox/shared'; import { writeConfig } from '@meebox/config'; import { buildDraftAdapter } from '../adapters.js'; import { setMainLanguage } from '../i18n/index.js'; @@ -156,12 +156,16 @@ export const setConnections: IpcController<'config:setConnections'> = async (_ev */ export const setProxy: IpcController<'config:setProxy'> = async (_event, req) => { const { bootstrap, logger, reconfigureConnections } = getContext(); - const next = { ...bootstrap.config, proxy: req.proxy }; + // Normalize the bypass list on the way in, not in the form: whichever way the config arrives — this IPC, or a + // hand-edited config.yaml — what lands on disk is one canonical comma-separated line, so the value the user reads + // back is the value actually matched against. + const proxy = { ...req.proxy, no_proxy: normalizeNoProxy(req.proxy.no_proxy) }; + const next = { ...bootstrap.config, proxy }; await writeConfig(bootstrap.paths.configFile, next); - bootstrap.config.proxy = req.proxy; + bootstrap.config.proxy = proxy; await reconfigureConnections(); logger.info( - { enabled: req.proxy.enabled, host: req.proxy.host, port: req.proxy.port }, + { enabled: proxy.enabled, host: proxy.host, port: proxy.port, noProxy: proxy.no_proxy }, 'proxy config updated (hot-reloaded)', ); }; diff --git a/apps/desktop/src/main/utils/proxy.ts b/apps/desktop/src/main/utils/proxy.ts index d28e8c69..194298dd 100644 --- a/apps/desktop/src/main/utils/proxy.ts +++ b/apps/desktop/src/main/utils/proxy.ts @@ -4,15 +4,33 @@ // - shouldBypass: whether loopback/local goes direct (② decides at the call site whether to attach a dispatcher) // Phase one is HTTP proxy only; when enabled=false all forms yield "empty/direct connection", so call sites need not each check the switch. import { ProxyAgent, type Dispatcher } from 'undici'; -import { ERROR_CODES, errorCodeMessage, type ProxyConfig } from '@meebox/shared'; +import { + ERROR_CODES, + LOOPBACK_NO_PROXY, + errorCodeMessage, + matchesNoProxy, + normalizeNoProxy, + type ProxyConfig, +} from '@meebox/shared'; -// loopback / local: always direct connection, never through the proxy. The env path relies on NO_PROXY, the dispatcher path on shouldBypass. -const NO_PROXY = 'localhost,127.0.0.1,::1'; +/** + * Effective bypass rules = the built-in loopback set + whatever the user configured. Loopback is prepended rather than + * left to the user: a local model or local service must never be sent through a proxy, and that guarantee should not + * depend on the user having typed it. + */ +function effectiveNoProxy(proxy: ProxyConfig): string { + return normalizeNoProxy(`${LOOPBACK_NO_PROXY},${proxy.no_proxy ?? ''}`); +} -/** loopback / local host → true (should go direct connection, not through the proxy). */ -export function shouldBypass(host: string): boolean { - const h = host.toLowerCase().replace(/^\[|\]$/g, ''); // strip IPv6 literal brackets - return h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '::1'; +/** + * Host should egress directly (loopback, or covered by the user's `no_proxy`) rather than through the proxy. + * + * The env path hands the same rules to subprocesses via `NO_PROXY` and lets their libraries apply them; this is the + * in-process equivalent for the dispatcher path, which is why both derive from `effectiveNoProxy` — the two egress + * classes must not disagree about the same config. + */ +export function shouldBypass(proxy: ProxyConfig, host: string): boolean { + return matchesNoProxy(host, effectiveNoProxy(proxy)); } /** Build a standard proxy URL: `://[user:pass@]host:port`. undefined when disabled / no host. */ @@ -32,6 +50,7 @@ export function proxyUrl(proxy: ProxyConfig): string | undefined { export function buildProxyEnv(proxy: ProxyConfig): Record { const url = proxyUrl(proxy); if (!url) return {}; + const bypass = effectiveNoProxy(proxy); return { HTTP_PROXY: url, http_proxy: url, @@ -39,8 +58,8 @@ export function buildProxyEnv(proxy: ProxyConfig): Record { https_proxy: url, ALL_PROXY: url, all_proxy: url, - NO_PROXY, - no_proxy: NO_PROXY, + NO_PROXY: bypass, + no_proxy: bypass, }; } @@ -85,14 +104,15 @@ export async function testProxyConnectivity( /** * Build a "proxy-aware" fetch for a target host, to inject into BitbucketClient's opts.fetch. - * host hits loopback/local → returns undefined (the call site uses the default global fetch for a direct connection). - * Otherwise returns a fetch wrapper carrying the dispatcher. Also returns undefined when the proxy is disabled. + * host hits loopback/local or the configured bypass list → returns undefined (the call site uses the default global + * fetch for a direct connection). Otherwise returns a fetch wrapper carrying the dispatcher. Also returns undefined + * when the proxy is disabled. */ export function proxyFetchForHost( proxy: ProxyConfig, host: string, ): ((input: string, init?: RequestInit) => Promise) | undefined { - if (shouldBypass(host)) return undefined; + if (shouldBypass(proxy, host)) return undefined; const dispatcher = buildProxyDispatcher(proxy); if (!dispatcher) return undefined; return (input, init) => diff --git a/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx b/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx index d32a5b04..1cda4ea2 100644 --- a/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx +++ b/apps/desktop/src/renderer/src/components/features/settings/editors/ProxyEditorModal.tsx @@ -109,7 +109,25 @@ export function ProxyEditorModal({ +
{t('settings.proxyNoProxy')}
+
+ {/* Multi-line on purpose: a bypass list is usually several hosts, and one per line stays readable where a + single comma-separated line does not. Stored normalized to one line (see normalizeNoProxy), and the + parser accepts commas / whitespace / newlines alike, so a value pasted from NO_PROXY also works. */} +