From 1facdc88f9ce958e2e4b682864bad015e391ede8 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Tue, 8 Sep 2026 11:15:46 +0800 Subject: [PATCH] feat(proxy): let hosts bypass the proxy via a configurable no_proxy list The proxy is a single global egress, but part of what the app reaches commonly lives inside the network the proxy leads out of -- a self-hosted code platform, its git remote, an internal model server. Routing those through the proxy wastes a hop at best and makes them unreachable at worst, and turning the proxy off is not a fix, since the LLM egress still needs it. Only loopback was bypassed, and only as built-in behaviour, so there was no way to express any of this. Settings now take a bypass list, applying to every outbound path at once: the platform REST fetch, git over HTTPS, pr-agent and the local CLIs. The syntax mirrors the conventional NO_PROXY on purpose. The subprocess egresses are handed these rules as the environment variable and interpret them with their own libraries -- the app does not get to decide how git matches a host -- so any richer syntax would apply in-process and not in the subprocess, making the same config bypass on one egress while proxying on the other. Hence the portable subset (domain plus subdomains, IP literal, `*`, case-insensitive, port ignored) and the deliberate exclusion of CIDR ranges, which only some implementations honour. Matching lives in shared/no-proxy.ts, used by both paths so they cannot drift apart, and loopback is prepended to whatever the user configured: a local service must never be proxied, and that guarantee should not depend on the user having typed it. Normalization happens in the setProxy controller rather than the form, so the value stored is canonical however the config arrived, and what the user reads back is what is actually matched. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + CHANGELOG.zh-CN.md | 1 + apps/desktop/src/main/controllers/config.ts | 12 ++- apps/desktop/src/main/utils/proxy.ts | 44 +++++--- .../settings/editors/ProxyEditorModal.tsx | 18 ++++ .../settings/sections/ProxySection.tsx | 10 +- .../src/renderer/src/i18n/locales/de-DE.json | 6 ++ .../src/renderer/src/i18n/locales/en-US.json | 6 ++ .../src/renderer/src/i18n/locales/ja-JP.json | 5 + .../src/renderer/src/i18n/locales/zh-CN.json | 5 + docs/arch/99-core/03-networking-proxy.md | 19 ++-- docs/guide/03-proxy.md | 20 +++- docs/guide/04-config-reference.md | 4 +- docs/guide/zh-CN/03-proxy.md | 20 +++- docs/guide/zh-CN/04-config-reference.md | 4 +- packages/shared/src/config.ts | 7 +- packages/shared/src/index.ts | 1 + packages/shared/src/no-proxy.ts | 67 ++++++++++++ packages/shared/tests/no-proxy.test.ts | 101 ++++++++++++++++++ 19 files changed, 321 insertions(+), 30 deletions(-) create mode 100644 packages/shared/src/no-proxy.ts create mode 100644 packages/shared/tests/no-proxy.test.ts 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. */} +