From 8b6a9b581f2d45e1963f188e4ca3e972e8380247 Mon Sep 17 00:00:00 2001 From: qiangyanjun Date: Fri, 5 Jun 2026 16:42:19 +0800 Subject: [PATCH 1/3] feat: add HTTP/HTTPS/SOCKS5 proxy support - Add src/common/proxy.ts: resolve proxy from env vars (HTTP_PROXY, HTTPS_PROXY, SOCKS_PROXY, NO_PROXY) and settings.json env field - Integrate undici ProxyAgent into OpenAI client (openai-client.ts) - Route telemetry and web-search requests through proxyFetch - Support DEEPCODE_HTTPS_PROXY prefixed env vars - NO_PROXY matching: exact host, subdomain wildcard, and * bypass - Priority: user settings < project settings < system env vars - Update configuration docs (zh + en) with proxy section --- docs/configuration.md | 73 +++++++++++++ docs/configuration_en.md | 73 +++++++++++++ src/common/openai-client.ts | 6 +- src/common/proxy.ts | 186 ++++++++++++++++++++++++++++++++ src/common/telemetry.ts | 4 +- src/tools/web-search-handler.ts | 3 +- 6 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 src/common/proxy.ts diff --git a/docs/configuration.md b/docs/configuration.md index 752f7ab7..7adab37e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,6 +49,10 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 | `REASONING_EFFORT` | string | 推理强度 | | `DEBUG_LOG_ENABLED` | string | 是否启用调试日志输出 | | `TELEMETRY_ENABLED` | string | 是否启用匿名使用数据上报 | +| `HTTP_PROXY` | string | HTTP 代理地址,例如 `"http://127.0.0.1:7890"` | +| `HTTPS_PROXY` | string | HTTPS 代理地址,例如 `"http://127.0.0.1:7890"` | +| `SOCKS_PROXY` | string | SOCKS5 代理地址,例如 `"socks5://127.0.0.1:1080"` | +| `NO_PROXY` | string | 不走代理的地址列表,逗号分隔,例如 `"localhost,127.0.0.1,.example.com"` | | `<其他任意KEY>` | string | 自定义环境变量 | #### `thinkingEnabled` — 思考模式 @@ -197,3 +201,72 @@ DEEPCODE_TELEMETRY_ENABLED=0 deepcode 3. 项目级settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` 4. 项目级settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` 5. 系统环境变量: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` + +## 代理配置 + +Deep Code 支持通过 HTTP/HTTPS/SOCKS5 代理发送所有网络请求,包括 API 调用、遥测上报和联网搜索。 + +### 支持的代理变量 + +| 变量名 | 说明 | 示例 | +| -------------- | ----------------------------------------------------------- | --------------------------------------- | +| `HTTPS_PROXY` | HTTPS 代理(优先级最高) | `http://127.0.0.1:7890` | +| `HTTP_PROXY` | HTTP 代理 | `http://127.0.0.1:7890` | +| `SOCKS_PROXY` | SOCKS5 代理 | `socks5://127.0.0.1:1080` | +| `SOCKS5_PROXY` | SOCKS5 代理(`SOCKS_PROXY` 的别名) | `socks5://127.0.0.1:1080` | +| `NO_PROXY` | 不走代理的地址列表(逗号分隔,支持 `*`、`.example.com`) | `localhost,127.0.0.1,.internal.corp` | + +### 配置方式 + +#### 一、通过 Shell 环境变量设置(优先级最高) + +**Bash / Zsh:** + +```bash +export HTTPS_PROXY=http://127.0.0.1:7890 +export NO_PROXY=localhost,127.0.0.1 +deepcode +``` + +**PowerShell:** + +```powershell +$env:HTTPS_PROXY = "http://127.0.0.1:7890" +$env:NO_PROXY = "localhost,127.0.0.1" +deepcode +``` + +也可以使用 `DEEPCODE_` 前缀: + +```bash +DEEPCODE_HTTPS_PROXY=http://127.0.0.1:7890 deepcode +``` + +#### 二、通过 `settings.json` 的 `env` 字段配置 + +```json +{ + "env": { + "HTTPS_PROXY": "http://127.0.0.1:7890", + "NO_PROXY": "localhost,127.0.0.1" + } +} +``` + +### 优先级 + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖): + +1. 用户级 `settings.json`:`{"env": {"HTTPS_PROXY": "..."}}` +2. 项目级 `settings.json`:`{"env": {"HTTPS_PROXY": "..."}}` +3. 系统环境变量:`HTTPS_PROXY=... deepcode` 或 `DEEPCODE_HTTPS_PROXY=... deepcode` + +### `NO_PROXY` 匹配规则 + +| 模式 | 说明 | +| --------------- | ---------------------------------------------- | +| `*` | 所有地址均不走代理 | +| `localhost` | 精确匹配 `localhost` | +| `127.0.0.1` | 精确匹配 `127.0.0.1` | +| `.example.com` | 匹配 `example.com` 及其所有子域名(如 `api.example.com`) | +| `example.com` | 匹配 `example.com` 及其所有子域名 | diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 8634992c..7bad58de 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -49,6 +49,10 @@ The following are all the top-level fields supported in `settings.json`, along w | `REASONING_EFFORT`| string | Reasoning intensity | | `DEBUG_LOG_ENABLED`| string| Enable debug log output | | `TELEMETRY_ENABLED`| string| Enable anonymous usage reporting | +| `HTTP_PROXY` | string| HTTP proxy URL, e.g. `"http://127.0.0.1:7890"` | +| `HTTPS_PROXY` | string| HTTPS proxy URL, e.g. `"http://127.0.0.1:7890"` | +| `SOCKS_PROXY` | string| SOCKS5 proxy URL, e.g. `"socks5://127.0.0.1:1080"` | +| `NO_PROXY` | string| Comma-separated list of hosts to bypass proxy, e.g. `"localhost,127.0.0.1,.example.com"` | | `` | string | Custom environment variable | #### `thinkingEnabled` — Thinking Mode @@ -196,3 +200,72 @@ Applied in the following priority order (lower-numbered overridden by higher-num 3. Project-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` 4. Project-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` 5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` + +## Proxy Configuration + +Deep Code supports routing all network traffic (API calls, telemetry reporting, and web search) through HTTP, HTTPS, or SOCKS5 proxies. + +### Supported Proxy Variables + +| Variable | Description | Example | +| -------------- | --------------------------------------------------------------- | ---------------------------------------- | +| `HTTPS_PROXY` | HTTPS proxy (highest priority) | `http://127.0.0.1:7890` | +| `HTTP_PROXY` | HTTP proxy | `http://127.0.0.1:7890` | +| `SOCKS_PROXY` | SOCKS5 proxy | `socks5://127.0.0.1:1080` | +| `SOCKS5_PROXY` | SOCKS5 proxy (alias for `SOCKS_PROXY`) | `socks5://127.0.0.1:1080` | +| `NO_PROXY` | Comma-separated list of hosts to bypass proxy (supports `*`, `.example.com`) | `localhost,127.0.0.1,.internal.corp` | + +### Configuration Methods + +#### 1. Shell Environment Variables (highest priority) + +**Bash / Zsh:** + +```bash +export HTTPS_PROXY=http://127.0.0.1:7890 +export NO_PROXY=localhost,127.0.0.1 +deepcode +``` + +**PowerShell:** + +```powershell +$env:HTTPS_PROXY = "http://127.0.0.1:7890" +$env:NO_PROXY = "localhost,127.0.0.1" +deepcode +``` + +You can also use the `DEEPCODE_` prefix: + +```bash +DEEPCODE_HTTPS_PROXY=http://127.0.0.1:7890 deepcode +``` + +#### 2. `settings.json` `env` field + +```json +{ + "env": { + "HTTPS_PROXY": "http://127.0.0.1:7890", + "NO_PROXY": "localhost,127.0.0.1" + } +} +``` + +### Priority + +Applied in the following priority order (lower-numbered overridden by higher-numbered): + +1. User-level `settings.json`: `{"env": {"HTTPS_PROXY": "..."}}` +2. Project-level `settings.json`: `{"env": {"HTTPS_PROXY": "..."}}` +3. System environment variable: `HTTPS_PROXY=... deepcode` or `DEEPCODE_HTTPS_PROXY=... deepcode` + +### `NO_PROXY` Matching Rules + +| Pattern | Description | +| --------------- | --------------------------------------------------------------- | +| `*` | Bypass proxy for all hosts | +| `localhost` | Exact match for `localhost` | +| `127.0.0.1` | Exact match for `127.0.0.1` | +| `.example.com` | Matches `example.com` and all its subdomains (e.g. `api.example.com`) | +| `example.com` | Matches `example.com` and all its subdomains | diff --git a/src/common/openai-client.ts b/src/common/openai-client.ts index d3b56c08..1bbb42cf 100644 --- a/src/common/openai-client.ts +++ b/src/common/openai-client.ts @@ -4,6 +4,7 @@ import * as path from "path"; import OpenAI from "openai"; import { Agent, fetch as undiciFetch } from "undici"; import { resolveCurrentSettings } from "../settings"; +import { getProxyDispatcher } from "./proxy"; // Custom undici Agent with a 180-second keepAlive timeout. The default // global fetch (undici) only keeps connections alive for 4 seconds, which @@ -51,7 +52,8 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): { }; } - const cacheKey = `${settings.apiKey}::${settings.baseURL}`; + const proxyDispatcher = getProxyDispatcher(settings.baseURL); + const cacheKey = `${settings.apiKey}::${settings.baseURL}::${proxyDispatcher ? "proxy" : "direct"}`; if (cachedOpenAI && cachedOpenAIKey === cacheKey) { return { client: cachedOpenAI, @@ -73,7 +75,7 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): { apiKey: settings.apiKey, baseURL: settings.baseURL || undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any - fetch: (url: any, init: any) => undiciFetch(url, { ...init, dispatcher: keepAliveAgent }), + fetch: (url: any, init: any) => undiciFetch(url, { ...init, dispatcher: proxyDispatcher ?? keepAliveAgent }), }); cachedOpenAIKey = cacheKey; diff --git a/src/common/proxy.ts b/src/common/proxy.ts new file mode 100644 index 00000000..88bec4ca --- /dev/null +++ b/src/common/proxy.ts @@ -0,0 +1,186 @@ +import { ProxyAgent } from "undici"; +import { readSettings, readProjectSettings } from "../settings"; +import type { SettingsProcessEnv } from "../settings"; + +export type ProxyType = "http" | "https" | "socks5"; + +export type ResolvedProxy = { + url: string; + type: ProxyType; +}; + +/** + * Determine whether a target URL should bypass the proxy based on NO_PROXY. + * + * Supports: + * - `*` wildcard (bypass everything) + * - Exact hostname match (e.g. `localhost`, `example.com`) + * - Sub-domain wildcard (e.g. `.example.com` matches `api.example.com`) + */ +function shouldBypassProxy(targetUrl: string, noProxy: string): boolean { + if (!noProxy.trim()) { + return false; + } + + const entries = noProxy + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + + if (entries.includes("*")) { + return true; + } + + let hostname: string; + try { + hostname = new URL(targetUrl).hostname.toLowerCase(); + } catch { + return false; + } + + for (const entry of entries) { + if (entry.startsWith(".")) { + // ".example.com" matches "api.example.com" and "example.com" + if (hostname.endsWith(entry) || hostname === entry.slice(1)) { + return true; + } + } else if (hostname === entry || hostname.endsWith(`.${entry}`)) { + return true; + } + } + + return false; +} + +/** + * Pick the first non-empty proxy variable from `source`, preferring + * HTTPS_PROXY → HTTP_PROXY → SOCKS_PROXY → SOCKS5_PROXY (case-insensitive + * lowercase fallback for each). + */ +function pickProxyVar(source: Record): { url: string; type: ProxyType } | undefined { + const candidates: Array<{ keys: string[]; type: ProxyType }> = [ + { keys: ["HTTPS_PROXY", "https_proxy"], type: "https" }, + { keys: ["HTTP_PROXY", "http_proxy"], type: "http" }, + { keys: ["SOCKS_PROXY", "socks_proxy", "SOCKS5_PROXY", "socks5_proxy"], type: "socks5" }, + ]; + + for (const { keys, type } of candidates) { + for (const key of keys) { + const value = source[key]; + if (typeof value === "string" && value.trim()) { + return { url: value.trim(), type }; + } + } + } + + return undefined; +} + +/** + * Pick NO_PROXY from a source (case-insensitive fallback). + */ +function pickNoProxy(source: Record): string { + return ( + (typeof source.NO_PROXY === "string" && source.NO_PROXY) || + (typeof source.no_proxy === "string" && source.no_proxy) || + "" + ); +} + +/** + * Resolve the effective proxy URL by consulting (in ascending priority): + * 1. User-level `settings.json` → `env` + * 2. Project-level `settings.json` → `env` + * 3. Process environment variables (both standard `HTTP_PROXY` / `HTTPS_PROXY` + * and `DEEPCODE_`-prefixed variants) + * + * Returns `undefined` when no proxy is configured or when NO_PROXY matches. + */ +export function resolveProxyUrl( + targetUrl: string, + projectRoot: string = process.cwd(), + processEnv: SettingsProcessEnv = process.env +): ResolvedProxy | undefined { + // --- Collect proxy vars from each layer --- + const userEnv = readSettings()?.env ?? {}; + const projectEnv = readProjectSettings(projectRoot)?.env ?? {}; + + // System env includes both standard proxy vars and DEEPCODE_-prefixed ones + // (collectDeepcodeEnv strips the prefix, so DEEPCODE_HTTPS_PROXY → HTTPS_PROXY). + const systemProxySource: Record = { ...processEnv }; + for (const [key, value] of Object.entries(processEnv)) { + if (key.startsWith("DEEPCODE_") && typeof value === "string") { + const stripped = key.slice("DEEPCODE_".length); + if (stripped) { + systemProxySource[stripped] = value; + } + } + } + + // --- NO_PROXY check (system level takes absolute precedence) --- + const systemNoProxy = pickNoProxy(systemProxySource); + if (shouldBypassProxy(targetUrl, systemNoProxy)) { + return undefined; + } + + // --- Merge: user < project < system --- + const merged: Record = { + ...userEnv, + ...projectEnv, + ...systemProxySource, + }; + + // NO_PROXY from merged (user/project may also define it, but system wins) + const mergedNoProxy = pickNoProxy(merged); + if (shouldBypassProxy(targetUrl, mergedNoProxy)) { + return undefined; + } + + return pickProxyVar(merged); +} + +// --------------------------------------------------------------------------- +// Dispatcher cache – avoids re-creating ProxyAgent on every request. +// --------------------------------------------------------------------------- +let cachedProxyUrl = ""; +let cachedDispatcher: ProxyAgent | null = null; + +/** + * Return a `ProxyAgent` dispatcher when a proxy is configured for the given + * target URL, or `null` when requests should go direct. + */ +export function getProxyDispatcher(targetUrl?: string): ProxyAgent | null { + const resolved = resolveProxyUrl(targetUrl ?? "https://api.deepseek.com"); + const proxyUrl = resolved?.url ?? ""; + + if (!proxyUrl) { + cachedProxyUrl = ""; + cachedDispatcher = null; + return null; + } + + if (cachedDispatcher && cachedProxyUrl === proxyUrl) { + return cachedDispatcher; + } + + cachedProxyUrl = proxyUrl; + cachedDispatcher = new ProxyAgent({ + uri: proxyUrl, + keepAliveTimeout: 180_000, + }); + return cachedDispatcher; +} + +/** + * Fetch wrapper that automatically routes through the configured proxy. + * Use this in place of the global `fetch` for any HTTP request that should + * respect the proxy configuration. + */ +export async function proxyFetch(url: string | URL, init?: RequestInit): Promise { + const dispatcher = getProxyDispatcher(String(url)); + if (!dispatcher) { + return fetch(url, init); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (fetch as any)(url, { ...init, dispatcher }); +} diff --git a/src/common/telemetry.ts b/src/common/telemetry.ts index f6dc60b3..25fbd106 100644 --- a/src/common/telemetry.ts +++ b/src/common/telemetry.ts @@ -1,3 +1,5 @@ +import { proxyFetch } from "./proxy"; + const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; const DEFAULT_REPORT_TIMEOUT_MS = 3000; @@ -20,7 +22,7 @@ export function reportNewPrompt(options: NewPromptReportOptions): void { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); - void fetch(DEFAULT_NEW_PROMPT_API_URL, { + void proxyFetch(DEFAULT_NEW_PROMPT_API_URL, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/tools/web-search-handler.ts b/src/tools/web-search-handler.ts index b3dde69c..02586400 100644 --- a/src/tools/web-search-handler.ts +++ b/src/tools/web-search-handler.ts @@ -2,6 +2,7 @@ import { randomUUID } from "crypto"; import { spawn } from "child_process"; import type OpenAI from "openai"; import type { CreateOpenAIClient, ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { proxyFetch } from "../common/proxy"; const MAX_OUTPUT_CHARS = 30000; const MAX_CAPTURE_CHARS = 10 * 1024 * 1024; @@ -331,7 +332,7 @@ async function runDefaultWebSearchRequest( const activityId = `web-search-${randomUUID()}`; context.onProcessStart?.(activityId, formatWebSearchActivityLabel(query)); try { - const response = await fetch(DEFAULT_WEB_SEARCH_API_URL, { + const response = await proxyFetch(DEFAULT_WEB_SEARCH_API_URL, { method: "POST", headers: { "Content-Type": "application/json", From a3ecbbbede01f0d80bd8b3f3e2396338f293f268 Mon Sep 17 00:00:00 2001 From: qiangyanjun Date: Fri, 5 Jun 2026 16:55:49 +0800 Subject: [PATCH 2/3] docs: convert proxy.ts comments to bilingual (Chinese + English) --- src/common/proxy.ts | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/common/proxy.ts b/src/common/proxy.ts index 88bec4ca..510c4d4d 100644 --- a/src/common/proxy.ts +++ b/src/common/proxy.ts @@ -11,11 +11,12 @@ export type ResolvedProxy = { /** * Determine whether a target URL should bypass the proxy based on NO_PROXY. + * 根据 NO_PROXY 判断目标 URL 是否应绕过代理。 * - * Supports: - * - `*` wildcard (bypass everything) - * - Exact hostname match (e.g. `localhost`, `example.com`) - * - Sub-domain wildcard (e.g. `.example.com` matches `api.example.com`) + * Supports / 支持: + * - `*` wildcard (bypass everything) / 通配符(绕过所有地址) + * - Exact hostname match (e.g. `localhost`, `example.com`) / 精确主机名匹配 + * - Sub-domain wildcard (e.g. `.example.com` matches `api.example.com`) / 子域名通配 */ function shouldBypassProxy(targetUrl: string, noProxy: string): boolean { if (!noProxy.trim()) { @@ -41,6 +42,7 @@ function shouldBypassProxy(targetUrl: string, noProxy: string): boolean { for (const entry of entries) { if (entry.startsWith(".")) { // ".example.com" matches "api.example.com" and "example.com" + // ".example.com" 同时匹配 "api.example.com" 和 "example.com" if (hostname.endsWith(entry) || hostname === entry.slice(1)) { return true; } @@ -56,6 +58,9 @@ function shouldBypassProxy(targetUrl: string, noProxy: string): boolean { * Pick the first non-empty proxy variable from `source`, preferring * HTTPS_PROXY → HTTP_PROXY → SOCKS_PROXY → SOCKS5_PROXY (case-insensitive * lowercase fallback for each). + * + * 从 `source` 中取第一个非空的代理变量,优先级: + * HTTPS_PROXY → HTTP_PROXY → SOCKS_PROXY → SOCKS5_PROXY(大小写均兼容)。 */ function pickProxyVar(source: Record): { url: string; type: ProxyType } | undefined { const candidates: Array<{ keys: string[]; type: ProxyType }> = [ @@ -78,6 +83,7 @@ function pickProxyVar(source: Record): { url: string /** * Pick NO_PROXY from a source (case-insensitive fallback). + * 从 source 中读取 NO_PROXY(兼容小写 no_proxy)。 */ function pickNoProxy(source: Record): string { return ( @@ -94,7 +100,13 @@ function pickNoProxy(source: Record): string { * 3. Process environment variables (both standard `HTTP_PROXY` / `HTTPS_PROXY` * and `DEEPCODE_`-prefixed variants) * + * 按以下优先级解析有效代理地址(由低到高): + * 1. 用户级 `settings.json` → `env` + * 2. 项目级 `settings.json` → `env` + * 3. 进程环境变量(标准 `HTTP_PROXY` / `HTTPS_PROXY` 及 `DEEPCODE_` 前缀变体) + * * Returns `undefined` when no proxy is configured or when NO_PROXY matches. + * 未配置代理或命中 NO_PROXY 时返回 `undefined`。 */ export function resolveProxyUrl( targetUrl: string, @@ -102,11 +114,14 @@ export function resolveProxyUrl( processEnv: SettingsProcessEnv = process.env ): ResolvedProxy | undefined { // --- Collect proxy vars from each layer --- + // --- 从各配置层收集代理变量 --- const userEnv = readSettings()?.env ?? {}; const projectEnv = readProjectSettings(projectRoot)?.env ?? {}; // System env includes both standard proxy vars and DEEPCODE_-prefixed ones // (collectDeepcodeEnv strips the prefix, so DEEPCODE_HTTPS_PROXY → HTTPS_PROXY). + // 系统环境变量同时包含标准代理变量和 DEEPCODE_ 前缀变量 + // (collectDeepcodeEnv 会去除前缀,因此 DEEPCODE_HTTPS_PROXY → HTTPS_PROXY)。 const systemProxySource: Record = { ...processEnv }; for (const [key, value] of Object.entries(processEnv)) { if (key.startsWith("DEEPCODE_") && typeof value === "string") { @@ -118,12 +133,14 @@ export function resolveProxyUrl( } // --- NO_PROXY check (system level takes absolute precedence) --- + // --- NO_PROXY 检查(系统级拥有绝对优先权)--- const systemNoProxy = pickNoProxy(systemProxySource); if (shouldBypassProxy(targetUrl, systemNoProxy)) { return undefined; } // --- Merge: user < project < system --- + // --- 合并优先级:用户 < 项目 < 系统 --- const merged: Record = { ...userEnv, ...projectEnv, @@ -131,6 +148,7 @@ export function resolveProxyUrl( }; // NO_PROXY from merged (user/project may also define it, but system wins) + // 合并后的 NO_PROXY(用户/项目也可定义,但系统级优先) const mergedNoProxy = pickNoProxy(merged); if (shouldBypassProxy(targetUrl, mergedNoProxy)) { return undefined; @@ -141,6 +159,7 @@ export function resolveProxyUrl( // --------------------------------------------------------------------------- // Dispatcher cache – avoids re-creating ProxyAgent on every request. +// Dispatcher 缓存 —— 避免每次请求都重建 ProxyAgent。 // --------------------------------------------------------------------------- let cachedProxyUrl = ""; let cachedDispatcher: ProxyAgent | null = null; @@ -148,6 +167,8 @@ let cachedDispatcher: ProxyAgent | null = null; /** * Return a `ProxyAgent` dispatcher when a proxy is configured for the given * target URL, or `null` when requests should go direct. + * + * 当目标 URL 命中代理配置时返回 `ProxyAgent` dispatcher,否则返回 `null` 直连。 */ export function getProxyDispatcher(targetUrl?: string): ProxyAgent | null { const resolved = resolveProxyUrl(targetUrl ?? "https://api.deepseek.com"); @@ -175,6 +196,9 @@ export function getProxyDispatcher(targetUrl?: string): ProxyAgent | null { * Fetch wrapper that automatically routes through the configured proxy. * Use this in place of the global `fetch` for any HTTP request that should * respect the proxy configuration. + * + * 自动走代理的 fetch 封装。 + * 任何需要遵循代理配置的 HTTP 请求均应使用此函数替代全局 `fetch`。 */ export async function proxyFetch(url: string | URL, init?: RequestInit): Promise { const dispatcher = getProxyDispatcher(String(url)); From c170d7802b3aec4b026763fff0de09dccb17b3a5 Mon Sep 17 00:00:00 2001 From: qiangyanjun Date: Fri, 5 Jun 2026 17:05:06 +0800 Subject: [PATCH 3/3] test: add unit tests for proxy resolution module - 28 test cases covering resolveProxyUrl, getProxyDispatcher, proxyFetch - System env: HTTPS_PROXY, HTTP_PROXY, SOCKS_PROXY, SOCKS5_PROXY - DEEPCODE_ prefixed vars, lowercase fallback - settings.json: user-level and project-level env config - Priority: user < project < system - NO_PROXY: exact match, wildcard *, subdomain (.example.com) - Dispatcher caching verification - proxyFetch fallback to global fetch and dispatcher injection --- src/tests/proxy.test.ts | 467 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 src/tests/proxy.test.ts diff --git a/src/tests/proxy.test.ts b/src/tests/proxy.test.ts new file mode 100644 index 00000000..598b0f8d --- /dev/null +++ b/src/tests/proxy.test.ts @@ -0,0 +1,467 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resolveProxyUrl, getProxyDispatcher, proxyFetch } from "../common/proxy"; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const originalFetch = globalThis.fetch; +const tempDirs: string[] = []; + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function setHomeDir(dir: string): void { + process.env.HOME = dir; + if (process.platform === "win32") { + process.env.USERPROFILE = dir; + } +} + +/** + * Write a settings.json under /.deepcode/settings.json. + * 在临时 home 目录下创建用户级 settings.json。 + */ +function writeUserSettings(home: string, env: Record): void { + const dir = path.join(home, ".deepcode"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "settings.json"), JSON.stringify({ env }), "utf8"); +} + +/** + * Write a settings.json under /.deepcode/settings.json. + * 在项目根目录下创建项目级 settings.json。 + */ +function writeProjectSettings(projectRoot: string, env: Record): void { + const dir = path.join(projectRoot, ".deepcode"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "settings.json"), JSON.stringify({ env }), "utf8"); +} + +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +// --------------------------------------------------------------------------- +// resolveProxyUrl – no proxy configured +// --------------------------------------------------------------------------- + +test("resolveProxyUrl returns undefined when no proxy is configured", () => { + const home = createTempDir("deepcode-proxy-home-"); + const projectRoot = createTempDir("deepcode-proxy-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, {}); + assert.equal(result, undefined); +}); + +// --------------------------------------------------------------------------- +// resolveProxyUrl – system env vars +// --------------------------------------------------------------------------- + +test("resolveProxyUrl picks HTTPS_PROXY from system env", () => { + const home = createTempDir("deepcode-proxy-https-home-"); + const projectRoot = createTempDir("deepcode-proxy-https-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + HTTPS_PROXY: "http://127.0.0.1:7890", + }); + assert.ok(result); + assert.equal(result.url, "http://127.0.0.1:7890"); + assert.equal(result.type, "https"); +}); + +test("resolveProxyUrl picks HTTP_PROXY when HTTPS_PROXY is absent", () => { + const home = createTempDir("deepcode-proxy-http-home-"); + const projectRoot = createTempDir("deepcode-proxy-http-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + HTTP_PROXY: "http://127.0.0.1:8080", + }); + assert.ok(result); + assert.equal(result.url, "http://127.0.0.1:8080"); + assert.equal(result.type, "http"); +}); + +test("resolveProxyUrl prefers HTTPS_PROXY over HTTP_PROXY", () => { + const home = createTempDir("deepcode-proxy-pref-home-"); + const projectRoot = createTempDir("deepcode-proxy-pref-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + HTTPS_PROXY: "http://proxy-https:7890", + HTTP_PROXY: "http://proxy-http:8080", + }); + assert.ok(result); + assert.equal(result.url, "http://proxy-https:7890"); + assert.equal(result.type, "https"); +}); + +test("resolveProxyUrl picks SOCKS_PROXY when no HTTP/HTTPS proxy is set", () => { + const home = createTempDir("deepcode-proxy-socks-home-"); + const projectRoot = createTempDir("deepcode-proxy-socks-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + SOCKS_PROXY: "socks5://127.0.0.1:1080", + }); + assert.ok(result); + assert.equal(result.url, "socks5://127.0.0.1:1080"); + assert.equal(result.type, "socks5"); +}); + +test("resolveProxyUrl picks SOCKS5_PROXY as alias for SOCKS_PROXY", () => { + const home = createTempDir("deepcode-proxy-socks5-home-"); + const projectRoot = createTempDir("deepcode-proxy-socks5-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + SOCKS5_PROXY: "socks5://127.0.0.1:1081", + }); + assert.ok(result); + assert.equal(result.url, "socks5://127.0.0.1:1081"); + assert.equal(result.type, "socks5"); +}); + +test("resolveProxyUrl supports lowercase proxy env vars", () => { + const home = createTempDir("deepcode-proxy-lower-home-"); + const projectRoot = createTempDir("deepcode-proxy-lower-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + https_proxy: "http://lower-proxy:7890", + }); + assert.ok(result); + assert.equal(result.url, "http://lower-proxy:7890"); + assert.equal(result.type, "https"); +}); + +test("resolveProxyUrl supports DEEPCODE_ prefixed env vars", () => { + const home = createTempDir("deepcode-proxy-prefix-home-"); + const projectRoot = createTempDir("deepcode-proxy-prefix-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + DEEPCODE_HTTPS_PROXY: "http://deepproxy:7890", + }); + assert.ok(result); + assert.equal(result.url, "http://deepproxy:7890"); + assert.equal(result.type, "https"); +}); + +// --------------------------------------------------------------------------- +// resolveProxyUrl – settings.json +// --------------------------------------------------------------------------- + +test("resolveProxyUrl reads proxy from user-level settings.json env", () => { + const home = createTempDir("deepcode-proxy-user-settings-home-"); + const projectRoot = createTempDir("deepcode-proxy-user-settings-project-"); + setHomeDir(home); + writeUserSettings(home, { HTTPS_PROXY: "http://user-proxy:7890" }); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, {}); + assert.ok(result); + assert.equal(result.url, "http://user-proxy:7890"); + assert.equal(result.type, "https"); +}); + +test("resolveProxyUrl reads proxy from project-level settings.json env", () => { + const home = createTempDir("deepcode-proxy-project-settings-home-"); + const projectRoot = createTempDir("deepcode-proxy-project-settings-project-"); + setHomeDir(home); + writeProjectSettings(projectRoot, { HTTPS_PROXY: "http://project-proxy:7890" }); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, {}); + assert.ok(result); + assert.equal(result.url, "http://project-proxy:7890"); + assert.equal(result.type, "https"); +}); + +test("resolveProxyUrl: system env overrides user settings.json", () => { + const home = createTempDir("deepcode-proxy-override-home-"); + const projectRoot = createTempDir("deepcode-proxy-override-project-"); + setHomeDir(home); + writeUserSettings(home, { HTTPS_PROXY: "http://user-proxy:7890" }); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + HTTPS_PROXY: "http://system-proxy:9999", + }); + assert.ok(result); + assert.equal(result.url, "http://system-proxy:9999"); +}); + +test("resolveProxyUrl: project settings override user settings", () => { + const home = createTempDir("deepcode-proxy-proj-override-home-"); + const projectRoot = createTempDir("deepcode-proxy-proj-override-project-"); + setHomeDir(home); + writeUserSettings(home, { HTTPS_PROXY: "http://user-proxy:7890" }); + writeProjectSettings(projectRoot, { HTTPS_PROXY: "http://project-proxy:8888" }); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, {}); + assert.ok(result); + assert.equal(result.url, "http://project-proxy:8888"); +}); + +// --------------------------------------------------------------------------- +// resolveProxyUrl – NO_PROXY +// --------------------------------------------------------------------------- + +test("resolveProxyUrl returns undefined when NO_PROXY matches exact hostname", () => { + const home = createTempDir("deepcode-proxy-noproxy-home-"); + const projectRoot = createTempDir("deepcode-proxy-noproxy-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + HTTPS_PROXY: "http://proxy:7890", + NO_PROXY: "api.deepseek.com", + }); + assert.equal(result, undefined); +}); + +test("resolveProxyUrl bypasses proxy when NO_PROXY contains *", () => { + const home = createTempDir("deepcode-proxy-noproxy-star-home-"); + const projectRoot = createTempDir("deepcode-proxy-noproxy-star-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + HTTPS_PROXY: "http://proxy:7890", + NO_PROXY: "*", + }); + assert.equal(result, undefined); +}); + +test("resolveProxyUrl supports subdomain wildcard in NO_PROXY (.example.com)", () => { + const home = createTempDir("deepcode-proxy-noproxy-sub-home-"); + const projectRoot = createTempDir("deepcode-proxy-noproxy-sub-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + HTTPS_PROXY: "http://proxy:7890", + NO_PROXY: ".deepseek.com", + }); + assert.equal(result, undefined); +}); + +test("resolveProxyUrl: NO_PROXY entry also matches the base domain", () => { + const home = createTempDir("deepcode-proxy-noproxy-base-home-"); + const projectRoot = createTempDir("deepcode-proxy-noproxy-base-project-"); + setHomeDir(home); + + // ".deepseek.com" should also match "deepseek.com" itself + const result = resolveProxyUrl("https://deepseek.com", projectRoot, { + HTTPS_PROXY: "http://proxy:7890", + NO_PROXY: ".deepseek.com", + }); + assert.equal(result, undefined); +}); + +test("resolveProxyUrl does not bypass when NO_PROXY doesn't match", () => { + const home = createTempDir("deepcode-proxy-noproxy-nomatch-home-"); + const projectRoot = createTempDir("deepcode-proxy-noproxy-nomatch-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + HTTPS_PROXY: "http://proxy:7890", + NO_PROXY: "localhost,127.0.0.1", + }); + assert.ok(result); + assert.equal(result.url, "http://proxy:7890"); +}); + +test("resolveProxyUrl ignores empty NO_PROXY", () => { + const home = createTempDir("deepcode-proxy-noproxy-empty-home-"); + const projectRoot = createTempDir("deepcode-proxy-noproxy-empty-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + HTTPS_PROXY: "http://proxy:7890", + NO_PROXY: " ", + }); + assert.ok(result); + assert.equal(result.url, "http://proxy:7890"); +}); + +test("resolveProxyUrl supports lowercase no_proxy", () => { + const home = createTempDir("deepcode-proxy-noproxy-lower-home-"); + const projectRoot = createTempDir("deepcode-proxy-noproxy-lower-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + HTTPS_PROXY: "http://proxy:7890", + no_proxy: "api.deepseek.com", + }); + assert.equal(result, undefined); +}); + +test("resolveProxyUrl: NO_PROXY from settings.json also works", () => { + const home = createTempDir("deepcode-proxy-noproxy-settings-home-"); + const projectRoot = createTempDir("deepcode-proxy-noproxy-settings-project-"); + setHomeDir(home); + writeUserSettings(home, { + HTTPS_PROXY: "http://proxy:7890", + NO_PROXY: "api.deepseek.com", + }); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, {}); + assert.equal(result, undefined); +}); + +// --------------------------------------------------------------------------- +// resolveProxyUrl – empty / whitespace values +// --------------------------------------------------------------------------- + +test("resolveProxyUrl trims whitespace from proxy URL", () => { + const home = createTempDir("deepcode-proxy-trim-home-"); + const projectRoot = createTempDir("deepcode-proxy-trim-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + HTTPS_PROXY: " http://proxy:7890 ", + }); + assert.ok(result); + assert.equal(result.url, "http://proxy:7890"); +}); + +test("resolveProxyUrl ignores empty-string proxy env vars", () => { + const home = createTempDir("deepcode-proxy-emptystr-home-"); + const projectRoot = createTempDir("deepcode-proxy-emptystr-project-"); + setHomeDir(home); + + const result = resolveProxyUrl("https://api.deepseek.com", projectRoot, { + HTTPS_PROXY: "", + HTTP_PROXY: " ", + }); + assert.equal(result, undefined); +}); + +// --------------------------------------------------------------------------- +// getProxyDispatcher – caching +// --------------------------------------------------------------------------- + +test("getProxyDispatcher returns null when no proxy is configured", () => { + const home = createTempDir("deepcode-proxy-dispatcher-null-home-"); + const projectRoot = createTempDir("deepcode-proxy-dispatcher-null-project-"); + setHomeDir(home); + + const dispatcher = getProxyDispatcher("https://api.deepseek.com"); + assert.equal(dispatcher, null); +}); + +test("getProxyDispatcher returns a ProxyAgent when proxy is configured", () => { + const home = createTempDir("deepcode-proxy-dispatcher-home-"); + const projectRoot = createTempDir("deepcode-proxy-dispatcher-project-"); + setHomeDir(home); + writeUserSettings(home, { HTTPS_PROXY: "http://proxy:7890" }); + + // Use a URL not in NO_PROXY to ensure we get a dispatcher. + // Note: getProxyDispatcher uses the default targetUrl for resolveProxyUrl, + // but since we configured proxy in user settings, it will be picked up. + const dispatcher = getProxyDispatcher("https://api.deepseek.com"); + assert.ok(dispatcher, "Expected a ProxyAgent, got null"); +}); + +test("getProxyDispatcher caches the same dispatcher for the same proxy URL", () => { + const home = createTempDir("deepcode-proxy-dispatcher-cache-home-"); + const projectRoot = createTempDir("deepcode-proxy-dispatcher-cache-project-"); + setHomeDir(home); + writeUserSettings(home, { HTTPS_PROXY: "http://proxy:7890" }); + + const d1 = getProxyDispatcher("https://api.deepseek.com"); + const d2 = getProxyDispatcher("https://api.deepseek.com"); + assert.ok(d1); + assert.equal(d1, d2, "Expected cached dispatcher to be returned"); +}); + +// --------------------------------------------------------------------------- +// proxyFetch – falls back to global fetch when no proxy +// --------------------------------------------------------------------------- + +test("proxyFetch uses global fetch when no proxy is configured", async () => { + const home = createTempDir("deepcode-proxy-fetch-home-"); + const projectRoot = createTempDir("deepcode-proxy-fetch-project-"); + setHomeDir(home); + + const calls: Array<{ url: string }> = []; + globalThis.fetch = ((url: string | URL | Request) => { + calls.push({ url: String(url) }); + return Promise.resolve(new Response("ok")); + }) as typeof globalThis.fetch; + + const response = await proxyFetch("https://api.deepseek.com/test"); + assert.equal(response.ok, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://api.deepseek.com/test"); +}); + +test("proxyFetch passes init options through to fetch", async () => { + const home = createTempDir("deepcode-proxy-fetch-init-home-"); + const projectRoot = createTempDir("deepcode-proxy-fetch-init-project-"); + setHomeDir(home); + + const calls: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return Promise.resolve(new Response("ok")); + }) as typeof globalThis.fetch; + + await proxyFetch("https://api.deepseek.com/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: '{"key": "value"}', + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].init?.method, "POST"); + assert.equal(calls[0].init?.body, '{"key": "value"}'); +}); + +// --------------------------------------------------------------------------- +// proxyFetch – uses dispatcher when proxy is configured +// --------------------------------------------------------------------------- + +test("proxyFetch injects dispatcher when proxy is configured", async () => { + const home = createTempDir("deepcode-proxy-fetch-proxy-home-"); + const projectRoot = createTempDir("deepcode-proxy-fetch-proxy-project-"); + setHomeDir(home); + writeUserSettings(home, { HTTPS_PROXY: "http://proxy:7890" }); + + const calls: Array<{ url: string; init?: Record }> = []; + globalThis.fetch = ((url: string | URL | Request, init?: Record) => { + calls.push({ url: String(url), init }); + return Promise.resolve(new Response("ok")); + }) as typeof globalThis.fetch; + + await proxyFetch("https://api.deepseek.com/test", { method: "GET" }); + + assert.equal(calls.length, 1); + // When proxy is configured, the init object should contain a dispatcher. + assert.ok(calls[0].init?.dispatcher, "Expected a dispatcher in the fetch init"); +});