@simonyea/holysheep-cli 1.7.2 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/claude.js +67 -0
- package/src/commands/doctor.js +9 -30
- package/src/commands/setup.js +10 -8
- package/src/commands/upgrade.js +17 -3
- package/src/index.js +9 -11
- package/src/tools/claude-code.js +21 -30
- package/src/tools/claude-process-proxy.js +253 -0
- package/src/tools/claude-bridge.js +0 -331
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simonyea/holysheep-cli",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.4",
|
|
4
4
|
"description": "Claude Code/Cursor/Cline API relay for China — ¥1=$1, WeChat/Alipay payment, no credit card, no VPN. One command setup for all AI coding tools.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openai-china",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process')
|
|
4
|
+
const {
|
|
5
|
+
BASE_URL_ANTHROPIC,
|
|
6
|
+
getApiKey,
|
|
7
|
+
} = require('../utils/config')
|
|
8
|
+
const {
|
|
9
|
+
closeSession,
|
|
10
|
+
getLocalProxyUrl,
|
|
11
|
+
startProcessProxy,
|
|
12
|
+
readConfig,
|
|
13
|
+
} = require('../tools/claude-process-proxy')
|
|
14
|
+
|
|
15
|
+
async function runClaude(args = []) {
|
|
16
|
+
const config = readConfig()
|
|
17
|
+
const apiKey = config.apiKey || getApiKey()
|
|
18
|
+
if (!apiKey) {
|
|
19
|
+
throw new Error('Missing API Key. Run hs setup first.')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { server, port, sessionId } = startProcessProxy({})
|
|
23
|
+
const proxyUrl = getLocalProxyUrl(port)
|
|
24
|
+
|
|
25
|
+
const env = {
|
|
26
|
+
...process.env,
|
|
27
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
28
|
+
ANTHROPIC_BASE_URL: BASE_URL_ANTHROPIC,
|
|
29
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
|
|
30
|
+
HOLYSHEEP_CLAUDE_PROCESS_PROXY: '1',
|
|
31
|
+
HOLYSHEEP_CLAUDE_SESSION_ID: sessionId,
|
|
32
|
+
HTTP_PROXY: proxyUrl,
|
|
33
|
+
HTTPS_PROXY: proxyUrl,
|
|
34
|
+
ALL_PROXY: proxyUrl,
|
|
35
|
+
NO_PROXY: '127.0.0.1,localhost',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const child = spawn('claude', args, {
|
|
39
|
+
stdio: 'inherit',
|
|
40
|
+
env,
|
|
41
|
+
shell: process.platform === 'win32',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const cleanup = async () => {
|
|
45
|
+
try {
|
|
46
|
+
server.close()
|
|
47
|
+
} catch {}
|
|
48
|
+
await closeSession(undefined, sessionId)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
process.on('SIGINT', () => child.kill('SIGINT'))
|
|
52
|
+
process.on('SIGTERM', () => child.kill('SIGTERM'))
|
|
53
|
+
|
|
54
|
+
return await new Promise((resolve, reject) => {
|
|
55
|
+
child.once('error', async (error) => {
|
|
56
|
+
await cleanup()
|
|
57
|
+
reject(error)
|
|
58
|
+
})
|
|
59
|
+
child.once('exit', async (code, signal) => {
|
|
60
|
+
await cleanup()
|
|
61
|
+
if (signal) process.kill(process.pid, signal)
|
|
62
|
+
resolve(code || 0)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = runClaude
|
package/src/commands/doctor.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
const chalk = require('chalk')
|
|
5
5
|
const { execSync } = require('child_process')
|
|
6
|
-
const { getApiKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI } = require('../utils/config')
|
|
6
|
+
const { getApiKey, BASE_URL_ANTHROPIC, BASE_URL_OPENAI, BASE_URL_CLAUDE_RELAY } = require('../utils/config')
|
|
7
7
|
const TOOLS = require('../tools')
|
|
8
8
|
|
|
9
9
|
async function doctor() {
|
|
@@ -59,7 +59,7 @@ async function doctor() {
|
|
|
59
59
|
printOpenClawDetails(tool, installState, nodeMajor)
|
|
60
60
|
}
|
|
61
61
|
if (tool.id === 'claude-code' && installed && configured) {
|
|
62
|
-
|
|
62
|
+
printClaudeProcessProxyDetails(tool)
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -159,34 +159,13 @@ function printOpenClawDetails(tool, installState, nodeMajor) {
|
|
|
159
159
|
})
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
function
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
execSync(
|
|
170
|
-
process.platform === 'win32'
|
|
171
|
-
? `powershell -NonInteractive -Command "try{(Invoke-WebRequest -Uri http://127.0.0.1:${port}/health -TimeoutSec 1 -UseBasicParsing).StatusCode}catch{exit 1}"`
|
|
172
|
-
: `curl -sf http://127.0.0.1:${port}/health -o /dev/null --max-time 1`,
|
|
173
|
-
{ stdio: 'ignore', timeout: 3000 }
|
|
174
|
-
)
|
|
175
|
-
healthy = true
|
|
176
|
-
} catch {}
|
|
177
|
-
|
|
178
|
-
const icon = healthy ? chalk.green('↳') : chalk.yellow('↳')
|
|
179
|
-
const text = healthy
|
|
180
|
-
? chalk.green(`Claude Bridge 已运行:127.0.0.1:${port}`)
|
|
181
|
-
: chalk.yellow(`Claude Bridge 未运行:127.0.0.1:${port}(可运行 hs claude-bridge)`)
|
|
182
|
-
console.log(` ${icon} ${text}`)
|
|
183
|
-
|
|
184
|
-
const relayUrl = bridgeConfig.relayUrl || bridgeConfig.controlPlaneUrl || ''
|
|
185
|
-
if (relayUrl) {
|
|
186
|
-
console.log(` ${chalk.gray('↳')} ${chalk.gray(`Claude Relay: ${relayUrl}`)}`)
|
|
187
|
-
} else {
|
|
188
|
-
console.log(` ${chalk.gray('↳')} ${chalk.gray('Claude Relay: 未配置,当前仍直连 Anthropic 兼容入口')}`)
|
|
189
|
-
}
|
|
162
|
+
function printClaudeProcessProxyDetails(tool) {
|
|
163
|
+
const proxyConfig = typeof tool.getProcessProxyConfig === 'function' ? tool.getProcessProxyConfig() : {}
|
|
164
|
+
const relayUrl = proxyConfig.controlPlaneUrl || proxyConfig.relayUrl || BASE_URL_CLAUDE_RELAY
|
|
165
|
+
const mode = proxyConfig.proxyMode || 'unknown'
|
|
166
|
+
console.log(` ${chalk.gray('↳')} ${chalk.gray(`Claude 启动方式:hs claude`)}`)
|
|
167
|
+
console.log(` ${chalk.gray('↳')} ${chalk.gray(`Claude 代理模式:${mode}`)}`)
|
|
168
|
+
console.log(` ${chalk.gray('↳')} ${chalk.gray(`Claude Relay: ${relayUrl || '未配置'}`)}`)
|
|
190
169
|
}
|
|
191
170
|
|
|
192
171
|
function maskKey(key) {
|
package/src/commands/setup.js
CHANGED
|
@@ -11,7 +11,12 @@ const TOOLS = require('../tools')
|
|
|
11
11
|
|
|
12
12
|
// 工具的自动安装命令(npm/pip)
|
|
13
13
|
const AUTO_INSTALL = {
|
|
14
|
-
'claude-code': {
|
|
14
|
+
'claude-code': {
|
|
15
|
+
cmd: process.platform === 'win32'
|
|
16
|
+
? 'powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://claude.ai/install.ps1 | iex"'
|
|
17
|
+
: 'curl -fsSL https://claude.ai/install.sh | bash',
|
|
18
|
+
mgr: process.platform === 'win32' ? 'powershell' : 'bash',
|
|
19
|
+
},
|
|
15
20
|
'codex': { cmd: 'npm install -g @openai/codex', mgr: 'npm' },
|
|
16
21
|
'gemini-cli': { cmd: 'npm install -g @google/gemini-cli', mgr: 'npm' },
|
|
17
22
|
'opencode': { cmd: 'npm install -g opencode-ai', mgr: 'npm' },
|
|
@@ -22,19 +27,19 @@ const AUTO_INSTALL = {
|
|
|
22
27
|
function getWindowsImmediateLaunchCmd(tool) {
|
|
23
28
|
if (process.platform !== 'win32' || !tool?.launchCmd) return null
|
|
24
29
|
|
|
25
|
-
const cmdBin = tool.launchCmd.split(' ')
|
|
30
|
+
const [cmdBin, ...cmdArgs] = tool.launchCmd.split(' ')
|
|
26
31
|
|
|
27
32
|
try {
|
|
28
33
|
const npmPrefix = execSync('npm prefix -g', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
29
34
|
if (npmPrefix) {
|
|
30
35
|
const directCmd = `${npmPrefix}\\${cmdBin}.cmd`
|
|
31
|
-
return `& "${directCmd}"`
|
|
36
|
+
return `& "${directCmd}"${cmdArgs.length ? ` ${cmdArgs.join(' ')}` : ''}`
|
|
32
37
|
}
|
|
33
38
|
} catch {}
|
|
34
39
|
|
|
35
40
|
const appData = process.env.APPDATA
|
|
36
41
|
if (appData) {
|
|
37
|
-
return `& "${appData}\\npm\\${cmdBin}.cmd"`
|
|
42
|
+
return `& "${appData}\\npm\\${cmdBin}.cmd"${cmdArgs.length ? ` ${cmdArgs.join(' ')}` : ''}`
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
return null
|
|
@@ -58,10 +63,7 @@ async function tryAutoInstall(tool) {
|
|
|
58
63
|
|
|
59
64
|
const spinner = ora(`正在安装 ${tool.name}...`).start()
|
|
60
65
|
try {
|
|
61
|
-
const ret = spawnSync(info.cmd
|
|
62
|
-
stdio: 'inherit',
|
|
63
|
-
shell: true,
|
|
64
|
-
})
|
|
66
|
+
const ret = spawnSync(info.cmd, [], { stdio: 'inherit', shell: true })
|
|
65
67
|
if (ret.status !== 0) {
|
|
66
68
|
spinner.fail(`安装失败,请手动运行: ${chalk.cyan(info.cmd)}`)
|
|
67
69
|
return false
|
package/src/commands/upgrade.js
CHANGED
|
@@ -12,8 +12,10 @@ const UPGRADABLE_TOOLS = [
|
|
|
12
12
|
id: 'claude-code',
|
|
13
13
|
command: 'claude',
|
|
14
14
|
versionCmd: 'claude --version',
|
|
15
|
-
npmPkg:
|
|
16
|
-
installCmd:
|
|
15
|
+
npmPkg: null,
|
|
16
|
+
installCmd: process.platform === 'win32'
|
|
17
|
+
? 'powershell -NoProfile -ExecutionPolicy Bypass -Command "irm https://claude.ai/install.ps1 | iex"'
|
|
18
|
+
: 'curl -fsSL https://claude.ai/install.sh | bash',
|
|
17
19
|
},
|
|
18
20
|
{
|
|
19
21
|
name: 'Codex CLI',
|
|
@@ -114,6 +116,18 @@ async function upgrade() {
|
|
|
114
116
|
|
|
115
117
|
const latestVer = await getLatestVersion(tool.npmPkg)
|
|
116
118
|
|
|
119
|
+
if (!tool.npmPkg) {
|
|
120
|
+
console.log(`\r ${chalk.yellow('↑')} ${chalk.yellow(tool.name.padEnd(18))} ${chalk.gray('v' + (localVer || '?'))} → ${chalk.cyan('official installer')} `)
|
|
121
|
+
const success = runUpgrade(tool)
|
|
122
|
+
if (success) {
|
|
123
|
+
const newVer = getLocalVersion(tool)
|
|
124
|
+
console.log(` ${chalk.green('✓')} ${chalk.green(tool.name)} 升级成功 → ${chalk.cyan('v' + (newVer || 'latest'))}`)
|
|
125
|
+
upgraded++
|
|
126
|
+
}
|
|
127
|
+
console.log()
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
|
|
117
131
|
if (!latestVer) {
|
|
118
132
|
console.log(chalk.yellow(' 无法获取最新版本'))
|
|
119
133
|
continue
|
|
@@ -142,7 +156,7 @@ async function upgrade() {
|
|
|
142
156
|
if (!hasInstalled) {
|
|
143
157
|
console.log(chalk.yellow('没有检测到已安装的 AI 编程工具。'))
|
|
144
158
|
console.log(chalk.gray('支持的工具: Claude Code, Codex CLI, Gemini CLI'))
|
|
145
|
-
console.log(chalk.gray('
|
|
159
|
+
console.log(chalk.gray(`安装示例: ${process.platform === 'win32' ? 'irm https://claude.ai/install.ps1 | iex' : 'curl -fsSL https://claude.ai/install.sh | bash'}`))
|
|
146
160
|
} else if (upgraded > 0) {
|
|
147
161
|
console.log(chalk.green(`✓ 升级了 ${upgraded} 个工具`))
|
|
148
162
|
} else if (alreadyLatest > 0) {
|
package/src/index.js
CHANGED
|
@@ -167,18 +167,16 @@ program
|
|
|
167
167
|
})
|
|
168
168
|
})
|
|
169
169
|
|
|
170
|
-
// ── claude
|
|
170
|
+
// ── claude ──────────────────────────────────────────────────────────────────
|
|
171
171
|
program
|
|
172
|
-
.command('claude
|
|
173
|
-
.
|
|
174
|
-
.
|
|
175
|
-
.
|
|
176
|
-
.action((
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
host: opts.host || '127.0.0.1',
|
|
181
|
-
})
|
|
172
|
+
.command('claude [args...]')
|
|
173
|
+
.allowUnknownOption(true)
|
|
174
|
+
.passThroughOptions()
|
|
175
|
+
.description('通过 HolySheep 整进程代理启动 Claude Code')
|
|
176
|
+
.action(async (args = []) => {
|
|
177
|
+
const runClaude = require('./commands/claude')
|
|
178
|
+
const code = await runClaude(args)
|
|
179
|
+
process.exit(code)
|
|
182
180
|
})
|
|
183
181
|
|
|
184
182
|
// 默认:无命令时显示帮助 + 提示 setup
|
package/src/tools/claude-code.js
CHANGED
|
@@ -16,14 +16,7 @@ const {
|
|
|
16
16
|
BASE_URL_ANTHROPIC,
|
|
17
17
|
BASE_URL_CLAUDE_RELAY,
|
|
18
18
|
} = require('../utils/config')
|
|
19
|
-
const {
|
|
20
|
-
DEFAULT_BRIDGE_PORT,
|
|
21
|
-
getBridgeBaseUrl,
|
|
22
|
-
getConfiguredBridgePort,
|
|
23
|
-
readBridgeConfig,
|
|
24
|
-
spawnBridge,
|
|
25
|
-
writeBridgeConfig,
|
|
26
|
-
} = require('./claude-bridge')
|
|
19
|
+
const { readConfig, writeConfig } = require('./claude-process-proxy')
|
|
27
20
|
|
|
28
21
|
const SETTINGS_FILE = path.join(os.homedir(), '.claude', 'settings.json')
|
|
29
22
|
|
|
@@ -51,21 +44,21 @@ module.exports = {
|
|
|
51
44
|
},
|
|
52
45
|
isConfigured() {
|
|
53
46
|
const s = readSettings()
|
|
54
|
-
const bridge =
|
|
55
|
-
const bridgePort = getConfiguredBridgePort(bridge)
|
|
47
|
+
const bridge = readConfig()
|
|
56
48
|
return Boolean(
|
|
57
49
|
s.env?.ANTHROPIC_AUTH_TOKEN &&
|
|
58
|
-
s.env?.ANTHROPIC_BASE_URL ===
|
|
50
|
+
s.env?.ANTHROPIC_BASE_URL === BASE_URL_ANTHROPIC &&
|
|
59
51
|
bridge.apiKey &&
|
|
60
|
-
bridge.bridgeSecret
|
|
52
|
+
bridge.bridgeSecret &&
|
|
53
|
+
bridge.controlPlaneUrl
|
|
61
54
|
)
|
|
62
55
|
},
|
|
63
56
|
configure(apiKey, baseUrl) {
|
|
64
|
-
const bridge =
|
|
65
|
-
const bridgePort = getConfiguredBridgePort(bridge)
|
|
57
|
+
const bridge = readConfig()
|
|
66
58
|
const relayUrl = bridge.relayUrl || BASE_URL_CLAUDE_RELAY || ''
|
|
67
|
-
|
|
68
|
-
port:
|
|
59
|
+
writeConfig({
|
|
60
|
+
port: bridge.port || 14555,
|
|
61
|
+
processProxyPort: bridge.processProxyPort || 14556,
|
|
69
62
|
apiKey,
|
|
70
63
|
baseUrlAnthropic: baseUrl || BASE_URL_ANTHROPIC,
|
|
71
64
|
controlPlaneUrl: relayUrl || baseUrl || BASE_URL_ANTHROPIC,
|
|
@@ -76,20 +69,18 @@ module.exports = {
|
|
|
76
69
|
nodeId: bridge.nodeId || '',
|
|
77
70
|
sessionMode: bridge.sessionMode || 'pinned-node',
|
|
78
71
|
installSource: 'holysheep-cli',
|
|
72
|
+
proxyMode: 'claude-process',
|
|
79
73
|
})
|
|
80
74
|
|
|
81
|
-
if (!spawnBridge(bridgePort)) {
|
|
82
|
-
throw new Error('HolySheep Claude Bridge 启动失败')
|
|
83
|
-
}
|
|
84
|
-
|
|
85
75
|
const settings = readSettings()
|
|
86
76
|
if (!settings.env) settings.env = {}
|
|
87
77
|
settings.env.ANTHROPIC_AUTH_TOKEN = apiKey
|
|
88
|
-
settings.env.ANTHROPIC_BASE_URL =
|
|
78
|
+
settings.env.ANTHROPIC_BASE_URL = BASE_URL_ANTHROPIC
|
|
89
79
|
settings.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1'
|
|
90
|
-
settings.env.
|
|
80
|
+
settings.env.HOLYSHEEP_CLAUDE_LAUNCHER = 'hs'
|
|
91
81
|
// 清理旧的同义字段
|
|
92
82
|
delete settings.env.ANTHROPIC_API_KEY
|
|
83
|
+
delete settings.env.HOLYSHEEP_CLAUDE_BRIDGE
|
|
93
84
|
writeSettings(settings)
|
|
94
85
|
return { file: SETTINGS_FILE, hot: true }
|
|
95
86
|
},
|
|
@@ -101,18 +92,18 @@ module.exports = {
|
|
|
101
92
|
delete settings.env.ANTHROPIC_BASE_URL
|
|
102
93
|
delete settings.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
|
|
103
94
|
delete settings.env.HOLYSHEEP_CLAUDE_BRIDGE
|
|
95
|
+
delete settings.env.HOLYSHEEP_CLAUDE_LAUNCHER
|
|
104
96
|
}
|
|
105
97
|
writeSettings(settings)
|
|
106
98
|
},
|
|
107
99
|
getConfigPath() { return SETTINGS_FILE },
|
|
108
|
-
hint: '通过
|
|
109
|
-
launchCmd: 'claude',
|
|
110
|
-
installCmd:
|
|
100
|
+
hint: '通过 hs claude 以整进程代理方式启动 Claude Code',
|
|
101
|
+
launchCmd: 'hs claude',
|
|
102
|
+
installCmd: process.platform === 'win32'
|
|
103
|
+
? 'irm https://claude.ai/install.ps1 | iex'
|
|
104
|
+
: 'curl -fsSL https://claude.ai/install.sh | bash',
|
|
111
105
|
docsUrl: 'https://docs.anthropic.com/claude-code',
|
|
112
|
-
|
|
113
|
-
return
|
|
114
|
-
},
|
|
115
|
-
getBridgeConfig() {
|
|
116
|
-
return readBridgeConfig()
|
|
106
|
+
getProcessProxyConfig() {
|
|
107
|
+
return readConfig()
|
|
117
108
|
},
|
|
118
109
|
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict'
|
|
3
|
+
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const http = require('http')
|
|
6
|
+
const https = require('https')
|
|
7
|
+
const net = require('net')
|
|
8
|
+
const path = require('path')
|
|
9
|
+
const os = require('os')
|
|
10
|
+
const crypto = require('crypto')
|
|
11
|
+
const { URL } = require('url')
|
|
12
|
+
const fetch = global.fetch || require('node-fetch')
|
|
13
|
+
|
|
14
|
+
const HOLYSHEEP_DIR = path.join(os.homedir(), '.holysheep')
|
|
15
|
+
const CONFIG_PATH = path.join(HOLYSHEEP_DIR, 'claude-proxy.json')
|
|
16
|
+
const DEFAULT_PROXY_PORT = 14556
|
|
17
|
+
|
|
18
|
+
function ensureDir() {
|
|
19
|
+
if (!fs.existsSync(HOLYSHEEP_DIR)) fs.mkdirSync(HOLYSHEEP_DIR, { recursive: true })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readConfig() {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
|
|
25
|
+
} catch {
|
|
26
|
+
return {}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeConfig(data) {
|
|
31
|
+
ensureDir()
|
|
32
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(data, null, 2), 'utf8')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getProcessProxyPort(config = readConfig()) {
|
|
36
|
+
const value = Number(config.processProxyPort)
|
|
37
|
+
return Number.isInteger(value) && value > 0 ? value : DEFAULT_PROXY_PORT
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getLocalProxyUrl(port = getProcessProxyPort()) {
|
|
41
|
+
return `http://127.0.0.1:${port}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getControlPlaneUrl(config) {
|
|
45
|
+
return String(config.controlPlaneUrl || config.relayUrl || '').replace(/\/+$/, '')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const leaseCache = new Map()
|
|
49
|
+
|
|
50
|
+
async function readJsonResponse(response) {
|
|
51
|
+
const chunks = []
|
|
52
|
+
for await (const chunk of response) chunks.push(Buffer.from(chunk))
|
|
53
|
+
if (!chunks.length) return null
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function requestSessionLease(config, sessionId) {
|
|
62
|
+
const cached = leaseCache.get(sessionId)
|
|
63
|
+
if (cached?.expiresAt && new Date(cached.expiresAt).getTime() - Date.now() > 30_000) {
|
|
64
|
+
return cached
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const controlPlaneUrl = getControlPlaneUrl(config)
|
|
68
|
+
if (!controlPlaneUrl) throw new Error('Claude relay control plane is not configured')
|
|
69
|
+
|
|
70
|
+
const response = await fetch(`${controlPlaneUrl}/session/open`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'content-type': 'application/json' },
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
sessionId,
|
|
75
|
+
bridgeId: config.bridgeId || 'local-bridge',
|
|
76
|
+
deviceId: config.deviceId || '',
|
|
77
|
+
installSource: config.installSource || 'holysheep-cli',
|
|
78
|
+
proxyMode: 'claude-process',
|
|
79
|
+
}),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const payload = await response.json().catch(() => null)
|
|
83
|
+
if (!response.ok || !payload?.success || !payload?.data?.ticket) {
|
|
84
|
+
throw new Error(payload?.error?.message || `Failed to open Claude session (HTTP ${response.status})`)
|
|
85
|
+
}
|
|
86
|
+
leaseCache.set(sessionId, payload.data)
|
|
87
|
+
return payload.data
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildAuthHeaders(config, lease) {
|
|
91
|
+
return {
|
|
92
|
+
'x-hs-bridge-id': config.bridgeId || 'local-bridge',
|
|
93
|
+
'x-hs-device-id': config.deviceId || '',
|
|
94
|
+
'x-hs-install-source': config.installSource || 'holysheep-cli',
|
|
95
|
+
'x-hs-session-id': lease.sessionId,
|
|
96
|
+
'x-hs-bridge-ticket': lease.ticket,
|
|
97
|
+
'x-hs-node-id': lease.nodeId || '',
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function deriveNodeProxyUrl(lease) {
|
|
102
|
+
if (lease.nodeProxyUrl) return String(lease.nodeProxyUrl)
|
|
103
|
+
if (!lease.nodeBaseUrl) throw new Error('Lease does not include node proxy information')
|
|
104
|
+
const upstream = new URL(String(lease.nodeBaseUrl))
|
|
105
|
+
const proxyPort = upstream.port === '3101' ? '3129' : upstream.port
|
|
106
|
+
upstream.port = proxyPort || '3129'
|
|
107
|
+
upstream.pathname = ''
|
|
108
|
+
upstream.search = ''
|
|
109
|
+
upstream.hash = ''
|
|
110
|
+
return upstream.toString().replace(/\/+$/, '')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function createConnectTunnel(proxyUrl, target, headers) {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const upstream = new URL(proxyUrl)
|
|
116
|
+
const request = http.request({
|
|
117
|
+
host: upstream.hostname,
|
|
118
|
+
port: Number(upstream.port || 80),
|
|
119
|
+
method: 'CONNECT',
|
|
120
|
+
path: target,
|
|
121
|
+
headers,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
request.once('connect', (response, socket, head) => {
|
|
125
|
+
if (response.statusCode !== 200) {
|
|
126
|
+
socket.destroy()
|
|
127
|
+
return reject(new Error(`Upstream proxy CONNECT failed (HTTP ${response.statusCode})`))
|
|
128
|
+
}
|
|
129
|
+
if (head?.length) socket.unshift(head)
|
|
130
|
+
resolve(socket)
|
|
131
|
+
})
|
|
132
|
+
request.once('error', reject)
|
|
133
|
+
request.end()
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function pipeWithCleanup(a, b) {
|
|
138
|
+
a.pipe(b)
|
|
139
|
+
b.pipe(a)
|
|
140
|
+
const close = () => {
|
|
141
|
+
a.destroy()
|
|
142
|
+
b.destroy()
|
|
143
|
+
}
|
|
144
|
+
a.once('error', close)
|
|
145
|
+
b.once('error', close)
|
|
146
|
+
a.once('close', close)
|
|
147
|
+
b.once('close', close)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function createProcessProxyServer({ sessionId, configPath = CONFIG_PATH }) {
|
|
151
|
+
const server = http.createServer(async (clientReq, clientRes) => {
|
|
152
|
+
try {
|
|
153
|
+
const config = readConfig(configPath)
|
|
154
|
+
const lease = await requestSessionLease(config, sessionId)
|
|
155
|
+
const nodeProxyUrl = deriveNodeProxyUrl(lease)
|
|
156
|
+
const headers = {
|
|
157
|
+
...buildAuthHeaders(config, lease),
|
|
158
|
+
host: new URL(clientReq.url).host,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const upstream = new URL(nodeProxyUrl)
|
|
162
|
+
const forwardReq = http.request({
|
|
163
|
+
host: upstream.hostname,
|
|
164
|
+
port: Number(upstream.port || 80),
|
|
165
|
+
method: clientReq.method,
|
|
166
|
+
path: clientReq.url,
|
|
167
|
+
headers: {
|
|
168
|
+
...clientReq.headers,
|
|
169
|
+
...headers,
|
|
170
|
+
connection: 'close',
|
|
171
|
+
},
|
|
172
|
+
}, (forwardRes) => {
|
|
173
|
+
clientRes.writeHead(forwardRes.statusCode || 502, forwardRes.headers)
|
|
174
|
+
forwardRes.pipe(clientRes)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
forwardReq.once('error', (error) => {
|
|
178
|
+
clientRes.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
179
|
+
clientRes.end(error.message || 'Proxy error')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
clientReq.pipe(forwardReq)
|
|
183
|
+
} catch (error) {
|
|
184
|
+
clientRes.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
|
|
185
|
+
clientRes.end(error.message || 'Proxy error')
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
server.on('connect', async (req, clientSocket, head) => {
|
|
190
|
+
try {
|
|
191
|
+
const config = readConfig(configPath)
|
|
192
|
+
const lease = await requestSessionLease(config, sessionId)
|
|
193
|
+
const target = String(req.url || '').trim()
|
|
194
|
+
const [host, rawPort] = target.split(':')
|
|
195
|
+
const port = Number(rawPort || 443)
|
|
196
|
+
if (!host || !Number.isInteger(port) || ![80, 443].includes(port)) {
|
|
197
|
+
clientSocket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
|
|
198
|
+
return clientSocket.destroy()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const upstreamSocket = await createConnectTunnel(
|
|
202
|
+
deriveNodeProxyUrl(lease),
|
|
203
|
+
target,
|
|
204
|
+
buildAuthHeaders(config, lease)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
|
208
|
+
if (head?.length) upstreamSocket.write(head)
|
|
209
|
+
pipeWithCleanup(clientSocket, upstreamSocket)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
clientSocket.write(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain; charset=utf-8\r\n\r\n${error.message}`)
|
|
212
|
+
clientSocket.destroy()
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
return server
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function startProcessProxy({ port = null, sessionId = null, configPath = CONFIG_PATH } = {}) {
|
|
220
|
+
const config = readConfig(configPath)
|
|
221
|
+
const listenPort = port || getProcessProxyPort(config)
|
|
222
|
+
const effectiveSessionId = sessionId || crypto.randomUUID()
|
|
223
|
+
const server = createProcessProxyServer({ sessionId: effectiveSessionId, configPath })
|
|
224
|
+
server.listen(listenPort, '127.0.0.1')
|
|
225
|
+
return { server, port: listenPort, sessionId: effectiveSessionId }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function closeSession(configPath, sessionId) {
|
|
229
|
+
if (!sessionId) return
|
|
230
|
+
const config = readConfig(configPath)
|
|
231
|
+
const controlPlaneUrl = getControlPlaneUrl(config)
|
|
232
|
+
if (!controlPlaneUrl) return
|
|
233
|
+
try {
|
|
234
|
+
await fetch(`${controlPlaneUrl}/session/close`, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'content-type': 'application/json' },
|
|
237
|
+
body: JSON.stringify({ sessionId }),
|
|
238
|
+
})
|
|
239
|
+
} catch {}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
CONFIG_PATH,
|
|
244
|
+
DEFAULT_PROXY_PORT,
|
|
245
|
+
closeSession,
|
|
246
|
+
getLocalProxyUrl,
|
|
247
|
+
getProcessProxyPort,
|
|
248
|
+
getControlPlaneUrl,
|
|
249
|
+
readConfig,
|
|
250
|
+
requestSessionLease,
|
|
251
|
+
startProcessProxy,
|
|
252
|
+
writeConfig,
|
|
253
|
+
}
|
|
@@ -1,331 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict'
|
|
3
|
-
|
|
4
|
-
const fs = require('fs')
|
|
5
|
-
const http = require('http')
|
|
6
|
-
const path = require('path')
|
|
7
|
-
const os = require('os')
|
|
8
|
-
const crypto = require('crypto')
|
|
9
|
-
const { spawn, execSync } = require('child_process')
|
|
10
|
-
const fetch = global.fetch || require('node-fetch')
|
|
11
|
-
|
|
12
|
-
const HOLYSHEEP_DIR = path.join(os.homedir(), '.holysheep')
|
|
13
|
-
const CLAUDE_BRIDGE_CONFIG_FILE = path.join(HOLYSHEEP_DIR, 'claude-bridge.json')
|
|
14
|
-
const DEFAULT_BRIDGE_PORT = 14555
|
|
15
|
-
const isWin = process.platform === 'win32'
|
|
16
|
-
const leaseCache = new Map()
|
|
17
|
-
|
|
18
|
-
function ensureDir() {
|
|
19
|
-
if (!fs.existsSync(HOLYSHEEP_DIR)) fs.mkdirSync(HOLYSHEEP_DIR, { recursive: true })
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function parseArgs(argv) {
|
|
23
|
-
const args = { port: null, host: '127.0.0.1', config: CLAUDE_BRIDGE_CONFIG_FILE }
|
|
24
|
-
for (let i = 0; i < argv.length; i++) {
|
|
25
|
-
const value = argv[i]
|
|
26
|
-
if (value === '--port') args.port = Number(argv[++i])
|
|
27
|
-
else if (value === '--host') args.host = argv[++i]
|
|
28
|
-
else if (value === '--config') args.config = argv[++i]
|
|
29
|
-
}
|
|
30
|
-
return args
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function readBridgeConfig(configPath = CLAUDE_BRIDGE_CONFIG_FILE) {
|
|
34
|
-
try {
|
|
35
|
-
return JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
|
36
|
-
} catch {
|
|
37
|
-
return {}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function writeBridgeConfig(data, configPath = CLAUDE_BRIDGE_CONFIG_FILE) {
|
|
42
|
-
ensureDir()
|
|
43
|
-
fs.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf8')
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function getConfiguredBridgePort(config = readBridgeConfig()) {
|
|
47
|
-
const port = Number(config.port)
|
|
48
|
-
return Number.isInteger(port) && port > 0 ? port : DEFAULT_BRIDGE_PORT
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function getBridgeBaseUrl(port = getConfiguredBridgePort()) {
|
|
52
|
-
return `http://127.0.0.1:${port}`
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function sendJson(res, statusCode, payload) {
|
|
56
|
-
res.writeHead(statusCode, {
|
|
57
|
-
'content-type': 'application/json; charset=utf-8',
|
|
58
|
-
'cache-control': 'no-store',
|
|
59
|
-
})
|
|
60
|
-
res.end(JSON.stringify(payload))
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function readRawBody(req) {
|
|
64
|
-
return new Promise((resolve, reject) => {
|
|
65
|
-
const chunks = []
|
|
66
|
-
let size = 0
|
|
67
|
-
req.on('data', (chunk) => {
|
|
68
|
-
size += chunk.length
|
|
69
|
-
if (size > 20 * 1024 * 1024) {
|
|
70
|
-
reject(new Error('Request body too large'))
|
|
71
|
-
req.destroy()
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
chunks.push(chunk)
|
|
75
|
-
})
|
|
76
|
-
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
77
|
-
req.on('error', reject)
|
|
78
|
-
})
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function stripBearer(value) {
|
|
82
|
-
const raw = String(value || '').trim()
|
|
83
|
-
if (!raw) return ''
|
|
84
|
-
if (/^Bearer\s+/i.test(raw)) return raw.replace(/^Bearer\s+/i, '').trim()
|
|
85
|
-
return raw
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function buildBodyHash(body) {
|
|
89
|
-
return crypto.createHash('sha256').update(body || Buffer.alloc(0)).digest('hex')
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function buildBridgeSignature(req, config, body, timestamp) {
|
|
93
|
-
const secret = String(config.bridgeSecret || '').trim()
|
|
94
|
-
if (!secret) return ''
|
|
95
|
-
|
|
96
|
-
const url = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`)
|
|
97
|
-
const canonical = [
|
|
98
|
-
String(req.method || 'GET').toUpperCase(),
|
|
99
|
-
url.pathname + (url.search || ''),
|
|
100
|
-
String(timestamp),
|
|
101
|
-
buildBodyHash(body),
|
|
102
|
-
String(config.bridgeId || '')
|
|
103
|
-
].join('\n')
|
|
104
|
-
|
|
105
|
-
return crypto.createHmac('sha256', secret).update(canonical).digest('hex')
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function getBridgeUpstream(config) {
|
|
109
|
-
return String(config.relayUrl || config.controlPlaneUrl || config.baseUrlAnthropic || '').replace(/\/+$/, '')
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function parseJsonBody(body) {
|
|
113
|
-
if (!body || !body.length) return null
|
|
114
|
-
try {
|
|
115
|
-
return JSON.parse(body.toString('utf8'))
|
|
116
|
-
} catch {
|
|
117
|
-
return null
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function extractSessionId(parsedBody, config) {
|
|
122
|
-
const headerSessionId =
|
|
123
|
-
parsedBody?.metadata?.session_id ||
|
|
124
|
-
parsedBody?.session_id ||
|
|
125
|
-
parsedBody?.metadata?.conversation_id ||
|
|
126
|
-
null
|
|
127
|
-
if (headerSessionId) return String(headerSessionId)
|
|
128
|
-
|
|
129
|
-
const metadataUserId = parsedBody?.metadata?.user_id
|
|
130
|
-
if (typeof metadataUserId === 'string' && metadataUserId.trim()) {
|
|
131
|
-
return metadataUserId.trim()
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return `${config.bridgeId || 'local-bridge'}:default`
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function isRelayMode(config) {
|
|
138
|
-
const upstream = getBridgeUpstream(config)
|
|
139
|
-
return /\/claude-relay(?:\/|$)/.test(upstream)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async function requestSessionLease(config, sessionId, parsedBody) {
|
|
143
|
-
const controlPlaneUrl = getBridgeUpstream(config)
|
|
144
|
-
const cached = leaseCache.get(sessionId)
|
|
145
|
-
if (cached && cached.expiresAt && new Date(cached.expiresAt).getTime() - Date.now() > 30_000) {
|
|
146
|
-
return cached
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const response = await fetch(`${controlPlaneUrl}/session/open`, {
|
|
150
|
-
method: 'POST',
|
|
151
|
-
headers: {
|
|
152
|
-
'content-type': 'application/json',
|
|
153
|
-
},
|
|
154
|
-
body: JSON.stringify({
|
|
155
|
-
sessionId,
|
|
156
|
-
bridgeId: config.bridgeId || 'local-bridge',
|
|
157
|
-
deviceId: config.deviceId || '',
|
|
158
|
-
model: parsedBody?.model || null,
|
|
159
|
-
installSource: config.installSource || 'holysheep-cli',
|
|
160
|
-
}),
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
const payload = await response.json().catch(() => null)
|
|
164
|
-
if (!response.ok || !payload?.success || !payload?.data?.nodeBaseUrl || !payload?.data?.ticket) {
|
|
165
|
-
const message =
|
|
166
|
-
payload?.error?.message ||
|
|
167
|
-
payload?.message ||
|
|
168
|
-
`Failed to open Claude relay session (HTTP ${response.status})`
|
|
169
|
-
throw new Error(message)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
leaseCache.set(sessionId, payload.data)
|
|
173
|
-
return payload.data
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function buildUpstreamHeaders(req, config, body) {
|
|
177
|
-
const headers = {}
|
|
178
|
-
for (const [key, value] of Object.entries(req.headers || {})) {
|
|
179
|
-
const normalized = key.toLowerCase()
|
|
180
|
-
if (normalized === 'host') continue
|
|
181
|
-
if (normalized === 'content-length') continue
|
|
182
|
-
headers[normalized] = value
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const token = stripBearer(headers.authorization) || headers['x-api-key'] || config.apiKey || ''
|
|
186
|
-
delete headers.authorization
|
|
187
|
-
if (token) headers['x-api-key'] = token
|
|
188
|
-
if (!headers['anthropic-version']) headers['anthropic-version'] = '2023-06-01'
|
|
189
|
-
headers['user-agent'] = 'holysheep-claude-bridge/1.0'
|
|
190
|
-
headers['x-hs-bridge-id'] = config.bridgeId || 'local-bridge'
|
|
191
|
-
headers['x-hs-install-source'] = config.installSource || 'holysheep-cli'
|
|
192
|
-
if (config.deviceId) headers['x-hs-device-id'] = config.deviceId
|
|
193
|
-
if (config.userToken) headers['x-hs-user-token'] = config.userToken
|
|
194
|
-
if (config.nodeId) headers['x-hs-node-id'] = config.nodeId
|
|
195
|
-
if (config.sessionMode) headers['x-hs-session-mode'] = config.sessionMode
|
|
196
|
-
const timestamp = Math.floor(Date.now() / 1000)
|
|
197
|
-
headers['x-hs-bridge-timestamp'] = String(timestamp)
|
|
198
|
-
const signature = buildBridgeSignature(req, config, body, timestamp)
|
|
199
|
-
if (signature) headers['x-hs-bridge-signature'] = signature
|
|
200
|
-
if (body && body.length > 0) headers['content-length'] = String(body.length)
|
|
201
|
-
|
|
202
|
-
return headers
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function createBridgeServer(configPath = CLAUDE_BRIDGE_CONFIG_FILE) {
|
|
206
|
-
return http.createServer(async (req, res) => {
|
|
207
|
-
if (req.method === 'OPTIONS') {
|
|
208
|
-
res.writeHead(204, {
|
|
209
|
-
'access-control-allow-origin': '*',
|
|
210
|
-
'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
|
|
211
|
-
'access-control-allow-headers': 'content-type,authorization,x-api-key,anthropic-version',
|
|
212
|
-
})
|
|
213
|
-
return res.end()
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
try {
|
|
217
|
-
const config = readBridgeConfig(configPath)
|
|
218
|
-
const url = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`)
|
|
219
|
-
const port = getConfiguredBridgePort(config)
|
|
220
|
-
|
|
221
|
-
if (req.method === 'GET' && url.pathname === '/health') {
|
|
222
|
-
return sendJson(res, 200, {
|
|
223
|
-
ok: true,
|
|
224
|
-
port,
|
|
225
|
-
upstream: getBridgeUpstream(config),
|
|
226
|
-
relayUrl: config.relayUrl || '',
|
|
227
|
-
mode: config.relayUrl ? 'relay' : 'direct',
|
|
228
|
-
bridgeId: config.bridgeId || null,
|
|
229
|
-
nodeId: config.nodeId || null,
|
|
230
|
-
installSource: config.installSource || 'holysheep-cli',
|
|
231
|
-
})
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
let upstreamBase = getBridgeUpstream(config)
|
|
235
|
-
if (!upstreamBase) {
|
|
236
|
-
return sendJson(res, 500, { error: { message: 'Bridge upstream is not configured' } })
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const body = await readRawBody(req)
|
|
240
|
-
const parsedBody = parseJsonBody(body)
|
|
241
|
-
if (isRelayMode(config) && req.method !== 'GET' && url.pathname !== '/health') {
|
|
242
|
-
const sessionId = extractSessionId(parsedBody, config)
|
|
243
|
-
const lease = await requestSessionLease(config, sessionId, parsedBody)
|
|
244
|
-
upstreamBase = String(lease.nodeBaseUrl || '').replace(/\/+$/, '')
|
|
245
|
-
req.headers['x-hs-session-id'] = sessionId
|
|
246
|
-
req.headers['x-hs-bridge-ticket'] = lease.ticket
|
|
247
|
-
req.headers['x-hs-node-id'] = lease.nodeId || ''
|
|
248
|
-
}
|
|
249
|
-
const upstreamUrl = `${upstreamBase}${url.pathname}${url.search || ''}`
|
|
250
|
-
const upstream = await fetch(upstreamUrl, {
|
|
251
|
-
method: req.method,
|
|
252
|
-
headers: buildUpstreamHeaders(req, config, body),
|
|
253
|
-
body: body.length > 0 ? body : undefined,
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
res.writeHead(upstream.status, {
|
|
257
|
-
'content-type': upstream.headers.get('content-type') || 'application/json; charset=utf-8',
|
|
258
|
-
'cache-control': upstream.headers.get('cache-control') || 'no-store',
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
if (upstream.body && typeof upstream.body.pipe === 'function') {
|
|
262
|
-
upstream.body.pipe(res)
|
|
263
|
-
return
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const text = await upstream.text()
|
|
267
|
-
res.end(text)
|
|
268
|
-
} catch (error) {
|
|
269
|
-
return sendJson(res, 500, { error: { message: error.message || 'Claude bridge error' } })
|
|
270
|
-
}
|
|
271
|
-
})
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function waitForBridge(port) {
|
|
275
|
-
for (let i = 0; i < 12; i++) {
|
|
276
|
-
const t0 = Date.now()
|
|
277
|
-
while (Date.now() - t0 < 500) {}
|
|
278
|
-
|
|
279
|
-
try {
|
|
280
|
-
execSync(
|
|
281
|
-
isWin
|
|
282
|
-
? `powershell -NonInteractive -Command "try{(Invoke-WebRequest -Uri http://127.0.0.1:${port}/health -TimeoutSec 1 -UseBasicParsing).StatusCode}catch{exit 1}"`
|
|
283
|
-
: `curl -sf http://127.0.0.1:${port}/health -o /dev/null --max-time 1`,
|
|
284
|
-
{ stdio: 'ignore', timeout: 3000 }
|
|
285
|
-
)
|
|
286
|
-
return true
|
|
287
|
-
} catch {}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return false
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function startBridge(args = parseArgs(process.argv.slice(2))) {
|
|
294
|
-
const config = readBridgeConfig(args.config)
|
|
295
|
-
const port = args.port || getConfiguredBridgePort(config)
|
|
296
|
-
const host = args.host || '127.0.0.1'
|
|
297
|
-
const server = createBridgeServer(args.config)
|
|
298
|
-
|
|
299
|
-
server.listen(port, host, () => {
|
|
300
|
-
process.stdout.write(`HolySheep Claude bridge listening on http://${host}:${port}\n`)
|
|
301
|
-
})
|
|
302
|
-
|
|
303
|
-
return server
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function spawnBridge(port = getConfiguredBridgePort()) {
|
|
307
|
-
if (waitForBridge(port)) return true
|
|
308
|
-
|
|
309
|
-
const scriptPath = path.join(__dirname, '..', 'index.js')
|
|
310
|
-
const child = spawn(process.execPath, [scriptPath, 'claude-bridge', '--port', String(port)], {
|
|
311
|
-
detached: true,
|
|
312
|
-
stdio: 'ignore',
|
|
313
|
-
})
|
|
314
|
-
child.unref()
|
|
315
|
-
|
|
316
|
-
return waitForBridge(port)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
module.exports = {
|
|
320
|
-
CLAUDE_BRIDGE_CONFIG_FILE,
|
|
321
|
-
DEFAULT_BRIDGE_PORT,
|
|
322
|
-
createBridgeServer,
|
|
323
|
-
getBridgeBaseUrl,
|
|
324
|
-
getConfiguredBridgePort,
|
|
325
|
-
parseArgs,
|
|
326
|
-
readBridgeConfig,
|
|
327
|
-
spawnBridge,
|
|
328
|
-
startBridge,
|
|
329
|
-
waitForBridge,
|
|
330
|
-
writeBridgeConfig,
|
|
331
|
-
}
|