fix(agents): route Claude Code child process through configured proxies (#13895)

### What this PR does

fix #13833

Before this PR:

Spawned Claude Code child processes did not reliably use the app's
configured proxy settings. HTTP proxy mode could work, but SOCKS proxy
mode could hang before the SDK returned any initial stream events.

After this PR:

Spawned Claude Code child processes inherit a dedicated Node-only proxy
bootstrap that applies the app's configured proxy settings for
fetch/undici, http/https, and axios. SOCKS proxy mode now avoids
exporting incompatible HTTP proxy env vars and emits clearer diagnostics
when proxy injection is active.

Fixes # None

### Why we need it and why it was done in this way

Claude Code runs as a standalone spawned `cli.js` process, so
main-process proxy patching and Electron session proxy configuration do
not automatically apply to it. This change builds a separate proxy
bootstrap, injects it only when proxy settings are configured, and keeps
the child-process proxy behavior aligned with the app's proxy settings
without changing the Claude SDK package itself.

The following tradeoffs were made:

- Added a separate build artifact for the Claude Code child-process
proxy bootstrap.
- Added child-process-specific proxy diagnostics to improve debugging
when proxy routing fails.
- Split SOCKS proxy environment handling from HTTP proxy handling to
avoid incompatible env combinations.

The following alternatives were considered:

- Relying only on inherited shell proxy environment variables.
- Relying on Electron session proxy configuration from the main process.
- Patching the Claude SDK package directly instead of injecting a local
bootstrap.

Links to places where the discussion took place: None

### Breaking changes

None.

If this PR introduces breaking changes, please describe the changes and
the impact on users.

No breaking changes.

### Special notes for your reviewer

- `out/proxy/index.js` is built as a standalone child-process bootstrap
and unpacked for packaged app usage.
- SOCKS proxy mode now exports `ALL_PROXY` / `SOCKS_PROXY` for the
Claude child process instead of forcing `HTTP_PROXY` / `HTTPS_PROXY` to
a SOCKS URL.
- Added tests covering HTTP vs SOCKS child-process proxy environment
generation.

### Checklist

This checklist is not enforcing, but it's a reminder of items that could
be relevant to every PR.
Approvers are expected to review this list.

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others

### Release note

<!--  Write your release note:
1. Enter your extended release note in the below block. If the PR
requires additional action from users switching to the new release,
include the string "action required".
2. If no release note is required, just write "NONE".
3. Only include user-facing changes (new features, bug fixes visible to
users, UI changes, behavior changes). For CI, maintenance, internal
refactoring, build tooling, or other non-user-facing work, write "NONE".
-->

```release-note
Fixed Claude Code agent sessions so spawned child processes respect configured HTTP and SOCKS proxy settings.
```

---------

Signed-off-by: beyondkmp <beyondkmp@gmail.com>
Signed-off-by: Payne Fu <payne@Paynes-MacBook-Air.local>
Co-authored-by: Payne Fu <payne@Paynes-MacBook-Air.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: suyao <sy20010504@gmail.com>
Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
This commit is contained in:
beyondkmp
2026-04-02 17:04:00 +08:00
committed by GitHub
parent fbb92256b8
commit 285ff0f3a3
12 changed files with 907 additions and 529 deletions

View File

@@ -69,6 +69,7 @@ files:
- "!node_modules/tesseract.js-core/{tesseract-core-simd-lstm.js,tesseract-core-simd-lstm.wasm,tesseract-core-simd-lstm.wasm.js}" # we don't need source files
- "!**/*.{h,iobj,ipdb,tlog,recipe,vcxproj,vcxproj.filters,Makefile,*.Makefile}" # filter .node build files
asarUnpack:
- out/proxy/**
- resources/**
- "**/*.{metal,exp,lib}"
- "node_modules/@img/sharp-libvips-*/**"

View File

@@ -7,6 +7,7 @@ import { visualizer } from 'rollup-plugin-visualizer'
// assert not supported by biome
// import pkg from './package.json' assert { type: 'json' }
import pkg from './package.json'
import { buildProxyBootstrapPlugin } from './scripts/buildProxyBootstrapPlugin'
const visualizerPlugin = (type: 'renderer' | 'main') => {
return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : []
@@ -17,7 +18,14 @@ const isProd = process.env.NODE_ENV === 'production'
export default defineConfig({
main: {
plugins: [...visualizerPlugin('main')],
plugins: [
...visualizerPlugin('main'),
buildProxyBootstrapPlugin({
dependencies: Object.keys(pkg.dependencies),
isProd,
rootDir: __dirname
})
],
resolve: {
alias: {
'@main': resolve('src/main'),

View File

@@ -0,0 +1,52 @@
import { builtinModules } from 'node:module'
import { resolve } from 'path'
import { build as viteBuild, type Plugin } from 'vite'
interface BuildProxyBootstrapPluginOptions {
dependencies: string[]
isProd: boolean
rootDir: string
}
export const buildProxyBootstrapPlugin = ({
dependencies,
isProd,
rootDir
}: BuildProxyBootstrapPluginOptions): Plugin => {
return {
name: 'cherry-build-proxy-bootstrap',
apply: 'build',
async closeBundle() {
await viteBuild({
configFile: false,
publicDir: false,
resolve: {
mainFields: ['module', 'jsnext:main', 'jsnext'],
conditions: ['node']
},
build: {
outDir: resolve(rootDir, 'out/proxy'),
target: 'node22',
minify: false,
reportCompressedSize: false,
copyPublicDir: false,
lib: {
entry: resolve(rootDir, 'src/main/services/proxy/bootstrap.ts'),
formats: ['cjs'],
fileName: () => 'index.js'
},
rollupOptions: {
external: [
'electron',
/^electron\/.+/,
...builtinModules.flatMap((moduleName) => [moduleName, `node:${moduleName}`]),
...dependencies
]
}
},
esbuild: isProd ? { legalComments: 'none' } : {}
})
}
}
}

View File

@@ -1,4 +1,5 @@
import { loggerService } from '@logger'
import { toAsarUnpackedPath } from '@main/utils'
import {
checkName,
getFilesDir,
@@ -19,7 +20,7 @@ import type { FSWatcher } from 'chokidar'
import chokidar from 'chokidar'
import * as crypto from 'crypto'
import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
import { app, dialog, net, shell } from 'electron'
import { dialog, net, shell } from 'electron'
import * as fs from 'fs'
import { writeFileSync } from 'fs'
import { readFile } from 'fs/promises'
@@ -44,9 +45,7 @@ const getRipgrepBinaryPath = (): string | null => {
process.platform === 'win32' ? 'rg.exe' : 'rg'
)
if (app.isPackaged) {
ripgrepBinaryPath = ripgrepBinaryPath.replace(/\.asar([\\/])/, '.asar.unpacked$1')
}
ripgrepBinaryPath = toAsarUnpackedPath(ripgrepBinaryPath)
if (fs.existsSync(ripgrepBinaryPath)) {
return ripgrepBinaryPath

View File

@@ -1,376 +1,20 @@
import { loggerService } from '@logger'
import axios from 'axios'
import type { ProxyConfig } from 'electron'
import { app, session } from 'electron'
import { socksDispatcher } from 'fetch-socks'
import http from 'http'
import https from 'https'
import * as ipaddr from 'ipaddr.js'
import { getSystemProxy } from 'os-proxy-config'
import { ProxyAgent } from 'proxy-agent'
import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici'
import { NodeProxyController } from './proxy/nodeProxy'
const logger = loggerService.withContext('ProxyManager')
let byPassRules: string[] = []
type HostnameMatchType = 'exact' | 'wildcardSubdomain' | 'generalWildcard'
const enum ProxyBypassRuleType {
Local = 'local',
Cidr = 'cidr',
Ip = 'ip',
Domain = 'domain'
}
interface ParsedProxyBypassRule {
type: ProxyBypassRuleType
matchType: HostnameMatchType
rule: string
scheme?: string
port?: string
domain?: string
regex?: RegExp
cidr?: [ipaddr.IPv4 | ipaddr.IPv6, number]
ip?: string
}
let parsedByPassRules: ParsedProxyBypassRule[] = []
const getDefaultPortForProtocol = (protocol: string): string | null => {
switch (protocol.toLowerCase()) {
case 'http:':
return '80'
case 'https:':
return '443'
default:
return null
}
}
const buildWildcardRegex = (pattern: string): RegExp => {
const escapedSegments = pattern.split('*').map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
return new RegExp(`^${escapedSegments.join('.*')}$`, 'i')
}
const isWildcardIp = (value: string): boolean => {
if (!value.includes('*')) {
return false
}
const replaced = value.replace(/\*/g, '0')
return ipaddr.isValid(replaced)
}
const matchHostnameRule = (hostname: string, rule: ParsedProxyBypassRule): boolean => {
const normalizedHostname = hostname.toLowerCase()
switch (rule.matchType) {
case 'exact':
return normalizedHostname === rule.domain
case 'wildcardSubdomain': {
const domain = rule.domain
if (!domain) {
return false
}
return normalizedHostname === domain || normalizedHostname.endsWith(`.${domain}`)
}
case 'generalWildcard':
return rule.regex ? rule.regex.test(normalizedHostname) : false
default:
return false
}
}
const parseProxyBypassRule = (rule: string): ParsedProxyBypassRule | null => {
const trimmedRule = rule.trim()
if (!trimmedRule) {
return null
}
if (trimmedRule === '<local>') {
return {
type: ProxyBypassRuleType.Local,
matchType: 'exact',
rule: '<local>'
}
}
let workingRule = trimmedRule
let scheme: string | undefined
const schemeMatch = workingRule.match(/^([a-zA-Z][a-zA-Z\d+\-.]*):\/\//)
if (schemeMatch) {
scheme = schemeMatch[1].toLowerCase()
workingRule = workingRule.slice(schemeMatch[0].length)
}
// CIDR notation must be processed before port extraction
if (workingRule.includes('/')) {
const cleanedCidr = workingRule.replace(/^\[|\]$/g, '')
if (ipaddr.isValidCIDR(cleanedCidr)) {
return {
type: ProxyBypassRuleType.Cidr,
matchType: 'exact',
rule: workingRule,
scheme,
cidr: ipaddr.parseCIDR(cleanedCidr)
}
}
}
// Extract port: supports "host:port" and "[ipv6]:port" formats
let port: string | undefined
const portMatch = workingRule.match(/^(.+?):(\d+)$/)
if (portMatch) {
// For IPv6, ensure we're not splitting inside the brackets
const potentialHost = portMatch[1]
if (!potentialHost.startsWith('[') || potentialHost.includes(']')) {
workingRule = potentialHost
port = portMatch[2]
}
}
const cleanedHost = workingRule.replace(/^\[|\]$/g, '')
const normalizedHost = cleanedHost.toLowerCase()
if (!cleanedHost) {
return null
}
if (ipaddr.isValid(cleanedHost)) {
return {
type: ProxyBypassRuleType.Ip,
matchType: 'exact',
rule: cleanedHost,
scheme,
port,
ip: cleanedHost
}
}
if (isWildcardIp(cleanedHost)) {
const regexPattern = cleanedHost.replace(/\./g, '\\.').replace(/\*/g, '\\d+')
return {
type: ProxyBypassRuleType.Ip,
matchType: 'generalWildcard',
rule: cleanedHost,
scheme,
port,
regex: new RegExp(`^${regexPattern}$`)
}
}
if (workingRule.startsWith('*.')) {
const domain = normalizedHost.slice(2)
return {
type: ProxyBypassRuleType.Domain,
matchType: 'wildcardSubdomain',
rule: workingRule,
scheme,
port,
domain
}
}
if (workingRule.startsWith('.')) {
const domain = normalizedHost.slice(1)
return {
type: ProxyBypassRuleType.Domain,
matchType: 'wildcardSubdomain',
rule: workingRule,
scheme,
port,
domain
}
}
if (workingRule.includes('*')) {
return {
type: ProxyBypassRuleType.Domain,
matchType: 'generalWildcard',
rule: workingRule,
scheme,
port,
regex: buildWildcardRegex(normalizedHost)
}
}
return {
type: ProxyBypassRuleType.Domain,
matchType: 'exact',
rule: workingRule,
scheme,
port,
domain: normalizedHost
}
}
const isLocalHostname = (hostname: string): boolean => {
const normalized = hostname.toLowerCase()
if (normalized === 'localhost') {
return true
}
const cleaned = hostname.replace(/^\[|\]$/g, '')
if (ipaddr.isValid(cleaned)) {
const parsed = ipaddr.parse(cleaned)
return parsed.range() === 'loopback'
}
return false
}
export const updateByPassRules = (rules: string[]): void => {
byPassRules = rules
parsedByPassRules = []
for (const rule of rules) {
const parsedRule = parseProxyBypassRule(rule)
if (parsedRule) {
parsedByPassRules.push(parsedRule)
} else {
logger.warn(`Skipping invalid proxy bypass rule: ${rule}`)
}
}
}
export const isByPass = (url: string) => {
if (parsedByPassRules.length === 0) {
return false
}
try {
const parsedUrl = new URL(url)
const hostname = parsedUrl.hostname
const cleanedHostname = hostname.replace(/^\[|\]$/g, '')
const protocol = parsedUrl.protocol
const protocolName = protocol.replace(':', '').toLowerCase()
const defaultPort = getDefaultPortForProtocol(protocol)
const port = parsedUrl.port || defaultPort || ''
const hostnameIsIp = ipaddr.isValid(cleanedHostname)
for (const rule of parsedByPassRules) {
if (rule.scheme && rule.scheme !== protocolName) {
continue
}
if (rule.port && rule.port !== port) {
continue
}
switch (rule.type) {
case ProxyBypassRuleType.Local:
if (isLocalHostname(hostname)) {
return true
}
break
case ProxyBypassRuleType.Ip:
if (!hostnameIsIp) {
break
}
if (rule.ip && cleanedHostname === rule.ip) {
return true
}
if (rule.regex && rule.regex.test(cleanedHostname)) {
return true
}
break
case ProxyBypassRuleType.Cidr:
if (hostnameIsIp && rule.cidr) {
const parsedHost = ipaddr.parse(cleanedHostname)
const [cidrAddress, prefixLength] = rule.cidr
// Ensure IP version matches before comparing
if (parsedHost.kind() === cidrAddress.kind() && parsedHost.match([cidrAddress, prefixLength])) {
return true
}
}
break
case ProxyBypassRuleType.Domain:
if (!hostnameIsIp && matchHostnameRule(hostname, rule)) {
return true
}
break
default:
logger.error(`Unknown proxy bypass rule type: ${rule.type}`)
break
}
}
} catch (error) {
logger.error('Failed to check bypass:', error as Error)
return false
}
return false
}
class SelectiveDispatcher extends Dispatcher {
private proxyDispatcher: Dispatcher
private directDispatcher: Dispatcher
constructor(proxyDispatcher: Dispatcher, directDispatcher: Dispatcher) {
super()
this.proxyDispatcher = proxyDispatcher
this.directDispatcher = directDispatcher
}
dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) {
if (opts.origin) {
if (isByPass(opts.origin.toString())) {
return this.directDispatcher.dispatch(opts, handler)
}
}
return this.proxyDispatcher.dispatch(opts, handler)
}
async close(): Promise<void> {
try {
await this.proxyDispatcher.close()
} catch (error) {
logger.error('Failed to close dispatcher:', error as Error)
void this.proxyDispatcher.destroy()
}
}
async destroy(): Promise<void> {
try {
await this.proxyDispatcher.destroy()
} catch (error) {
logger.error('Failed to destroy dispatcher:', error as Error)
}
}
}
export class ProxyManager {
private config: ProxyConfig = { mode: 'direct' }
private systemProxyInterval: NodeJS.Timeout | null = null
private isSettingProxy = false
private proxyDispatcher: Dispatcher | null = null
private proxyAgent: ProxyAgent | null = null
private originalGlobalDispatcher: Dispatcher
private originalSocksDispatcher: Dispatcher
// for http and https
private originalHttpGet: typeof http.get
private originalHttpRequest: typeof http.request
private originalHttpsGet: typeof https.get
private originalHttpsRequest: typeof https.request
private originalAxiosAdapter
constructor() {
this.originalGlobalDispatcher = getGlobalDispatcher()
this.originalSocksDispatcher = global[Symbol.for('undici.globalDispatcher.1')]
this.originalHttpGet = http.get
this.originalHttpRequest = http.request
this.originalHttpsGet = https.get
this.originalHttpsRequest = https.request
this.originalAxiosAdapter = axios.defaults.adapter
}
private nodeProxyController = new NodeProxyController(logger)
private async monitorSystemProxy(): Promise<void> {
// Clear any existing interval first
this.clearSystemProxyMonitor()
// Set new interval
this.systemProxyInterval = setInterval(async () => {
const currentProxy = await getSystemProxy()
if (
@@ -419,18 +63,6 @@ export class ProxyManager {
void this.monitorSystemProxy()
}
// Support both semicolon and comma as separators
if (config.proxyBypassRules !== this.config.proxyBypassRules) {
const rawRules = config.proxyBypassRules
? config.proxyBypassRules
.split(/[;,]/)
.map((rule) => rule.trim())
.filter((rule) => rule.length > 0)
: []
updateByPassRules(rawRules)
}
this.setGlobalProxy(config)
this.config = config
} catch (error) {
@@ -441,149 +73,18 @@ export class ProxyManager {
}
}
private setEnvironment(url: string): void {
if (url === '') {
delete process.env.HTTP_PROXY
delete process.env.HTTPS_PROXY
delete process.env.grpc_proxy
delete process.env.http_proxy
delete process.env.https_proxy
delete process.env.no_proxy
delete process.env.SOCKS_PROXY
delete process.env.ALL_PROXY
return
}
process.env.grpc_proxy = url
process.env.HTTP_PROXY = url
process.env.HTTPS_PROXY = url
process.env.http_proxy = url
process.env.https_proxy = url
process.env.no_proxy = byPassRules.join(',')
if (url.startsWith('socks')) {
process.env.SOCKS_PROXY = url
process.env.ALL_PROXY = url
}
}
private setGlobalProxy(config: ProxyConfig) {
this.setEnvironment(config.proxyRules || '')
this.setGlobalFetchProxy(config)
this.nodeProxyController.configure({
proxyRules: config.mode === 'direct' ? undefined : config.proxyRules,
proxyBypassRules: config.proxyBypassRules
})
void this.setSessionsProxy(config)
this.setGlobalHttpProxy(config)
}
private setGlobalHttpProxy(config: ProxyConfig) {
if (config.mode === 'direct' || !config.proxyRules) {
http.get = this.originalHttpGet
http.request = this.originalHttpRequest
https.get = this.originalHttpsGet
https.request = this.originalHttpsRequest
try {
this.proxyAgent?.destroy()
} catch (error) {
logger.error('Failed to destroy proxy agent:', error as Error)
}
this.proxyAgent = null
return
}
// ProxyAgent 从环境变量读取代理配置
const agent = new ProxyAgent()
this.proxyAgent = agent
http.get = this.bindHttpMethod(this.originalHttpGet, agent)
http.request = this.bindHttpMethod(this.originalHttpRequest, agent)
https.get = this.bindHttpMethod(this.originalHttpsGet, agent)
https.request = this.bindHttpMethod(this.originalHttpsRequest, agent)
}
// oxlint-disable-next-line @typescript-eslint/no-unsafe-function-type
private bindHttpMethod(originalMethod: Function, agent: http.Agent | https.Agent) {
return (...args: any[]) => {
let url: string | URL | undefined
let options: http.RequestOptions | https.RequestOptions
let callback: (res: http.IncomingMessage) => void
if (typeof args[0] === 'string' || args[0] instanceof URL) {
url = args[0]
if (typeof args[1] === 'function') {
options = {}
callback = args[1]
} else {
options = {
...args[1]
}
callback = args[2]
}
} else {
options = {
...args[0]
}
callback = args[1]
}
// filter localhost
if (url) {
if (isByPass(url.toString())) {
return originalMethod(url, options, callback)
}
}
// for webdav https self-signed certificate
if (options.agent instanceof https.Agent) {
;(agent as https.Agent).options.rejectUnauthorized = options.agent.options.rejectUnauthorized
}
options.agent = agent
if (url) {
return originalMethod(url, options, callback)
}
return originalMethod(options, callback)
}
}
private setGlobalFetchProxy(config: ProxyConfig) {
const proxyUrl = config.proxyRules
if (config.mode === 'direct' || !proxyUrl) {
setGlobalDispatcher(this.originalGlobalDispatcher)
global[Symbol.for('undici.globalDispatcher.1')] = this.originalSocksDispatcher
void this.proxyDispatcher?.close()
this.proxyDispatcher = null
axios.defaults.adapter = this.originalAxiosAdapter
return
}
// axios 使用 fetch 代理
axios.defaults.adapter = 'fetch'
const url = new URL(proxyUrl)
if (url.protocol === 'http:' || url.protocol === 'https:') {
this.proxyDispatcher = new SelectiveDispatcher(new EnvHttpProxyAgent(), this.originalGlobalDispatcher)
setGlobalDispatcher(this.proxyDispatcher)
return
}
this.proxyDispatcher = new SelectiveDispatcher(
socksDispatcher({
port: parseInt(url.port),
type: url.protocol === 'socks4:' ? 4 : 5,
host: url.hostname,
userId: url.username || undefined,
password: url.password || undefined
}),
this.originalSocksDispatcher
)
global[Symbol.for('undici.globalDispatcher.1')] = this.proxyDispatcher
}
private async setSessionsProxy(config: ProxyConfig): Promise<void> {
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
await Promise.all(sessions.map((session) => session.setProxy(config)))
// set proxy for electron
void app.setProxy(config)
}
}

View File

@@ -1,10 +1,22 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { isByPass, updateByPassRules } from '../ProxyManager'
import {
applyNodeProxyFromEnvironment,
buildNodeProxyEnvironment,
getNodeProxyConfigFromEnvironment,
getProxyEnvironment,
getProxyProtocol,
ProxyBypassRuleMatcher
} from '../proxy/nodeProxy'
describe('ProxyManager - bypass evaluation', () => {
let matcher: ProxyBypassRuleMatcher
const updateByPassRules = (rules: string[]) => matcher.updateByPassRules(rules)
const isByPass = (url: string) => matcher.isByPass(url)
beforeEach(() => {
updateByPassRules([])
matcher = new ProxyBypassRuleMatcher()
})
it('matches simple hostname patterns', () => {
@@ -83,4 +95,83 @@ describe('ProxyManager - bypass evaluation', () => {
expect(isByPass('http://[::1]')).toBe(true)
expect(isByPass('http://dev.localdomain')).toBe(false)
})
it('exports standard HTTP proxy env vars for http proxies', () => {
const env = buildNodeProxyEnvironment({
proxyRules: 'http://127.0.0.1:7890',
proxyBypassRules: 'localhost,*.local'
})
expect(env.HTTP_PROXY).toBe('http://127.0.0.1:7890')
expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:7890')
expect(env.http_proxy).toBe('http://127.0.0.1:7890')
expect(env.https_proxy).toBe('http://127.0.0.1:7890')
expect(env.ALL_PROXY).toBe('http://127.0.0.1:7890')
expect(env.NO_PROXY).toBe('localhost,*.local')
expect(env.no_proxy).toBe('localhost,*.local')
})
it('exports only socks-compatible env vars for socks proxies', () => {
const env = buildNodeProxyEnvironment({
proxyRules: 'socks5://127.0.0.1:6153',
proxyBypassRules: 'localhost,*.local'
})
expect(env.SOCKS_PROXY).toBe('socks5://127.0.0.1:6153')
expect(env.socks_proxy).toBe('socks5://127.0.0.1:6153')
expect(env.ALL_PROXY).toBe('socks5://127.0.0.1:6153')
expect(env.all_proxy).toBe('socks5://127.0.0.1:6153')
expect(env.HTTP_PROXY).toBeUndefined()
expect(env.HTTPS_PROXY).toBeUndefined()
expect(env.http_proxy).toBeUndefined()
expect(env.https_proxy).toBeUndefined()
expect(env.NO_PROXY).toBe('localhost,*.local')
expect(env.no_proxy).toBe('localhost,*.local')
})
it('returns empty env when proxy rules are missing', () => {
expect(buildNodeProxyEnvironment({})).toEqual({})
})
it('omits no_proxy env vars when bypass rules are missing', () => {
const env = buildNodeProxyEnvironment({
proxyRules: 'http://127.0.0.1:7890'
})
expect(env.NO_PROXY).toBeUndefined()
expect(env.no_proxy).toBeUndefined()
})
it('returns false when bootstrap env has no proxy rules', () => {
expect(applyNodeProxyFromEnvironment({})).toBe(false)
})
it('returns null for invalid proxy urls when detecting protocol', () => {
expect(getProxyProtocol('127.0.0.1:7890')).toBe(null)
})
it('extracts only proxy-related env vars', () => {
expect(
getProxyEnvironment({
HTTP_PROXY: 'http://127.0.0.1:7890',
NO_PROXY: 'localhost',
PATH: '/usr/bin'
})
).toEqual({
HTTP_PROXY: 'http://127.0.0.1:7890',
NO_PROXY: 'localhost'
})
})
it('derives proxy config from standard proxy env vars', () => {
expect(
getNodeProxyConfigFromEnvironment({
ALL_PROXY: 'socks5://127.0.0.1:6153',
NO_PROXY: 'localhost'
})
).toEqual({
proxyRules: 'socks5://127.0.0.1:6153',
proxyBypassRules: 'localhost'
})
})
})

View File

@@ -20,6 +20,12 @@ import { validateModelId } from '@main/apiServer/utils'
import { isWin } from '@main/constant'
import { pluginService } from '@main/services/agents/plugins/PluginService'
import { configManager } from '@main/services/ConfigManager'
import {
getNodeProxyConfigFromEnvironment,
getProxyEnvironment,
getProxyProtocol
} from '@main/services/proxy/nodeProxy'
import { toAsarUnpackedPath } from '@main/utils'
import { autoDiscoverGitBash } from '@main/utils/process'
import { rtkRewrite } from '@main/utils/rtk'
import getLoginShellEnvironment from '@main/utils/shell-env'
@@ -72,13 +78,14 @@ class ClaudeCodeStream extends EventEmitter implements AgentStream {
class ClaudeCodeService implements AgentServiceInterface {
private claudeExecutablePath: string
private claudeProxyBootstrapPath: string
constructor() {
// Resolve Claude Code CLI robustly (works in dev and in asar)
this.claudeExecutablePath = path.join(path.dirname(require_.resolve('@anthropic-ai/claude-agent-sdk')), 'cli.js')
if (app.isPackaged) {
this.claudeExecutablePath = this.claudeExecutablePath.replace(/\.asar([\\/])/, '.asar.unpacked$1')
}
this.claudeExecutablePath = toAsarUnpackedPath(
path.join(path.dirname(require_.resolve('@anthropic-ai/claude-agent-sdk')), 'cli.js')
)
this.claudeProxyBootstrapPath = toAsarUnpackedPath(path.join(app.getAppPath(), 'out', 'proxy', 'index.js'))
}
async invoke(
@@ -132,9 +139,6 @@ class ClaudeCodeService implements AgentServiceInterface {
const apiConfig = await apiConfigService.get()
const loginShellEnv = await getLoginShellEnvironment()
const loginShellEnvWithoutProxies = Object.fromEntries(
Object.entries(loginShellEnv).filter(([key]) => !key.toLowerCase().endsWith('_proxy'))
) as Record<string, string>
// Auto-discover Git Bash path on Windows (already logs internally)
const customGitBashPath = isWin ? autoDiscoverGitBash() : null
@@ -147,7 +151,8 @@ class ClaudeCodeService implements AgentServiceInterface {
)
const env = {
...loginShellEnvWithoutProxies,
...loginShellEnv,
...getProxyEnvironment(process.env),
// prevent claude agent sdk using bedrock api
CLAUDE_CODE_USE_BEDROCK: '0',
// TODO: fix the proxy api server
@@ -188,6 +193,8 @@ class ClaudeCodeService implements AgentServiceInterface {
'CLAUDE_CONFIG_DIR',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_GIT_BASH_PATH',
'CHERRY_STUDIO_NODE_PROXY_RULES',
'CHERRY_STUDIO_NODE_PROXY_BYPASS_RULES',
'NODE_OPTIONS',
'__PROTO__',
'CONSTRUCTOR',
@@ -356,9 +363,27 @@ class ClaudeCodeService implements AgentServiceInterface {
errorChunks.push(chunk)
},
spawnClaudeCodeProcess: (spawnOptions) => {
const childEnv = { ...spawnOptions.env } as NodeJS.ProcessEnv
let execArgv = process.execArgv
const activeProxyConfig = getNodeProxyConfigFromEnvironment(childEnv)
if (activeProxyConfig) {
const proxyProtocol = getProxyProtocol(activeProxyConfig.proxyRules)
logger.info('Injecting proxy into Claude Code child process', {
proxyProtocol,
proxyRules: activeProxyConfig.proxyRules,
proxyBypassRules: activeProxyConfig.proxyBypassRules,
proxyBootstrapPath: this.claudeProxyBootstrapPath
})
execArgv = [...process.execArgv, '--require', this.claudeProxyBootstrapPath]
}
const child = fork(spawnOptions.args[0], spawnOptions.args.slice(1), {
cwd: spawnOptions.cwd,
env: spawnOptions.env as NodeJS.ProcessEnv,
env: childEnv,
execArgv,
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
signal: spawnOptions.signal
})

View File

@@ -0,0 +1,10 @@
import { applyNodeProxyFromEnvironment } from './nodeProxy'
try {
applyNodeProxyFromEnvironment()
} catch (error) {
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
process.stderr.write(
`[CherryStudioProxyBootstrap] Proxy bootstrap failed - child process will run WITHOUT proxy: ${message}\n`
)
}

View File

@@ -0,0 +1,671 @@
import axios from 'axios'
import { socksDispatcher } from 'fetch-socks'
import http from 'http'
import https from 'https'
import * as ipaddr from 'ipaddr.js'
import { ProxyAgent } from 'proxy-agent'
import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici'
export const CHERRY_NODE_PROXY_RULES_ENV = 'CHERRY_STUDIO_NODE_PROXY_RULES'
export const CHERRY_NODE_PROXY_BYPASS_RULES_ENV = 'CHERRY_STUDIO_NODE_PROXY_BYPASS_RULES'
const NODE_PROXY_ENV_KEYS = [
CHERRY_NODE_PROXY_RULES_ENV,
CHERRY_NODE_PROXY_BYPASS_RULES_ENV,
'HTTP_PROXY',
'HTTPS_PROXY',
'http_proxy',
'https_proxy',
'ALL_PROXY',
'all_proxy',
'SOCKS_PROXY',
'socks_proxy',
'NO_PROXY',
'no_proxy',
'grpc_proxy'
] as const
export interface NodeProxyConfig {
proxyRules?: string
proxyBypassRules?: string | string[]
}
interface NodeProxyLogger {
error?: (message: string, ...data: any[]) => void
warn?: (message: string, ...data: any[]) => void
}
type HostnameMatchType = 'exact' | 'wildcardSubdomain' | 'generalWildcard'
const enum ProxyBypassRuleType {
Local = 'local',
Cidr = 'cidr',
Ip = 'ip',
Domain = 'domain'
}
interface ParsedProxyBypassRule {
type: ProxyBypassRuleType
matchType: HostnameMatchType
rule: string
scheme?: string
port?: string
domain?: string
regex?: RegExp
cidr?: [ipaddr.IPv4 | ipaddr.IPv6, number]
ip?: string
}
// This well-known symbol is used by Node.js built-in undici to store the global dispatcher.
// Derived from undici (bundled with Node 22). If undici changes this symbol name in a future
// Node.js release, SOCKS dispatcher save/restore will silently no-op (falls back to original).
// Ref: https://github.com/nodejs/undici/blob/main/lib/global.js
const SOCKS_DISPATCHER_SYMBOL = Symbol.for('undici.globalDispatcher.1')
const globalDispatcherRegistry = globalThis as typeof globalThis & Record<symbol, Dispatcher | undefined>
const getDefaultPortForProtocol = (protocol: string): string | null => {
switch (protocol.toLowerCase()) {
case 'http:':
return '80'
case 'https:':
return '443'
default:
return null
}
}
const buildWildcardRegex = (pattern: string): RegExp => {
const escapedSegments = pattern.split('*').map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
return new RegExp(`^${escapedSegments.join('.*')}$`, 'i')
}
const isWildcardIp = (value: string): boolean => {
if (!value.includes('*')) {
return false
}
const replaced = value.replace(/\*/g, '0')
return ipaddr.isValid(replaced)
}
const matchHostnameRule = (hostname: string, rule: ParsedProxyBypassRule): boolean => {
const normalizedHostname = hostname.toLowerCase()
switch (rule.matchType) {
case 'exact':
return normalizedHostname === rule.domain
case 'wildcardSubdomain': {
const domain = rule.domain
if (!domain) {
return false
}
return normalizedHostname === domain || normalizedHostname.endsWith(`.${domain}`)
}
case 'generalWildcard':
return rule.regex ? rule.regex.test(normalizedHostname) : false
default:
return false
}
}
const parseProxyBypassRule = (rule: string): ParsedProxyBypassRule | null => {
const trimmedRule = rule.trim()
if (!trimmedRule) {
return null
}
if (trimmedRule === '<local>') {
return {
type: ProxyBypassRuleType.Local,
matchType: 'exact',
rule: '<local>'
}
}
let workingRule = trimmedRule
let scheme: string | undefined
const schemeMatch = workingRule.match(/^([a-zA-Z][a-zA-Z\d+\-.]*):\/\//)
if (schemeMatch) {
scheme = schemeMatch[1].toLowerCase()
workingRule = workingRule.slice(schemeMatch[0].length)
}
if (workingRule.includes('/')) {
const cleanedCidr = workingRule.replace(/^\[|\]$/g, '')
if (ipaddr.isValidCIDR(cleanedCidr)) {
return {
type: ProxyBypassRuleType.Cidr,
matchType: 'exact',
rule: workingRule,
scheme,
cidr: ipaddr.parseCIDR(cleanedCidr)
}
}
}
let port: string | undefined
const portMatch = workingRule.match(/^(.+?):(\d+)$/)
if (portMatch) {
const potentialHost = portMatch[1]
if (!potentialHost.startsWith('[') || potentialHost.includes(']')) {
workingRule = potentialHost
port = portMatch[2]
}
}
const cleanedHost = workingRule.replace(/^\[|\]$/g, '')
const normalizedHost = cleanedHost.toLowerCase()
if (!cleanedHost) {
return null
}
if (ipaddr.isValid(cleanedHost)) {
return {
type: ProxyBypassRuleType.Ip,
matchType: 'exact',
rule: cleanedHost,
scheme,
port,
ip: cleanedHost
}
}
if (isWildcardIp(cleanedHost)) {
const regexPattern = cleanedHost.replace(/\./g, '\\.').replace(/\*/g, '\\d+')
return {
type: ProxyBypassRuleType.Ip,
matchType: 'generalWildcard',
rule: cleanedHost,
scheme,
port,
regex: new RegExp(`^${regexPattern}$`)
}
}
if (workingRule.startsWith('*.')) {
const domain = normalizedHost.slice(2)
return {
type: ProxyBypassRuleType.Domain,
matchType: 'wildcardSubdomain',
rule: workingRule,
scheme,
port,
domain
}
}
if (workingRule.startsWith('.')) {
const domain = normalizedHost.slice(1)
return {
type: ProxyBypassRuleType.Domain,
matchType: 'wildcardSubdomain',
rule: workingRule,
scheme,
port,
domain
}
}
if (workingRule.includes('*')) {
return {
type: ProxyBypassRuleType.Domain,
matchType: 'generalWildcard',
rule: workingRule,
scheme,
port,
regex: buildWildcardRegex(normalizedHost)
}
}
return {
type: ProxyBypassRuleType.Domain,
matchType: 'exact',
rule: workingRule,
scheme,
port,
domain: normalizedHost
}
}
const isLocalHostname = (hostname: string): boolean => {
const normalized = hostname.toLowerCase()
if (normalized === 'localhost') {
return true
}
const cleaned = hostname.replace(/^\[|\]$/g, '')
if (ipaddr.isValid(cleaned)) {
const parsed = ipaddr.parse(cleaned)
return parsed.range() === 'loopback'
}
return false
}
export const normalizeProxyBypassRules = (rules?: string | string[]): string[] => {
if (Array.isArray(rules)) {
return rules.map((rule) => rule.trim()).filter((rule) => rule.length > 0)
}
return rules
? rules
.split(/[;,]/)
.map((rule) => rule.trim())
.filter((rule) => rule.length > 0)
: []
}
export const getProxyEnvironment = (env: NodeJS.ProcessEnv = process.env): Record<string, string> => {
const proxyEnv: Record<string, string> = {}
for (const key of NODE_PROXY_ENV_KEYS) {
const value = env[key]
if (typeof value === 'string' && value.trim() !== '') {
proxyEnv[key] = value
}
}
return proxyEnv
}
export const getNodeProxyConfigFromEnvironment = (env: NodeJS.ProcessEnv = process.env): NodeProxyConfig | null => {
const proxyEnv = getProxyEnvironment(env)
const proxyRules =
proxyEnv[CHERRY_NODE_PROXY_RULES_ENV] ||
proxyEnv.ALL_PROXY ||
proxyEnv.all_proxy ||
proxyEnv.SOCKS_PROXY ||
proxyEnv.socks_proxy ||
proxyEnv.HTTPS_PROXY ||
proxyEnv.https_proxy ||
proxyEnv.HTTP_PROXY ||
proxyEnv.http_proxy
if (!proxyRules) {
return null
}
return {
proxyRules,
proxyBypassRules: proxyEnv[CHERRY_NODE_PROXY_BYPASS_RULES_ENV] || proxyEnv.NO_PROXY || proxyEnv.no_proxy
}
}
export const getProxyProtocol = (proxyRules?: string): string | null => {
if (!proxyRules) {
return null
}
try {
return new URL(proxyRules).protocol.replace(':', '').toLowerCase()
} catch {
return null
}
}
export const isSocksProxyProtocol = (protocol: string | null): boolean => {
return protocol !== null && protocol.startsWith('socks')
}
export class ProxyBypassRuleMatcher {
private parsedByPassRules: ParsedProxyBypassRule[] = []
updateByPassRules(rules: string[], logger?: NodeProxyLogger): void {
this.parsedByPassRules = []
for (const rule of rules) {
const parsedRule = parseProxyBypassRule(rule)
if (parsedRule) {
this.parsedByPassRules.push(parsedRule)
} else {
logger?.warn?.(`Skipping invalid proxy bypass rule: ${rule}`)
}
}
}
isByPass(url: string, logger?: NodeProxyLogger) {
if (this.parsedByPassRules.length === 0) {
return false
}
try {
const parsedUrl = new URL(url)
const hostname = parsedUrl.hostname
const cleanedHostname = hostname.replace(/^\[|\]$/g, '')
const protocol = parsedUrl.protocol
const protocolName = protocol.replace(':', '').toLowerCase()
const defaultPort = getDefaultPortForProtocol(protocol)
const port = parsedUrl.port || defaultPort || ''
const hostnameIsIp = ipaddr.isValid(cleanedHostname)
for (const rule of this.parsedByPassRules) {
if (rule.scheme && rule.scheme !== protocolName) {
continue
}
if (rule.port && rule.port !== port) {
continue
}
switch (rule.type) {
case ProxyBypassRuleType.Local:
if (isLocalHostname(hostname)) {
return true
}
break
case ProxyBypassRuleType.Ip:
if (!hostnameIsIp) {
break
}
if (rule.ip && cleanedHostname === rule.ip) {
return true
}
if (rule.regex && rule.regex.test(cleanedHostname)) {
return true
}
break
case ProxyBypassRuleType.Cidr:
if (hostnameIsIp && rule.cidr) {
const parsedHost = ipaddr.parse(cleanedHostname)
const [cidrAddress, prefixLength] = rule.cidr
if (parsedHost.kind() === cidrAddress.kind() && parsedHost.match([cidrAddress, prefixLength])) {
return true
}
}
break
case ProxyBypassRuleType.Domain:
if (!hostnameIsIp && matchHostnameRule(hostname, rule)) {
return true
}
break
default:
logger?.error?.(`Unknown proxy bypass rule type: ${rule.type}`)
break
}
}
} catch (error) {
logger?.error?.('Failed to check bypass:', error as Error)
return false
}
return false
}
}
export const buildNodeProxyEnvironment = (config: NodeProxyConfig): Record<string, string> => {
const proxyUrl = config.proxyRules?.trim()
if (!proxyUrl) {
return {}
}
const normalizedByPassRules = normalizeProxyBypassRules(config.proxyBypassRules)
const proxyProtocol = getProxyProtocol(proxyUrl)
const env: Record<string, string> = {
[CHERRY_NODE_PROXY_RULES_ENV]: proxyUrl,
[CHERRY_NODE_PROXY_BYPASS_RULES_ENV]: normalizedByPassRules.join(',')
}
if (normalizedByPassRules.length > 0) {
env.NO_PROXY = normalizedByPassRules.join(',')
env.no_proxy = normalizedByPassRules.join(',')
}
if (isSocksProxyProtocol(proxyProtocol)) {
env.SOCKS_PROXY = proxyUrl
env.socks_proxy = proxyUrl
env.ALL_PROXY = proxyUrl
env.all_proxy = proxyUrl
return env
}
env.grpc_proxy = proxyUrl
env.HTTP_PROXY = proxyUrl
env.HTTPS_PROXY = proxyUrl
env.http_proxy = proxyUrl
env.https_proxy = proxyUrl
env.ALL_PROXY = proxyUrl
env.all_proxy = proxyUrl
return env
}
class SelectiveDispatcher extends Dispatcher {
constructor(
private proxyDispatcher: Dispatcher,
private directDispatcher: Dispatcher,
private shouldByPass: (url: string) => boolean,
private logger?: NodeProxyLogger
) {
super()
}
dispatch(opts: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandlers) {
if (opts.origin && this.shouldByPass(opts.origin.toString())) {
return this.directDispatcher.dispatch(opts, handler)
}
return this.proxyDispatcher.dispatch(opts, handler)
}
async close(): Promise<void> {
// Only the proxy dispatcher is owned by this wrapper. The direct dispatcher
// is a snapshot of the original global dispatcher and must remain intact so
// NodeProxyController can restore it when proxying is disabled.
try {
await this.proxyDispatcher.close()
} catch (error) {
this.logger?.error?.('Failed to close dispatcher:', error as Error)
void this.proxyDispatcher.destroy()
}
}
async destroy(): Promise<void> {
try {
await this.proxyDispatcher.destroy()
} catch (error) {
this.logger?.error?.('Failed to destroy dispatcher:', error as Error)
}
}
}
export class NodeProxyController {
private proxyDispatcher: Dispatcher | null = null
private proxyAgent: ProxyAgent | null = null
private currentConfigKey: string | null = null
private readonly proxyBypassRuleMatcher = new ProxyBypassRuleMatcher()
private readonly originalGlobalDispatcher: Dispatcher
private readonly originalSocksDispatcher: Dispatcher
private readonly originalHttpGet: typeof http.get
private readonly originalHttpRequest: typeof http.request
private readonly originalHttpsGet: typeof https.get
private readonly originalHttpsRequest: typeof https.request
private readonly originalAxiosAdapter
constructor(private logger?: NodeProxyLogger) {
this.originalGlobalDispatcher = getGlobalDispatcher()
this.originalSocksDispatcher = globalDispatcherRegistry[SOCKS_DISPATCHER_SYMBOL] ?? this.originalGlobalDispatcher
this.originalHttpGet = http.get
this.originalHttpRequest = http.request
this.originalHttpsGet = https.get
this.originalHttpsRequest = https.request
this.originalAxiosAdapter = axios.defaults.adapter
}
configure(config: NodeProxyConfig): void {
const proxyUrl = config.proxyRules?.trim()
const normalizedByPassRules = normalizeProxyBypassRules(config.proxyBypassRules)
const configKey = JSON.stringify({
proxyUrl: proxyUrl ?? null,
proxyByPassRules: normalizedByPassRules
})
if (this.currentConfigKey === configKey) {
return
}
this.proxyBypassRuleMatcher.updateByPassRules(normalizedByPassRules, this.logger)
this.setEnvironment(proxyUrl, normalizedByPassRules)
this.setGlobalFetchProxy(proxyUrl)
this.setGlobalHttpProxy(proxyUrl)
this.currentConfigKey = configKey
}
private setEnvironment(url: string | undefined, normalizedByPassRules: string[]): void {
delete process.env[CHERRY_NODE_PROXY_RULES_ENV]
delete process.env[CHERRY_NODE_PROXY_BYPASS_RULES_ENV]
delete process.env.HTTP_PROXY
delete process.env.HTTPS_PROXY
delete process.env.grpc_proxy
delete process.env.http_proxy
delete process.env.https_proxy
delete process.env.NO_PROXY
delete process.env.no_proxy
delete process.env.SOCKS_PROXY
delete process.env.socks_proxy
delete process.env.ALL_PROXY
delete process.env.all_proxy
if (!url) {
return
}
const env = buildNodeProxyEnvironment({
proxyRules: url,
proxyBypassRules: normalizedByPassRules
})
for (const [key, value] of Object.entries(env)) {
process.env[key] = value
}
}
private setGlobalHttpProxy(proxyUrl: string | undefined) {
if (!proxyUrl) {
http.get = this.originalHttpGet
http.request = this.originalHttpRequest
https.get = this.originalHttpsGet
https.request = this.originalHttpsRequest
try {
this.proxyAgent?.destroy()
} catch (error) {
this.logger?.error?.('Failed to destroy proxy agent:', error as Error)
}
this.proxyAgent = null
return
}
const agent = new ProxyAgent()
this.proxyAgent = agent
http.get = this.bindHttpMethod(this.originalHttpGet, agent)
http.request = this.bindHttpMethod(this.originalHttpRequest, agent)
https.get = this.bindHttpMethod(this.originalHttpsGet, agent)
https.request = this.bindHttpMethod(this.originalHttpsRequest, agent)
}
// oxlint-disable-next-line @typescript-eslint/no-unsafe-function-type
private bindHttpMethod(originalMethod: Function, agent: http.Agent | https.Agent) {
return (...args: any[]) => {
let url: string | URL | undefined
let options: http.RequestOptions | https.RequestOptions
let callback: ((res: http.IncomingMessage) => void) | undefined
if (typeof args[0] === 'string' || args[0] instanceof URL) {
url = args[0]
if (typeof args[1] === 'function') {
options = {}
callback = args[1]
} else {
options = {
...args[1]
}
callback = args[2]
}
} else {
options = {
...args[0]
}
callback = args[1]
}
if (url && this.proxyBypassRuleMatcher.isByPass(url.toString(), this.logger)) {
return originalMethod(url, options, callback)
}
if (options.agent instanceof https.Agent) {
;(agent as https.Agent).options.rejectUnauthorized = options.agent.options.rejectUnauthorized
}
options.agent = agent
if (url) {
return originalMethod(url, options, callback)
}
return originalMethod(options, callback)
}
}
private setGlobalFetchProxy(proxyUrl: string | undefined) {
if (!proxyUrl) {
setGlobalDispatcher(this.originalGlobalDispatcher)
globalDispatcherRegistry[SOCKS_DISPATCHER_SYMBOL] = this.originalSocksDispatcher
void this.proxyDispatcher?.close()
this.proxyDispatcher = null
axios.defaults.adapter = this.originalAxiosAdapter
return
}
let url: URL
try {
url = new URL(proxyUrl)
} catch {
this.logger?.error?.(`Invalid proxy URL: ${proxyUrl}`)
return
}
axios.defaults.adapter = 'fetch'
if (url.protocol === 'http:' || url.protocol === 'https:') {
this.proxyDispatcher = new SelectiveDispatcher(
new EnvHttpProxyAgent(),
this.originalGlobalDispatcher,
(origin) => this.proxyBypassRuleMatcher.isByPass(origin, this.logger),
this.logger
)
setGlobalDispatcher(this.proxyDispatcher)
return
}
this.proxyDispatcher = new SelectiveDispatcher(
socksDispatcher({
port: parseInt(url.port),
type: url.protocol === 'socks4:' ? 4 : 5,
host: url.hostname,
userId: url.username || undefined,
password: url.password || undefined
}),
this.originalSocksDispatcher,
(origin) => this.proxyBypassRuleMatcher.isByPass(origin, this.logger),
this.logger
)
setGlobalDispatcher(this.proxyDispatcher)
globalDispatcherRegistry[SOCKS_DISPATCHER_SYMBOL] = this.proxyDispatcher
}
}
export const applyNodeProxyFromEnvironment = (env: NodeJS.ProcessEnv = process.env): boolean => {
const proxyConfig = getNodeProxyConfigFromEnvironment(env)
if (!proxyConfig) {
return false
}
const controller = new NodeProxyController()
controller.configure(proxyConfig)
return true
}

View File

@@ -49,7 +49,8 @@ vi.mock('../../constant', () => ({
}))
vi.mock('..', () => ({
getResourcePath: () => '/app/resources'
getResourcePath: () => '/app/resources',
toAsarUnpackedPath: (filePath: string) => filePath
}))
vi.mock('semver', () => ({

View File

@@ -8,6 +8,29 @@ export function getResourcePath() {
return path.join(app.getAppPath(), 'resources')
}
export function toAsarUnpackedPath(filePath: string): string {
if (!app.isPackaged) {
return filePath
}
const appPath = app.getAppPath()
if (!appPath.endsWith('.asar')) {
return filePath
}
const unpackedAppPath = appPath.replace(/\.asar$/, '.asar.unpacked')
if (filePath === appPath) {
return unpackedAppPath
}
const appPathPrefix = `${appPath}${path.sep}`
if (!filePath.startsWith(appPathPrefix)) {
return filePath
}
return path.join(unpackedAppPath, path.relative(appPath, filePath))
}
export function getDataPath() {
const dataPath = path.join(app.getPath('userData'), 'Data')
if (!fs.existsSync(dataPath)) {

View File

@@ -6,11 +6,10 @@ import { promisify } from 'node:util'
import { loggerService } from '@logger'
import { HOME_CHERRY_DIR } from '@shared/config/constant'
import { app } from 'electron'
import { gte as semverGte } from 'semver'
import { isWin } from '../constant'
import { getResourcePath } from '.'
import { getResourcePath, toAsarUnpackedPath } from '.'
const execFileAsync = promisify(execFile)
const logger = loggerService.withContext('Utils:Rtk')
@@ -36,10 +35,7 @@ function isPlatformSupported(): boolean {
function getBundledBinariesDir(): string {
const dir = path.join(getResourcePath(), 'binaries', getPlatformKey())
if (app.isPackaged) {
return dir.replace(/\.asar([\\/])/, '.asar.unpacked$1')
}
return dir
return toAsarUnpackedPath(dir)
}
function getUserBinDir(): string {