mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-03 12:27:41 +08:00
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:
@@ -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-*/**"
|
||||
|
||||
@@ -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'),
|
||||
|
||||
52
scripts/buildProxyBootstrapPlugin.ts
Normal file
52
scripts/buildProxyBootstrapPlugin.ts
Normal 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' } : {}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
10
src/main/services/proxy/bootstrap.ts
Normal file
10
src/main/services/proxy/bootstrap.ts
Normal 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`
|
||||
)
|
||||
}
|
||||
671
src/main/services/proxy/nodeProxy.ts
Normal file
671
src/main/services/proxy/nodeProxy.ts
Normal 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
|
||||
}
|
||||
@@ -49,7 +49,8 @@ vi.mock('../../constant', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('..', () => ({
|
||||
getResourcePath: () => '/app/resources'
|
||||
getResourcePath: () => '/app/resources',
|
||||
toAsarUnpackedPath: (filePath: string) => filePath
|
||||
}))
|
||||
|
||||
vi.mock('semver', () => ({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user