@simonyea/holysheep-cli 2.1.38 → 2.1.41

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.
Files changed (45) hide show
  1. package/dist/configure-worker.js +4491 -0
  2. package/dist/index.js +9591 -0
  3. package/dist/process-proxy-inject.js +117 -0
  4. package/package.json +19 -6
  5. package/.gitea/workflows/sanity.yml +0 -125
  6. package/scripts/check-tarball-size.js +0 -44
  7. package/src/commands/balance.js +0 -57
  8. package/src/commands/claude-proxy.js +0 -248
  9. package/src/commands/claude.js +0 -135
  10. package/src/commands/doctor.js +0 -282
  11. package/src/commands/login.js +0 -211
  12. package/src/commands/openclaw.js +0 -258
  13. package/src/commands/reset.js +0 -53
  14. package/src/commands/setup.js +0 -493
  15. package/src/commands/upgrade.js +0 -168
  16. package/src/commands/webui.js +0 -622
  17. package/src/index.js +0 -226
  18. package/src/tools/aider.js +0 -78
  19. package/src/tools/antigravity.js +0 -42
  20. package/src/tools/claude-code.js +0 -228
  21. package/src/tools/claude-process-proxy.js +0 -1030
  22. package/src/tools/codex.js +0 -254
  23. package/src/tools/continue.js +0 -146
  24. package/src/tools/cursor.js +0 -71
  25. package/src/tools/droid.js +0 -281
  26. package/src/tools/env-config.js +0 -185
  27. package/src/tools/gemini-cli.js +0 -82
  28. package/src/tools/hermes.js +0 -354
  29. package/src/tools/index.js +0 -13
  30. package/src/tools/openclaw-bridge.js +0 -987
  31. package/src/tools/openclaw.js +0 -925
  32. package/src/tools/opencode.js +0 -227
  33. package/src/tools/process-proxy-inject.js +0 -142
  34. package/src/utils/config.js +0 -54
  35. package/src/utils/shell.js +0 -342
  36. package/src/utils/which.js +0 -63
  37. package/src/webui/aionui-runtime-fetcher.js +0 -429
  38. package/src/webui/aionui-runtime.js +0 -139
  39. package/src/webui/aionui-wrapper.js +0 -734
  40. package/src/webui/configure-worker.js +0 -67
  41. package/src/webui/server.js +0 -1566
  42. package/src/webui/workspace-runtime.js +0 -288
  43. package/src/webui/workspace-store.js +0 -325
  44. /package/{src/webui → dist}/index.html +0 -0
  45. /package/{src/tools → dist}/pty-hermes-wrapper.py +0 -0
