Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

### ✨ 新增

- 代理设置新增**直连地址**列表:列出的地址跳过代理直接连接,内网代码平台、它的 git 远端或自建模型服务因此保持可达,其余流量照常走代理。沿用通行的 `NO_PROXY` 写法(填域名同时覆盖子域),并对所有出站路径一并生效——REST、git 与 LLM 调用。
- 因模型不可用而失败的评审现在会明确说明,并给出处理方式——使用本地 CLI 供应商(claude / codex)时,模型来自该 CLI 自身的配置,需要在那里更换。

### 🔧 修复
Expand Down
12 changes: 8 additions & 4 deletions apps/desktop/src/main/controllers/config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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)',
);
};
Expand Down
44 changes: 32 additions & 12 deletions apps/desktop/src/main/utils/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<protocol>://[user:pass@]host:port`. undefined when disabled / no host. */
Expand All @@ -32,15 +50,16 @@ export function proxyUrl(proxy: ProxyConfig): string | undefined {
export function buildProxyEnv(proxy: ProxyConfig): Record<string, string> {
const url = proxyUrl(proxy);
if (!url) return {};
const bypass = effectiveNoProxy(proxy);
return {
HTTP_PROXY: url,
http_proxy: url,
HTTPS_PROXY: url,
https_proxy: url,
ALL_PROXY: url,
all_proxy: url,
NO_PROXY,
no_proxy: NO_PROXY,
NO_PROXY: bypass,
no_proxy: bypass,
};
}

Expand Down Expand Up @@ -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<Response>) | undefined {
if (shouldBypass(host)) return undefined;
if (shouldBypass(proxy, host)) return undefined;
const dispatcher = buildProxyDispatcher(proxy);
if (!dispatcher) return undefined;
return (input, init) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,25 @@ export function ProxyEditorModal({
</button>
</div>
</div>
<div className="modal-kv-key">{t('settings.proxyNoProxy')}</div>
<div className="modal-kv-val">
{/* 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. */}
<textarea
className="settings-input"
rows={3}
value={draft.no_proxy}
onChange={(e) => patch({ no_proxy: e.target.value })}
placeholder={t('settings.proxyNoProxyPlaceholder')}
aria-label={t('settings.proxyNoProxyAria')}
spellCheck={false}
/>
</div>
</div>
<p className="muted" style={{ margin: '6px 0 0' }}>
{t('settings.proxyNoProxyHint')}
</p>
<div
className="settings-edit-row"
style={{ marginTop: 10, alignItems: 'center', gap: 10 }}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import type { Config } from '@meebox/shared';
import { parseNoProxy, type Config } from '@meebox/shared';

export function ProxySection({
proxy,
Expand All @@ -10,6 +10,7 @@ export function ProxySection({
}) {
const { t } = useTranslation();
const on = proxy.enabled && !!proxy.host;
const bypassCount = parseNoProxy(proxy.no_proxy).length;
return (
<section className="modal-section">
<div className="modal-section-head">
Expand All @@ -28,6 +29,13 @@ export function ProxySection({
<p className="muted" style={{ margin: 0 }}>
{t('settings.proxyStatusHint')}
</p>
{/* Surface that bypass rules are in force: when a host unexpectedly does (or does not) go through the proxy, the
rule list is the first thing to check, and it would otherwise be hidden inside the dialog. */}
{on && bypassCount > 0 && (
<p className="muted" style={{ margin: '4px 0 0' }}>
{t('settings.proxyNoProxySummary', { count: bypassCount })}
</p>
)}
</section>
);
}
6 changes: 6 additions & 0 deletions apps/desktop/src/renderer/src/i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,12 @@
"proxyHostPlaceholder": "z. B. 127.0.0.1",
"proxyHostRequired": "Bitte den Proxy-Host eingeben",
"proxyModalHint": "HTTP-Proxy (ausgehend). Wenn aktiviert, laufen LLM-Aufrufe, Code-Plattformen und git (HTTPS) alle über den Proxy; lokale Adressen verbinden sich direkt.",
"proxyNoProxy": "Direktverbindungen",
"proxyNoProxyAria": "Hosts, die den Proxy umgehen",
"proxyNoProxyHint": "Hier aufgeführte Hosts verbinden sich direkt statt über den Proxy — nützlich für eine interne Code-Plattform oder ein Modell, das der Proxy nicht erreicht. Einer pro Zeile oder durch Kommas getrennt; eine Domain umfasst auch ihre Subdomains. Lokale Adressen verbinden sich immer direkt.",
"proxyNoProxyPlaceholder": "corp.example.com\n10.0.0.5",
"proxyNoProxySummary_one": "{{count}} Host umgeht den Proxy",
"proxyNoProxySummary_other": "{{count}} Hosts umgehen den Proxy",
"proxyOk": "✓ Proxy ist erreichbar",
"proxyPassword": "Passwort",
"proxyPasswordAria": "Proxy-Passwort",
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/renderer/src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,12 @@
"proxyHostPlaceholder": "e.g. 127.0.0.1",
"proxyHostRequired": "Please enter the proxy host",
"proxyModalHint": "HTTP proxy (outbound). When enabled, LLM calls, code platforms, and git (HTTPS) all go through the proxy; local addresses connect directly.",
"proxyNoProxy": "Direct connections",
"proxyNoProxyAria": "Hosts that bypass the proxy",
"proxyNoProxyHint": "Hosts listed here connect directly instead of through the proxy — useful for an internal code platform or model that the proxy cannot reach. One per line or comma-separated; a domain also covers its subdomains. Local addresses always connect directly.",
"proxyNoProxyPlaceholder": "corp.example.com\n10.0.0.5",
"proxyNoProxySummary_one": "{{count}} host bypasses the proxy",
"proxyNoProxySummary_other": "{{count}} hosts bypass the proxy",
"proxyOk": "✓ Proxy is reachable",
"proxyPassword": "Password",
"proxyPasswordAria": "Proxy password",
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,11 @@
"proxyHostPlaceholder": "例:127.0.0.1",
"proxyHostRequired": "プロキシホストを入力してください",
"proxyModalHint": "HTTP プロキシ(送信)。有効にすると、LLM 呼び出し・コードプラットフォーム・git(HTTPS)はすべてプロキシ経由になります。ローカルアドレスは直接接続します。",
"proxyNoProxy": "直接接続するホスト",
"proxyNoProxyAria": "プロキシを経由しないホスト",
"proxyNoProxyHint": "ここに指定したホストはプロキシを経由せず直接接続します。プロキシから到達できない社内のコードプラットフォームやモデルに利用します。1 行に 1 つ、またはカンマ区切り。ドメインを指定するとそのサブドメインも対象になります。ローカルアドレスは常に直接接続します。",
"proxyNoProxyPlaceholder": "corp.example.com\n10.0.0.5",
"proxyNoProxySummary_other": "{{count}} 件のホストがプロキシを経由しません",
"proxyOk": "✓ プロキシに到達可能です",
"proxyPassword": "パスワード",
"proxyPasswordAria": "プロキシパスワード",
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,11 @@
"proxyHostPlaceholder": "如 127.0.0.1",
"proxyHostRequired": "请填代理地址",
"proxyModalHint": "HTTP 代理(出站)。开启后 LLM 调用、代码平台、git(HTTPS) 统一经代理,本地地址直连。",
"proxyNoProxy": "直连地址",
"proxyNoProxyAria": "跳过代理的地址",
"proxyNoProxyHint": "此处列出的地址将直连而不走代理,适用于代理无法访问的内网代码平台或模型服务。每行一条或以逗号分隔;填写域名同时覆盖其子域。本机地址始终直连。",
"proxyNoProxyPlaceholder": "corp.example.com\n10.0.0.5",
"proxyNoProxySummary_other": "{{count}} 个地址跳过代理",
"proxyOk": "✓ 代理可用",
"proxyPassword": "密码",
"proxyPasswordAria": "代理密码",
Expand Down
Loading
Loading