@@ -1,925 +0,0 @@
1
- /**
2
- * OpenClaw 适配器 (v2 — 基于实测的正确配置格式)
3
- *
4
- * 正确方案:写入 HolySheep 的 OpenAI + Anthropic + MiniMax provider,
5
- * 默认模型固定为 GPT-5.4,同时保留 Claude / MiniMax 模型供 /model 切换。
6
- */
7
- const fs = require('fs')
8
- const path = require('path')
9
- const os = require('os')
10
- const { spawnSync, spawn, execSync } = require('child_process')
11
- const { commandExists } = require('../utils/which')
12
- const { BRIDGE_CONFIG_FILE } = require('./openclaw-bridge')
13
-
14
- const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw')
15
- const CONFIG_FILE = path.join(OPENCLAW_DIR, 'openclaw.json')
16
- const OPENCLAW_LAUNCH_AGENTS_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents')
17
- const OPENCLAW_GATEWAY_PLIST = path.join(OPENCLAW_LAUNCH_AGENTS_DIR, 'ai.openclaw.gateway.plist')
18
- const isWin = process.platform === 'win32'
19
- const DEFAULT_BRIDGE_PORT = 18788
20
- const DEFAULT_GATEWAY_PORT = 18789
21
- const MAX_PORT_SCAN = 40
22
- const OPENCLAW_DEFAULT_MODEL = 'gpt-5.4'
23
- const OPENCLAW_DEFAULT_CODEX_SPARK_MODEL = 'gpt-5.3-codex-spark'
24
- const OPENCLAW_DEFAULT_CLAUDE_MODEL = 'claude-sonnet-4-6'
25
- const OPENCLAW_DEFAULT_MINIMAX_MODEL = 'MiniMax-M2.7-highspeed'
26
- const OPENCLAW_PROVIDER_NAME = 'holysheep'
27
-
28
- /**
29
- * [HolySheep fork v2.1.38 / hs26] Atomic JSON write.
30
- *
31
- * Background: `~/.openclaw/` accumulated 30+ `openclaw.json.clobbered.*`
32
- * backup files (mtime span hours apart, content identical) — OpenClaw's
33
- * own config layer detected that two processes raced `writeFileSync`
34
- * against the same path and renamed the half-written file aside to avoid
35
- * corruption. Each race produced a backup.
36
- *
37
- * Root cause: `fs.writeFileSync(path, data)` is NOT atomic — it opens +
38
- * truncates + writes; a concurrent reader/writer can observe a partial
39
- * file or overwrite between truncation and final bytes. POSIX
40
- * `rename(tmp, final)` IS atomic on the same filesystem, so we:
41
- * 1. Write to `${final}.tmp.${pid}.${rand}`
42
- * 2. `fs.renameSync(tmp, final)`
43
- * Windows: `rename` fails if target exists → retry via copyFile + unlink.
44
- *
45
- * Never leaves the final path in a half-written state. If two procs race,
46
- * one's write wins atomically and the other's wins the next one; no
47
- * .clobbered.* files get produced by OpenClaw.
48
- */
49
- function atomicWriteJson(filePath, data) {
50
- const dir = path.dirname(filePath)
51
- fs.mkdirSync(dir, { recursive: true })
52
- const body = JSON.stringify(data, null, 2)
53
- const tmp = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 10)}`
54
- fs.writeFileSync(tmp, body, 'utf8')
55
- try {
56
- fs.renameSync(tmp, filePath)
57
- return
58
- } catch (renameErr) {
59
- // Windows: rename fails if target exists. Fall back to copy+unlink.
60
- // Also handles cross-device rename (EXDEV) in edge cases.
61
- if (process.platform === 'win32' || renameErr.code === 'EXDEV' || renameErr.code === 'EEXIST') {
62
- try {
63
- fs.copyFileSync(tmp, filePath)
64
- try { fs.unlinkSync(tmp) } catch {}
65
- return
66
- } catch (copyErr) {
67
- try { fs.unlinkSync(tmp) } catch {}
68
- throw copyErr
69
- }
70
- }
71
- try { fs.unlinkSync(tmp) } catch {}
72
- throw renameErr
73
- }
74
- }
75
-
76
- /**
77
- * [HolySheep fork v2.1.38 / hs26] Prune stale OpenClaw config backup files.
78
- *
79
- * Removes `~/.openclaw/openclaw.json.clobbered.*` older than 7 days. These
80
- * are produced by OpenClaw itself when a racy `writeFileSync` is detected,
81
- * but once our atomicWriteJson is in place no new ones should appear — this
82
- * cleanup just garbage-collects the historical accumulation without touching
83
- * `*.last-good` / `*.bak` / `*.pre-*` which users or other tooling may need.
84
- *
85
- * Safe: scoped to ONLY the exact glob `openclaw.json.clobbered.*` in the
86
- * known OpenClaw dir. No-op if dir doesn't exist or user has no matching
87
- * files. Best-effort (each unlink wrapped in try) so a single locked file
88
- * won't block the rest of configure().
89
- */
90
- function pruneClobberedBackups(maxAgeMs = 7 * 24 * 3600 * 1000) {
91
- try {
92
- if (!fs.existsSync(OPENCLAW_DIR)) return { scanned: 0, removed: 0 }
93
- const entries = fs.readdirSync(OPENCLAW_DIR)
94
- const cutoff = Date.now() - maxAgeMs
95
- let scanned = 0
96
- let removed = 0
97
- for (const name of entries) {
98
- if (!/^openclaw\.json\.clobbered\./.test(name)) continue
99
- scanned++
100
- const abs = path.join(OPENCLAW_DIR, name)
101
- try {
102
- const st = fs.statSync(abs)
103
- if (st.mtimeMs < cutoff) {
104
- fs.unlinkSync(abs)
105
- removed++
106
- }
107
- } catch {
108
- // locked / race — skip
109
- }
110
- }
111
- return { scanned, removed }
112
- } catch {
113
- return { scanned: 0, removed: 0 }
114
- }
115
- }
116
-
117
-
118
- function getOpenClawBinaryCandidates() {
119
- return isWin ? ['openclaw.cmd', 'openclaw'] : ['openclaw']
120
- }
121
-
122
- function getBinaryRunner() {
123
- return isWin
124
- ? { cmd: 'openclaw.cmd', argsPrefix: [], shell: true, label: 'openclaw', via: 'binary' }
125
- : { cmd: 'openclaw', argsPrefix: [], shell: false, label: 'openclaw', via: 'binary' }
126
- }
127
-
128
- function hasOpenClawBinary() {
129
- return getOpenClawBinaryCandidates().some((cmd) => commandExists(cmd))
130
- }
131
-
132
- function hasNpx() {
133
- return commandExists('npx')
134
- }
135
-
136
- function getRunner(preferNpx = false) {
137
- const binaryRunner = hasOpenClawBinary() ? getBinaryRunner() : null
138
-
139
- if (!preferNpx && hasOpenClawBinary()) {
140
- return binaryRunner
141
- }
142
- if (hasNpx()) {
143
- return { cmd: 'npx', argsPrefix: ['openclaw'], shell: isWin, label: 'npx openclaw', via: 'npx' }
144
- }
145
- if (binaryRunner) {
146
- return binaryRunner
147
- }
148
- return null
149
- }
150
-
151
- function runWithRunner(runner, args, opts = {}) {
152
- return spawnSync(runner.cmd, [...runner.argsPrefix, ...args], {
153
- shell: runner.shell,
154
- timeout: opts.timeout || 30000,
155
- stdio: opts.stdio || 'pipe',
156
- encoding: 'utf8',
157
- windowsHide: true,
158
- })
159
- }
160
-
161
- function normalizeVersionOutput(text) {
162
- return firstLine(text).replace(/^openclaw\s+/i, '').trim()
163
- }
164
-
165
- function probeRunner(runner, timeout) {
166
- const result = runWithRunner(runner, ['--version'], { timeout })
167
- if (result.error || result.status !== 0) return null
168
-
169
- const version = normalizeVersionOutput(result.stdout || result.stderr || '')
170
- return version || null
171
- }
172
-
173
- /** 运行 openclaw CLI(优先全局命令,可切换到 npx 回退) */
174
- function runOpenClaw(args, opts = {}) {
175
- const runner = getRunner(Boolean(opts.preferNpx))
176
- if (!runner) {
177
- return { status: 1, stdout: '', stderr: 'OpenClaw CLI not found' }
178
- }
179
-
180
- return runWithRunner(runner, args, opts)
181
- }
182
-
183
- function spawnOpenClaw(args, opts = {}) {
184
- const runner = getRunner(Boolean(opts.preferNpx))
185
- if (!runner) throw new Error('OpenClaw CLI not found')
186
-
187
- const { preferNpx: _preferNpx, ...spawnOpts } = opts
188
- return spawn(runner.cmd, [...runner.argsPrefix, ...args], {
189
- shell: runner.shell,
190
- windowsHide: true,
191
- ...spawnOpts,
192
- })
193
- }
194
-
195
- function getPreferredRuntime() {
196
- return module.exports._useNpx || !hasOpenClawBinary()
197
- }
198
-
199
- function firstLine(text) {
200
- return String(text || '').trim().split('\n')[0] || ''
201
- }
202
-
203
- function getOpenClawVersion(preferNpx = false) {
204
- const runner = getRunner(preferNpx)
205
- if (!runner) return null
206
- return probeRunner(runner, preferNpx ? 60000 : 15000)
207
- }
208
-
209
- function detectRuntime() {
210
- const preferNpx = getPreferredRuntime()
211
- const runnerOrder = preferNpx ? [getRunner(true), getRunner(false)] : [getRunner(false), getRunner(true)]
212
- const seen = new Set()
213
-
214
- for (const runner of runnerOrder) {
215
- if (!runner) continue
216
- const key = `${runner.via}:${runner.cmd}:${runner.argsPrefix.join(' ')}`
217
- if (seen.has(key)) continue
218
- seen.add(key)
219
-
220
- const version = probeRunner(runner, runner.via === 'npx' ? 60000 : 15000)
221
- if (version) {
222
- return {
223
- available: true,
224
- via: runner.via,
225
- command: runner.label,
226
- version,
227
- }
228
- }
229
- }
230
-
231
- const fallbackRunner = getRunner(preferNpx)
232
- if (fallbackRunner) {
233
- return {
234
- available: false,
235
- via: fallbackRunner.via,
236
- command: fallbackRunner.label,
237
- version: null,
238
- }
239
- }
240
-
241
- return { available: false, via: null, command: null, version: null }
242
- }
243
-
244
- function readBridgeConfig() {
245
- try {
246
- if (fs.existsSync(BRIDGE_CONFIG_FILE)) {
247
- return JSON.parse(fs.readFileSync(BRIDGE_CONFIG_FILE, 'utf8'))
248
- }
249
- } catch {}
250
- return {}
251
- }
252
-
253
- function writeBridgeConfig(data) {
254
- // [HolySheep fork v2.1.38 / hs26] Atomic write — see atomicWriteJson.
255
- atomicWriteJson(BRIDGE_CONFIG_FILE, data)
256
- }
257
-
258
- function updateBridgeConfig(patch) {
259
- const current = readBridgeConfig()
260
- writeBridgeConfig({
261
- ...current,
262
- ...patch,
263
- })
264
- }
265
-
266
- function getConfiguredBridgePort(config = readBridgeConfig()) {
267
- const port = Number(config?.port)
268
- return Number.isInteger(port) && port > 0 ? port : DEFAULT_BRIDGE_PORT
269
- }
270
-
271
- function getBridgeBaseUrl(port = getConfiguredBridgePort()) {
272
- return `http://127.0.0.1:${port}/v1`
273
- }
274
-
275
- function waitForBridge(port) {
276
- for (let i = 0; i < 10; i++) {
277
- const t0 = Date.now()
278
- while (Date.now() - t0 < 500) {}
279
-
280
- try {
281
- execSync(
282
- isWin
283
- ? `powershell -NonInteractive -Command "try{(Invoke-WebRequest -Uri http://127.0.0.1:${port}/health -TimeoutSec 1 -UseBasicParsing).StatusCode}catch{exit 1}"`
284
- : `curl -sf http://127.0.0.1:${port}/health -o /dev/null --max-time 1`,
285
- { stdio: 'ignore', timeout: 3000 }
286
- )
287
- return true
288
- } catch {}
289
- }
290
-
291
- return false
292
- }
293
-
294
- function stopBridge() {
295
- // 杀掉所有已有的 bridge 进程,避免重新配置时端口冲突
296
- try {
297
- if (isWin) {
298
- execSync('taskkill /F /FI "WINDOWTITLE eq openclaw-bridge*" 2>nul', { shell: true, stdio: 'ignore' })
299
- // 按命令行匹配
300
- const out = execSync('wmic process where "commandline like \'%openclaw-bridge%\'" get processid', { shell: true, stdio: 'pipe', encoding: 'utf8' })
301
- const pids = out.match(/\d+/g)
302
- if (pids) pids.forEach(pid => { try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }) } catch {} })
303
- } else {
304
- execSync("pkill -f 'openclaw-bridge' 2>/dev/null || true", { shell: true, stdio: 'ignore' })
305
- }
306
- } catch {}
307
- }
308
-
309
- function startBridge(port) {
310
- if (waitForBridge(port)) return true
311
-
312
- const scriptPath = path.join(__dirname, '..', 'index.js')
313
- // Windows: use shell+node command to avoid ERROR_FILE_NOT_FOUND with process.execPath
314
- // (Windows Store / nvm paths can be unresolvable when spawning detached)
315
- // Windows: use 'node' (resolved via PATH by CreateProcess) without shell:true.
316
- // shell:true spawns cmd.exe /c which exits after the command, breaking detach.
317
- const spawnCmd = isWin ? 'node' : process.execPath
318
- const spawnOpts = { detached: true, stdio: 'ignore', windowsHide: true }
319
- const child = spawn(spawnCmd, [scriptPath, 'openclaw-bridge', '--port', String(port)], spawnOpts)
320
- child.unref()
321
- return waitForBridge(port)
322
- }
323
-
324
- function getBridgeCommand(port = getConfiguredBridgePort()) {
325
- return `hs openclaw-bridge --port ${port}`
326
- }
327
-
328
- function pickPrimaryModel(primaryModel, selectedModels) {
329
- const models = Array.isArray(selectedModels) ? selectedModels : []
330
- return primaryModel || models[0] || OPENCLAW_DEFAULT_MODEL
331
- }
332
-
333
- function readConfig() {
334
- try {
335
- if (fs.existsSync(CONFIG_FILE)) {
336
- const raw = fs.readFileSync(CONFIG_FILE, 'utf8')
337
- try {
338
- return JSON.parse(raw)
339
- } catch {
340
- // 兼容极少数带注释的配置,但不要误伤 https:// 之类的 URL
341
- return JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''))
342
- }
343
- }
344
- } catch {}
345
- return {}
346
- }
347
-
348
- function getConfiguredGatewayPort(config = readConfig()) {
349
- const port = Number(config?.gateway?.port)
350
- return Number.isInteger(port) && port > 0 ? port : DEFAULT_GATEWAY_PORT
351
- }
352
-
353
- function getConfiguredPrimaryModel(config = readConfig()) {
354
- return config?.agents?.defaults?.model?.primary || ''
355
- }
356
-
357
- function normalizePrimaryModelRef(ref = getConfiguredPrimaryModel()) {
358
- const value = String(ref || '')
359
- const parts = value.split('/')
360
- return parts[parts.length - 1] || ''
361
- }
362
-
363
- function getPrimaryModelRoute(modelRef = getConfiguredPrimaryModel()) {
364
- const model = normalizePrimaryModelRef(modelRef)
365
- if (model.startsWith('gpt-')) {
366
- return 'openai /chat/completions'
367
- }
368
- if (model.startsWith('claude-')) {
369
- return 'anthropic /v1/messages'
370
- }
371
- if (model.startsWith('MiniMax-')) {
372
- return 'minimax /minimax/v1/messages'
373
- }
374
- return 'unknown'
375
- }
376
-
377
- function isPortInUse(port) {
378
- try {
379
- if (isWin) {
380
- const out = execSync(`netstat -ano | findstr :${port}`, { shell: true, stdio: 'pipe', encoding: 'utf8' })
381
- return out.trim().length > 0
382
- }
383
-
384
- execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN`, { shell: true, stdio: 'ignore' })
385
- return true
386
- } catch {
387
- return false
388
- }
389
- }
390
-
391
- function listPortListeners(port) {
392
- try {
393
- if (isWin) {
394
- const out = execSync(`netstat -ano | findstr :${port}`, { shell: true, stdio: 'pipe', encoding: 'utf8' })
395
- return out
396
- .trim()
397
- .split('\n')
398
- .filter(Boolean)
399
- .map((line) => {
400
- const parts = line.trim().split(/\s+/)
401
- return { pid: parts[parts.length - 1], command: 'pid', detail: parts[1] || '' }
402
- })
403
- }
404
-
405
- const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN`, { shell: true, stdio: 'pipe', encoding: 'utf8' })
406
- return out
407
- .trim()
408
- .split('\n')
409
- .slice(1)
410
- .filter(Boolean)
411
- .map((line) => {
412
- const parts = line.trim().split(/\s+/)
413
- return {
414
- command: parts[0] || 'unknown',
415
- pid: parts[1] || '?',
416
- detail: parts[parts.length - 1] || '',
417
- }
418
- })
419
- } catch {
420
- return []
421
- }
422
- }
423
-
424
- function findAvailableGatewayPort(startPort = DEFAULT_GATEWAY_PORT, excludedPorts = []) {
425
- const excluded = new Set((excludedPorts || []).map((value) => Number(value)).filter((value) => Number.isInteger(value) && value > 0))
426
- for (let offset = 0; offset < MAX_PORT_SCAN; offset++) {
427
- const port = startPort + offset
428
- if (excluded.has(port)) continue
429
- if (!isPortInUse(port)) return port
430
- }
431
- return null
432
- }
433
-
434
- function getLaunchCommand(port = getConfiguredGatewayPort()) {
435
- const runtime = module.exports._lastRuntimeCommand || (hasOpenClawBinary() ? 'openclaw' : 'npx openclaw')
436
- return `${runtime} gateway --port ${port}`
437
- }
438
-
439
- function getDashboardCommand() {
440
- const runtime = module.exports._lastRuntimeCommand || (hasOpenClawBinary() ? 'openclaw' : 'npx openclaw')
441
- return `${runtime} dashboard --no-open`
442
- }
443
-
444
- function getDashboardUrlForPort(port) {
445
- return `http://127.0.0.1:${port}/`
446
- }
447
-
448
- function isNpxCachePath(value) {
449
- return /[\\/]_npx[\\/]/i.test(String(value || ''))
450
- }
451
-
452
- function readOpenClawLaunchAgent() {
453
- if (process.platform !== 'darwin') return null
454
- try {
455
- if (!fs.existsSync(OPENCLAW_GATEWAY_PLIST)) return null
456
- return fs.readFileSync(OPENCLAW_GATEWAY_PLIST, 'utf8')
457
- } catch {
458
- return null
459
- }
460
- }
461
-
462
- function parseLaunchAgentProgramArguments(content) {
463
- const match = String(content || '').match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/i)
464
- if (!match) return []
465
-
466
- return Array.from(match[1].matchAll(/<string>([\s\S]*?)<\/string>/g)).map((item) => item[1])
467
- }
468
-
469
- function getLaunchAgentDiagnosis() {
470
- const content = readOpenClawLaunchAgent()
471
- if (!content) {
472
- return { exists: false, path: OPENCLAW_GATEWAY_PLIST, unstable: false, programArguments: [] }
473
- }
474
-
475
- const programArguments = parseLaunchAgentProgramArguments(content)
476
- const unstableArg = programArguments.find(isNpxCachePath) || ''
477
-
478
- return {
479
- exists: true,
480
- path: OPENCLAW_GATEWAY_PLIST,
481
- unstable: Boolean(unstableArg),
482
- unstableArg,
483
- programArguments,
484
- }
485
- }
486
-
487
- function removeBrokenLaunchAgent() {
488
- const diagnosis = getLaunchAgentDiagnosis()
489
- if (!diagnosis.exists || !diagnosis.unstable) return false
490
-
491
- try {
492
- execSync(`launchctl bootout "gui/${process.getuid()}" "${diagnosis.path}"`, {
493
- shell: true,
494
- stdio: 'ignore',
495
- })
496
- } catch {}
497
-
498
- try {
499
- fs.unlinkSync(diagnosis.path)
500
- return true
501
- } catch {
502
- return false
503
- }
504
- }
505
-
506
- function buildModelEntry(id) {
507
- return {
508
- id,
509
- name: `${id} (HolySheep)`,
510
- reasoning: false,
511
- input: ['text'],
512
- contextWindow: 200000,
513
- maxTokens: id.startsWith('gpt-') ? 8192 : 16000,
514
- }
515
- }
516
-
517
- function normalizeRequestedModels(selectedModels) {
518
- const requestedModels = Array.isArray(selectedModels) && selectedModels.length > 0
519
- ? [...selectedModels]
520
- : [OPENCLAW_DEFAULT_MODEL, OPENCLAW_DEFAULT_CODEX_SPARK_MODEL, OPENCLAW_DEFAULT_CLAUDE_MODEL, OPENCLAW_DEFAULT_MINIMAX_MODEL]
521
-
522
- if (!requestedModels.includes(OPENCLAW_DEFAULT_MODEL)) requestedModels.unshift(OPENCLAW_DEFAULT_MODEL)
523
- return Array.from(new Set(requestedModels))
524
- }
525
-
526
- function buildManagedPlan(baseUrlBridge, apiKey, primaryModel, selectedModels) {
527
- const requestedModels = normalizeRequestedModels(selectedModels)
528
- const managedModelRefs = requestedModels.map((model) => `${OPENCLAW_PROVIDER_NAME}/${model}`)
529
- const fallbackPrimaryModel = pickPrimaryModel(primaryModel, requestedModels)
530
- const primaryRef = managedModelRefs.includes(`${OPENCLAW_PROVIDER_NAME}/${fallbackPrimaryModel}`)
531
- ? `${OPENCLAW_PROVIDER_NAME}/${fallbackPrimaryModel}`
532
- : managedModelRefs[0] || `${OPENCLAW_PROVIDER_NAME}/${OPENCLAW_DEFAULT_MODEL}`
533
-
534
- return {
535
- providers: {
536
- [OPENCLAW_PROVIDER_NAME]: {
537
- baseUrl: baseUrlBridge,
538
- apiKey,
539
- api: 'openai-completions',
540
- models: requestedModels.map(buildModelEntry),
541
- },
542
- },
543
- managedModelRefs,
544
- models: requestedModels,
545
- primaryRef,
546
- }
547
- }
548
-
549
- function isHolySheepProvider(provider) {
550
- return typeof provider?.baseUrl === 'string' && (
551
- provider.baseUrl.includes('api.holysheep.ai') ||
552
- provider.baseUrl.includes('127.0.0.1')
553
- )
554
- }
555
-
556
- function writeManagedConfig(baseConfig, bridgeBaseUrl, apiKey, primaryModel, selectedModels, gatewayPort) {
557
- fs.mkdirSync(OPENCLAW_DIR, { recursive: true })
558
-
559
- const plan = buildManagedPlan(bridgeBaseUrl, apiKey, primaryModel, selectedModels)
560
- const existingProviders = baseConfig?.models?.providers || {}
561
- const managedProviderIds = Object.entries(existingProviders)
562
- .filter(([providerId, provider]) => providerId === OPENCLAW_PROVIDER_NAME || isHolySheepProvider(provider))
563
- .map(([providerId]) => providerId)
564
-
565
- const preservedProviders = Object.fromEntries(
566
- Object.entries(existingProviders).filter(([, provider]) => !isHolySheepProvider(provider))
567
- )
568
-
569
- const existingModelMap = baseConfig?.agents?.defaults?.models || {}
570
- const preservedModelMap = Object.fromEntries(
571
- Object.entries(existingModelMap).filter(([ref]) => {
572
- return !managedProviderIds.some((providerId) => ref.startsWith(`${providerId}/`))
573
- })
574
- )
575
-
576
- const managedModelMap = Object.fromEntries(plan.managedModelRefs.map((ref) => [ref, {}]))
577
-
578
- const nextConfig = {
579
- ...baseConfig,
580
- models: {
581
- ...(baseConfig.models || {}),
582
- mode: 'merge',
583
- providers: {
584
- ...preservedProviders,
585
- ...plan.providers,
586
- },
587
- },
588
- agents: {
589
- ...(baseConfig.agents || {}),
590
- defaults: {
591
- ...(baseConfig.agents?.defaults || {}),
592
- model: {
593
- ...(baseConfig.agents?.defaults?.model || {}),
594
- primary: plan.primaryRef,
595
- },
596
- models: {
597
- ...preservedModelMap,
598
- ...managedModelMap,
599
- },
600
- },
601
- },
602
- gateway: {
603
- ...(baseConfig.gateway || {}),
604
- mode: 'local',
605
- port: gatewayPort,
606
- bind: 'loopback',
607
- auth: {
608
- ...(baseConfig.gateway?.auth || {}),
609
- mode: 'none',
610
- },
611
- },
612
- }
613
-
614
- // [HolySheep fork v2.1.38 / hs26] Atomic write — prevents the
615
- // openclaw.json.clobbered.* pile-up from racing writeFileSync.
616
- atomicWriteJson(CONFIG_FILE, nextConfig)
617
- return plan
618
- }
619
-
620
- function _disableGatewayAuth(preferNpx = false) {
621
- try {
622
- runOpenClaw(['config', 'set', 'gateway.auth.mode', 'none'], { preferNpx })
623
- } catch {}
624
- }
625
-
626
- function _installGatewayService(port, preferNpx = false) {
627
- if (preferNpx) return false
628
-
629
- const result = runOpenClaw(['gateway', 'install', '--force', '--port', String(port)], {
630
- preferNpx,
631
- timeout: 60000,
632
- })
633
- return result.status === 0
634
- }
635
-
636
- function _startGateway(port, preferNpx = false, preferService = true) {
637
- const serviceResult = preferService
638
- ? runOpenClaw(['gateway', 'start'], { preferNpx, timeout: 20000 })
639
- : { status: 1 }
640
-
641
- let directChild = null
642
-
643
- if (serviceResult.status !== 0) {
644
- directChild = spawnOpenClaw(['gateway', '--port', String(port)], {
645
- preferNpx,
646
- detached: true,
647
- stdio: 'ignore',
648
- })
649
- directChild.unref()
650
- }
651
-
652
- for (let i = 0; i < 8; i++) {
653
- const t0 = Date.now()
654
- while (Date.now() - t0 < 1000) {}
655
-
656
- try {
657
- execSync(
658
- isWin
659
- ? `powershell -NonInteractive -Command "try{(Invoke-WebRequest -Uri http://127.0.0.1:${port}/ -TimeoutSec 1 -UseBasicParsing).StatusCode}catch{exit 1}"`
660
- : `curl -sf http://127.0.0.1:${port}/ -o /dev/null --max-time 1`,
661
- { stdio: 'ignore', timeout: 3000 }
662
- )
663
- return {
664
- ok: true,
665
- pid: directChild?.pid || null,
666
- mode: directChild ? 'direct-process' : 'daemon',
667
- }
668
- } catch {}
669
- }
670
-
671
- return {
672
- ok: false,
673
- pid: directChild?.pid || null,
674
- mode: directChild ? 'direct-process' : 'daemon',
675
- }
676
- }
677
-
678
- function getDashboardUrl(port, preferNpx = false) {
679
- const result = runOpenClaw(['dashboard', '--no-open'], {
680
- preferNpx,
681
- timeout: preferNpx ? 60000 : 20000,
682
- })
683
- if (result.status === 0) {
684
- const output = String(result.stdout || '')
685
- const match = output.match(/Dashboard URL:\s*(\S+)/) || output.match(/(https?:\/\/\S+)/)
686
- if (match) return match[1]
687
- }
688
- return getDashboardUrlForPort(port)
689
- }
690
-
691
- module.exports = {
692
- name: 'OpenClaw',
693
- id: 'openclaw',
694
-
695
- checkInstalled() {
696
- return detectRuntime().available
697
- },
698
-
699
- detectRuntime,
700
-
701
- getVersion() {
702
- return detectRuntime().version
703
- },
704
-
705
- isConfigured() {
706
- const cfg = readConfig()
707
- const hasProvider = cfg?.models?.providers?.[OPENCLAW_PROVIDER_NAME]?.baseUrl?.includes('127.0.0.1')
708
- const bridge = readBridgeConfig()
709
- return Boolean(hasProvider && bridge?.apiKey)
710
- },
711
-
712
- configure(apiKey, baseUrlAnthropic, baseUrlOpenAI, primaryModel, selectedModels) {
713
- const chalk = require('chalk')
714
- console.log(chalk.gray('\n ⚙️ 正在配置 OpenClaw...'))
715
-
716
- // [HolySheep fork v2.1.38 / hs26] Garbage-collect stale OpenClaw
717
- // backup files from pre-atomic-write builds. Scoped to exact glob
718
- // `openclaw.json.clobbered.*` older than 7 days — leaves .last-good
719
- // and .bak files alone.
720
- try {
721
- const pruned = pruneClobberedBackups()
722
- if (pruned.removed > 0) {
723
- console.log(chalk.gray(` → 已清理 ${pruned.removed} 个过期的 OpenClaw 配置备份(>7 天的 .clobbered.* 文件)`))
724
- }
725
- } catch {}
726
-
727
- const runtime = detectRuntime()
728
- if (!runtime.available) {
729
- throw new Error('未检测到 OpenClaw;请先全局安装,或确保 npx 可用')
730
- }
731
- this._lastRuntimeCommand = runtime.command
732
- this._lastRuntimeVia = runtime.via
733
-
734
- // 重新配置前先停掉旧的 Bridge 和 Gateway,释放端口
735
- console.log(chalk.gray(' → 停止旧的 Bridge 和 Gateway...'))
736
- stopBridge()
737
- runOpenClaw(['gateway', 'stop'], { preferNpx: runtime.via === 'npx' })
738
- // 等端口释放
739
- const t0 = Date.now()
740
- while (Date.now() - t0 < 1000) {}
741
-
742
- if (runtime.via === 'npx' && removeBrokenLaunchAgent()) {
743
- console.log(chalk.gray(' → 已清理旧的 OpenClaw 守护进程配置(失效的 npx 缓存路径)'))
744
- }
745
-
746
- const resolvedPrimaryModel = pickPrimaryModel(primaryModel, selectedModels)
747
- const gatewayPort = findAvailableGatewayPort(DEFAULT_GATEWAY_PORT)
748
- if (!gatewayPort) {
749
- throw new Error(`找不到可用端口(已检查 ${DEFAULT_GATEWAY_PORT}-${DEFAULT_GATEWAY_PORT + MAX_PORT_SCAN - 1})`)
750
- }
751
- this._lastGatewayPort = gatewayPort
752
-
753
- const bridgePort = findAvailableGatewayPort(DEFAULT_BRIDGE_PORT, [gatewayPort])
754
- if (!bridgePort) {
755
- throw new Error(`找不到可用桥接端口(已检查 ${DEFAULT_BRIDGE_PORT}-${DEFAULT_BRIDGE_PORT + MAX_PORT_SCAN - 1})`)
756
- }
757
- this._lastBridgePort = bridgePort
758
-
759
- writeBridgeConfig({
760
- port: bridgePort,
761
- host: '127.0.0.1',
762
- apiKey,
763
- baseUrlAnthropic,
764
- baseUrlOpenAI,
765
- models: normalizeRequestedModels(selectedModels),
766
- gatewayPort,
767
- gatewayHost: '127.0.0.1',
768
- gatewayPid: null,
769
- gatewayLaunchMode: null,
770
- watchdog: {
771
- enabled: true,
772
- intervalMs: 3000,
773
- failureThreshold: 3,
774
- startupGraceMs: 30000,
775
- requestTimeoutMs: 1500,
776
- },
777
- })
778
-
779
- console.log(chalk.gray(' → 正在启动 HolySheep Bridge...'))
780
- if (!startBridge(bridgePort)) {
781
- throw new Error('HolySheep OpenClaw Bridge 启动失败')
782
- }
783
- const bridgeBaseUrl = getBridgeBaseUrl(bridgePort)
784
-
785
- if (gatewayPort !== DEFAULT_GATEWAY_PORT) {
786
- console.log(chalk.yellow(` ⚠️ 端口 ${DEFAULT_GATEWAY_PORT} 已占用,自动切换到 ${gatewayPort}`))
787
- const listeners = listPortListeners(DEFAULT_GATEWAY_PORT)
788
- if (listeners.length) {
789
- const summary = listeners
790
- .slice(0, 2)
791
- .map((item) => `${item.command}(${item.pid})`)
792
- .join(', ')
793
- console.log(chalk.gray(` 占用进程: ${summary}`))
794
- }
795
- }
796
-
797
- try { fs.unlinkSync(CONFIG_FILE) } catch {}
798
-
799
- console.log(chalk.gray(' → 写入配置...'))
800
- const onboardArgs = [
801
- 'onboard',
802
- '--non-interactive',
803
- '--accept-risk',
804
- '--auth-choice', 'custom-api-key',
805
- '--custom-base-url', bridgeBaseUrl,
806
- '--custom-api-key', apiKey,
807
- '--custom-model-id', resolvedPrimaryModel.replace(/\[.*\]/, ''),
808
- '--custom-compatibility', 'openai',
809
- '--gateway-port', String(gatewayPort),
810
- ]
811
- if (runtime.via !== 'npx') onboardArgs.push('--install-daemon')
812
-
813
- const result = runOpenClaw(onboardArgs, { preferNpx: runtime.via === 'npx' })
814
-
815
- if (result.status !== 0) {
816
- console.log(chalk.yellow(' ⚠️ onboard 失败,使用备用配置...'))
817
- }
818
-
819
- const plan = writeManagedConfig(
820
- result.status === 0 ? readConfig() : {},
821
- bridgeBaseUrl,
822
- apiKey,
823
- resolvedPrimaryModel,
824
- selectedModels,
825
- gatewayPort,
826
- )
827
-
828
- _disableGatewayAuth(runtime.via === 'npx')
829
- const serviceReady = _installGatewayService(gatewayPort, runtime.via === 'npx')
830
- if (runtime.via === 'npx') {
831
- console.log(chalk.gray(' → 当前仅检测到 npx openclaw,跳过 daemon 安装,改为直接启动 Gateway 进程'))
832
- }
833
-
834
- console.log(chalk.gray(' → 正在启动 Gateway...'))
835
- const gatewayState = _startGateway(gatewayPort, runtime.via === 'npx', serviceReady)
836
- updateBridgeConfig({
837
- gatewayPort,
838
- gatewayPid: gatewayState.pid,
839
- gatewayLaunchMode: gatewayState.mode,
840
- gatewayStartedAt: new Date().toISOString(),
841
- })
842
-
843
- if (gatewayState.ok) {
844
- console.log(chalk.green(' ✓ OpenClaw Gateway 已启动'))
845
- } else {
846
- console.log(chalk.yellow(' ⚠️ Gateway 未就绪;当前不要打开 about:blank 或裸浏览器壳窗口'))
847
- }
848
-
849
- const dashUrl = gatewayState.ok ? getDashboardUrl(gatewayPort, runtime.via === 'npx') : getDashboardUrlForPort(gatewayPort)
850
- console.log(chalk.cyan('\n → 浏览器打开(推荐使用此地址):'))
851
- console.log(chalk.bold.cyan(` ${dashUrl}`))
852
- console.log(chalk.gray(` Bridge 地址: ${bridgeBaseUrl}`))
853
- console.log(chalk.gray(` 默认模型: ${plan.primaryRef || OPENCLAW_DEFAULT_MODEL}`))
854
- console.log(chalk.gray(` Gateway 启动方式: ${gatewayState.mode}`))
855
- console.log(chalk.gray(' 浏览器应直接打开 dashboard URL,不应停在 about:blank'))
856
- console.log(chalk.gray(' Bridge 会在检测到 OpenClaw Gateway 持续不可用后自动退出'))
857
- console.log(chalk.gray(' 如在 Windows 上打开裸 http://127.0.0.1:PORT/ 仍报 Unauthorized,请使用上面的 dashboard 地址'))
858
-
859
- return {
860
- file: CONFIG_FILE,
861
- hot: false,
862
- dashboardUrl: dashUrl,
863
- gatewayPort,
864
- gatewayReady: gatewayState.ok,
865
- gatewayLaunchMode: gatewayState.mode,
866
- launchCmd: getLaunchCommand(gatewayPort),
867
- }
868
- },
869
-
870
- reset() {
871
- // 先停进程再删配置,避免残留进程指向已删除的配置导致 timeout
872
- stopBridge()
873
- try { runOpenClaw(['gateway', 'stop'], { preferNpx: detectRuntime().via === 'npx' }) } catch {}
874
- try { fs.unlinkSync(CONFIG_FILE) } catch {}
875
- try { fs.unlinkSync(BRIDGE_CONFIG_FILE) } catch {}
876
- },
877
-
878
- getConfigPath() { return CONFIG_FILE },
879
- getBridgePort() { return getConfiguredBridgePort() },
880
- getGatewayPort() { return getConfiguredGatewayPort() },
881
- ensureBridgeRunning(port) {
882
- port = port || getConfiguredBridgePort()
883
- return startBridge(port)
884
- },
885
- ensureGatewayRunning(port) {
886
- port = port || getConfiguredGatewayPort()
887
- // 检查端口是否有进程在监听(不依赖 HTTP 状态码,500 也算运行中)
888
- try {
889
- const checkCmd = isWin
890
- ? `powershell -NonInteractive -Command "if(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue){exit 0}else{exit 1}"`
891
- : `curl -so /dev/null --max-time 1 http://127.0.0.1:${port}/ || lsof -iTCP:${port} -sTCP:LISTEN -t >/dev/null 2>&1`
892
- execSync(checkCmd, { stdio: 'ignore', timeout: 5000 })
893
- return true
894
- } catch {}
895
- // 未运行,启动它
896
- const preferNpx = !hasOpenClawBinary()
897
- const result = _startGateway(port, preferNpx, !preferNpx)
898
- return result.ok
899
- },
900
- getPrimaryModel() { return getConfiguredPrimaryModel() },
901
- getPrimaryModelRoute() { return getPrimaryModelRoute() },
902
- getPortListeners(port = getConfiguredGatewayPort()) { return listPortListeners(port) },
903
- getLaunchAgentDiagnosis,
904
- get hint() {
905
- return `Bridge + Gateway 已配置,默认模型为 ${getConfiguredPrimaryModel() || OPENCLAW_DEFAULT_MODEL}`
906
- },
907
- get launchSteps() {
908
- const bridgePort = getConfiguredBridgePort()
909
- const port = getConfiguredGatewayPort()
910
- return [
911
- { cmd: getBridgeCommand(bridgePort), note: '先启动 HolySheep OpenClaw Bridge' },
912
- { cmd: getLaunchCommand(port), note: '再启动 OpenClaw Gateway' },
913
- { cmd: getDashboardCommand(), note: '再生成/打开可直接连接的 Dashboard 地址(推荐)' },
914
- ]
915
- },
916
- get launchNote() {
917
- const runtime = module.exports._lastRuntimeVia === 'npx' ? '当前为 npx 模式,不安装常驻 daemon。' : ''
918
- return `🌐 请先启动 Bridge,再启动 Gateway;最后运行 ${getDashboardCommand()} ${runtime}`.trim()
919
- },
920
- installCmd: 'npm install -g openclaw@latest',
921
- docsUrl: 'https://docs.openclaw.ai',
922
- // [HolySheep fork v2.1.38 / hs26] Test-only exports.
923
- _atomicWriteJson: atomicWriteJson,
924
- _pruneClobberedBackups: pruneClobberedBackups,
925
- }