@simonyea/holysheep-cli 2.1.34 → 2.1.36

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.
@@ -0,0 +1,125 @@
1
+ name: CI sanity
2
+
3
+ # [HolySheep fork] Minimal Gitea-side CI for holysheep-cli.
4
+ # Mirrors the sanity check pattern from holysheep-webui.
5
+
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ pull_request:
10
+ branches: [main]
11
+
12
+ jobs:
13
+ sanity:
14
+ name: Sanity check (version + key files + invariants)
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - name: Checkout (host deploy-cache mode)
18
+ run: |
19
+ set -e
20
+ REPO_DIR="/opt/act_runner/deploy-cache/holysheep-cli"
21
+ BRANCH="${{ github.ref_name }}"
22
+ BRANCH="${BRANCH:-${GITHUB_REF_NAME:-main}}"
23
+ echo "branch: $BRANCH"
24
+ if [ -d "$REPO_DIR/.git" ]; then
25
+ cd "$REPO_DIR"
26
+ git fetch origin "$BRANCH"
27
+ git reset --hard "origin/$BRANCH"
28
+ else
29
+ mkdir -p "$REPO_DIR"
30
+ git clone --depth 1 -b "$BRANCH" \
31
+ http://simon:123123aa@localhost:3000/simon/holysheep-cli.git "$REPO_DIR"
32
+ fi
33
+ cd "$REPO_DIR"
34
+ echo "head: $(git rev-parse --short HEAD)"
35
+ echo "msg: $(git log -1 --pretty=%s)"
36
+
37
+ - name: Version format check
38
+ run: |
39
+ set -e
40
+ cd /opt/act_runner/deploy-cache/holysheep-cli
41
+ VER=$(node -e "console.log(require('./package.json').version)")
42
+ echo "version: $VER"
43
+ if ! echo "$VER" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
44
+ echo "::error::package.json version '$VER' does not match semver"
45
+ exit 1
46
+ fi
47
+ echo " ok: version $VER"
48
+
49
+ - name: Key source files present
50
+ run: |
51
+ set -e
52
+ cd /opt/act_runner/deploy-cache/holysheep-cli
53
+ for f in \
54
+ src/webui/aionui-wrapper.js \
55
+ src/webui/aionui-runtime-fetcher.js \
56
+ src/tools/claude-process-proxy.js \
57
+ src/tools/pty-hermes-wrapper.py \
58
+ src/tools/droid.js \
59
+ src/tools/hermes.js; do
60
+ if [ ! -f "$f" ]; then
61
+ echo "::error::required file missing: $f"
62
+ exit 1
63
+ fi
64
+ echo " ok: $f"
65
+ done
66
+
67
+ - name: Runtime fetcher URL/SHA/VERSION consistency
68
+ run: |
69
+ set -e
70
+ cd /opt/act_runner/deploy-cache/holysheep-cli
71
+ # The URL value is on the line AFTER 'const DEFAULT_RUNTIME_URL ='
72
+ # Use node to read the values reliably
73
+ URL_TAG=$(node -e "
74
+ const fs = require('fs');
75
+ const content = fs.readFileSync('src/webui/aionui-runtime-fetcher.js', 'utf8');
76
+ const urlMatch = content.match(/DEFAULT_RUNTIME_URL\s*=\s*[\r\n\s]*'[^']*holysheep-(hs\d+)[^']*'/);
77
+ const verMatch = content.match(/DEFAULT_RUNTIME_VERSION\s*=\s*'[^']*holysheep-(hs\d+)[^']*'/);
78
+ if (!urlMatch || !verMatch) { process.stderr.write('PARSE_FAIL'); process.exit(1); }
79
+ if (urlMatch[1] !== verMatch[1]) {
80
+ process.stderr.write('MISMATCH: URL=' + urlMatch[1] + ' VER=' + verMatch[1]);
81
+ process.exit(1);
82
+ }
83
+ console.log(urlMatch[1]);
84
+ " 2>&1)
85
+ echo "URL/VERSION tag: $URL_TAG"
86
+ if [ -z "$URL_TAG" ] || echo "$URL_TAG" | grep -q "FAIL\|MISMATCH"; then
87
+ echo "::error::DEFAULT_RUNTIME_URL and DEFAULT_RUNTIME_VERSION hs-tags mismatch: $URL_TAG"
88
+ exit 1
89
+ fi
90
+ echo " ok: URL and VERSION both reference $URL_TAG"
91
+
92
+ - name: Invariant grep (brand + proxy)
93
+ run: |
94
+ set -e
95
+ cd /opt/act_runner/deploy-cache/holysheep-cli
96
+
97
+ # Brand: wrapper must NOT use old [aionui-wrapper] prefix
98
+ if grep -q "\[aionui-wrapper\]" src/webui/aionui-wrapper.js; then
99
+ echo "::error::aionui-wrapper.js still contains [aionui-wrapper] log prefix (should be [holysheep-web])"
100
+ exit 1
101
+ fi
102
+ echo " ok: no [aionui-wrapper] prefix"
103
+
104
+ # mode field must be holysheep-webui
105
+ if ! grep -q "holysheep-webui" src/webui/aionui-wrapper.js; then
106
+ echo "::error::aionui-wrapper.js missing mode=holysheep-webui"
107
+ exit 1
108
+ fi
109
+ echo " ok: holysheep-webui mode present"
110
+
111
+ # claude-process-proxy: sanitizeClaudeClientHeaders must exist
112
+ if ! grep -q "sanitizeClaudeClientHeaders" src/tools/claude-process-proxy.js; then
113
+ echo "::error::claude-process-proxy.js missing sanitizeClaudeClientHeaders (UA-rewrite regression)"
114
+ exit 1
115
+ fi
116
+ echo " ok: sanitizeClaudeClientHeaders present"
117
+
118
+ # pty-hermes-wrapper: ICANON must be disabled
119
+ if ! grep -q "ICANON" src/tools/pty-hermes-wrapper.py; then
120
+ echo "::error::pty-hermes-wrapper.py missing ICANON disable (PTY truncation regression)"
121
+ exit 1
122
+ fi
123
+ echo " ok: ICANON disabled in pty-hermes-wrapper.py"
124
+
125
+ echo "all invariants passed"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonyea/holysheep-cli",
3
- "version": "2.1.34",
3
+ "version": "2.1.36",
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
  "scripts": {
6
6
  "test": "node tests/droid.test.js && node tests/workspace-store.test.js && node tests/runtime-stale-upgrade.test.js && node tests/hermes.test.js && node tests/preflight.test.js",
@@ -100,12 +100,34 @@ function createClientValidationErrorBody(message) {
100
100
  })
101
101
  }
102
102
 
103
+ // [HolySheep fork v2.1.36 / hs24] This is the ONLY trigger for switching to a
104
+ // different node (via fetchFreshLease({forceReassign:true}) → control plane
105
+ // drops Redis stickiness → chooseNode round-robins). It is NOT a generic retry
106
+ // trigger — same-node retries don't need this.
107
+ //
108
+ // Previous versions returned true for any 403/503, which made the CLI tear
109
+ // down 5-min session stickiness on every transient upstream hiccup (busy
110
+ // account, momentary scheduler capacity, ticket timing) and bounced the
111
+ // session node3↔node4↔node3 repeatedly. Bug surfaced as repeated lease.close
112
+ // log lines and ~5s stalls.
113
+ //
114
+ // Fix: only force-reassign when upstream sends an EXPLICIT "switch node"
115
+ // signal in the body. Other errors (403/503/ECONNREFUSED) stay on the same
116
+ // node — the retry loop will either succeed after upstream recovers, or fall
117
+ // through to direct-https fallback.
118
+ //
119
+ // IMPORTANT: we deliberately do NOT match bare `client_validation_error` —
120
+ // forwardViaNodeProxy wraps local forward errors (ECONNREFUSED etc.) in a
121
+ // client_validation_error body (see createClientValidationErrorBody), so
122
+ // matching that type alone would fire false positives on local-level errors
123
+ // that have nothing to do with node health. We only match specific messages
124
+ // that upstream emits when it explicitly wants the CLI to pick a new node:
125
+ // - homi-crs internalRoutes.js:1025 ("当前代理节点 X 不可用")
126
+ // - homi-crs auth.js / internalRoutes.js ("Claude Code 必须使用 hs claude 指令启动")
127
+ // - control plane server.js ("No active Claude relay nodes are available")
103
128
  function shouldRefreshLeaseAfterError(err) {
104
- const statusCode = Number(err?.statusCode || 0)
105
129
  const body = String(err?.body || err?.message || '')
106
- if (statusCode === 403 || statusCode === 503) return true
107
130
  return (
108
- body.includes('client_validation_error') ||
109
131
  body.includes('Claude Code 必须使用 hs claude 指令启动') ||
110
132
  body.includes('当前代理节点') ||
111
133
  body.includes('No active Claude relay nodes are available')
@@ -748,12 +770,27 @@ function createProcessProxyServer({ sessionId, configPath = CONFIG_PATH, allowAn
748
770
  await doForward(getCachedLease(sessionId), attempt)
749
771
  } else {
750
772
  const config = readConfig(configPath)
773
+ const prevLease = (() => { try { return leaseCache.get(sessionId) } catch { return null } })()
774
+ const forceReassign = shouldRefreshLeaseAfterError(lastError)
775
+ const retryReason = String(lastError?.body || lastError?.message || '').slice(0, 120)
776
+ // [HolySheep fork v2.1.36 / hs24] Emit structured breadcrumb so we
777
+ // can see whether the retry stayed on same node (sticky) or forced
778
+ // reassign. Previously invisible — made diagnosing node-bounce hard.
779
+ if (forceReassign) {
780
+ console.error(`[hs-claude-proxy] lease.force-reassign ${JSON.stringify({
781
+ sessionId, nodeId: prevLease?.nodeId || '', attempt, reason: retryReason,
782
+ })}`)
783
+ } else {
784
+ console.error(`[hs-claude-proxy] lease.sticky-hit ${JSON.stringify({
785
+ sessionId, nodeId: prevLease?.nodeId || '', attempt, retryReason,
786
+ })}`)
787
+ }
751
788
  leaseCache.delete(sessionId)
752
- if (shouldRefreshLeaseAfterError(lastError)) {
789
+ if (forceReassign) {
753
790
  await closeSession(configPath, sessionId)
754
791
  }
755
792
  const freshLease = await fetchFreshLease(config, sessionId, {
756
- forceReassign: shouldRefreshLeaseAfterError(lastError),
793
+ forceReassign,
757
794
  })
758
795
  await doForward(freshLease, attempt)
759
796
  }
@@ -875,12 +912,25 @@ function createProcessProxyServer({ sessionId, configPath = CONFIG_PATH, allowAn
875
912
  await doConnect(getCachedLease(sessionId))
876
913
  } else {
877
914
  const config = readConfig(configPath)
915
+ const prevLease = (() => { try { return leaseCache.get(sessionId) } catch { return null } })()
916
+ const forceReassign = shouldRefreshLeaseAfterError(lastError)
917
+ const retryReason = String(lastError?.body || lastError?.message || '').slice(0, 120)
918
+ // [HolySheep fork v2.1.36 / hs24] Same breadcrumb as HTTP retry loop.
919
+ if (forceReassign) {
920
+ console.error(`[hs-claude-proxy] lease.force-reassign ${JSON.stringify({
921
+ sessionId, nodeId: prevLease?.nodeId || '', attempt, reason: retryReason, path: 'CONNECT',
922
+ })}`)
923
+ } else {
924
+ console.error(`[hs-claude-proxy] lease.sticky-hit ${JSON.stringify({
925
+ sessionId, nodeId: prevLease?.nodeId || '', attempt, retryReason, path: 'CONNECT',
926
+ })}`)
927
+ }
878
928
  leaseCache.delete(sessionId)
879
- if (shouldRefreshLeaseAfterError(lastError)) {
929
+ if (forceReassign) {
880
930
  await closeSession(configPath, sessionId)
881
931
  }
882
932
  const freshLease = await fetchFreshLease(config, sessionId, {
883
- forceReassign: shouldRefreshLeaseAfterError(lastError),
933
+ forceReassign,
884
934
  })
885
935
  await doConnect(freshLease)
886
936
  }
@@ -70,6 +70,73 @@ function writeConfig(data) {
70
70
  fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8')
71
71
  }
72
72
 
73
+ /**
74
+ * [HolySheep fork v2.1.36 / hs25] OpenCode OAuth credential store paths.
75
+ * OpenCode >=1.3.0 keeps `opencode auth login` tokens in auth.json. If an
76
+ * Anthropic OAuth access token lives here, it OVERRIDES the config-file
77
+ * `baseURL`, routing requests straight to api.anthropic.com — bypassing the
78
+ * HolySheep relay. Symptom in the wild:
79
+ * "Third-party apps now draw from your extra usage, not your plan limits."
80
+ * which is Anthropic's plan-limit string, i.e. the request never hit us.
81
+ */
82
+ function getAuthFileCandidates() {
83
+ const home = os.homedir()
84
+ if (process.platform === 'win32') {
85
+ return [
86
+ path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'opencode', 'auth.json'),
87
+ path.join(home, '.local', 'share', 'opencode', 'auth.json'),
88
+ ]
89
+ }
90
+ const xdg = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share')
91
+ return [
92
+ path.join(xdg, 'opencode', 'auth.json'),
93
+ path.join(home, '.local', 'share', 'opencode', 'auth.json'),
94
+ path.join(home, 'Library', 'Application Support', 'opencode', 'auth.json'),
95
+ ]
96
+ }
97
+
98
+ /**
99
+ * Strip `anthropic` and `openai` OAuth tokens from OpenCode's auth.json.
100
+ * Uses `delete` on individual keys so other provider logins (github,
101
+ * deepseek, zhipu, …) survive. Deletes the whole file if empty after purge.
102
+ * Returns { touched: string[], keysAfter: string[] } for test assertions.
103
+ */
104
+ function purgeOauthAuth() {
105
+ const touched = []
106
+ let keysAfter = []
107
+ for (const file of getAuthFileCandidates()) {
108
+ try {
109
+ if (!fs.existsSync(file)) continue
110
+ const raw = fs.readFileSync(file, 'utf8')
111
+ let data
112
+ try { data = JSON.parse(raw) } catch { continue }
113
+ if (!data || typeof data !== 'object') continue
114
+
115
+ let changed = false
116
+ for (const key of ['anthropic', 'openai']) {
117
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
118
+ delete data[key]
119
+ changed = true
120
+ }
121
+ }
122
+ if (!changed) continue
123
+
124
+ touched.push(file)
125
+ const remaining = Object.keys(data)
126
+ keysAfter = remaining
127
+ if (remaining.length === 0) {
128
+ try { fs.unlinkSync(file) } catch {}
129
+ } else {
130
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8')
131
+ }
132
+ } catch {
133
+ // best-effort
134
+ }
135
+ }
136
+ return { touched, keysAfter }
137
+ }
138
+
139
+
73
140
  module.exports = {
74
141
  name: 'OpenCode',
75
142
  id: 'opencode',
@@ -84,6 +151,10 @@ module.exports = {
84
151
  )
85
152
  },
86
153
  configure(apiKey, baseUrlAnthropicNoV1, baseUrlOpenAI, primaryModel) {
154
+ // [HolySheep fork v2.1.36 / hs25] Strip any stale Anthropic/OpenAI OAuth
155
+ // tokens from OpenCode's auth.json BEFORE writing the config file, so
156
+ // the relay baseURL in opencode.json actually takes effect.
157
+ try { purgeOauthAuth() } catch {}
87
158
  const config = readConfig()
88
159
  if (!config.provider) config.provider = {}
89
160
 
@@ -141,10 +212,16 @@ module.exports = {
141
212
  delete config.provider.openai
142
213
  }
143
214
  writeConfig(config)
215
+ // [HolySheep fork v2.1.36 / hs25] Also purge OAuth tokens so a subsequent
216
+ // reconfigure doesn't silently fall back to direct-to-Anthropic.
217
+ try { purgeOauthAuth() } catch {}
144
218
  },
145
219
  getConfigPath() { return getConfigFile() },
146
220
  hint: '切换后重启 OpenCode 生效;配置文件: ~/.config/opencode/opencode.json',
147
221
  launchCmd: 'opencode',
148
222
  installCmd: 'brew install anomalyco/tap/opencode # 或: npm i -g opencode-ai@latest',
149
223
  docsUrl: 'https://opencode.ai',
224
+ // [HolySheep fork v2.1.36 / hs25] Exposed for tests — see tests/opencode-auth-purge.test.js
225
+ _purgeOauthAuth: purgeOauthAuth,
226
+ _getAuthFileCandidates: getAuthFileCandidates,
150
227
  }
@@ -116,6 +116,83 @@ function ensureWindowsUserPathHasNpmBin() {
116
116
  }
117
117
  }
118
118
 
119
+ /**
120
+ * [HolySheep fork v2.1.36 / hs25] Same mechanism as
121
+ * `ensureWindowsUserPathHasNpmBin`, but targets `%USERPROFILE%\.local\bin`.
122
+ *
123
+ * Background: Claude Code's official Windows installer (irm
124
+ * https://claude.ai/install.ps1 | iex) drops `claude.exe` under
125
+ * `C:\Users\<user>\.local\bin` and prints a warning that this directory
126
+ * is NOT on PATH. Our CLI Manager page then calls `commandExists('claude')`
127
+ * which runs `where claude` — finds nothing — and reports 未安装, even
128
+ * though the install succeeded.
129
+ *
130
+ * This helper:
131
+ * 1. Persists `.local\bin` into the USER-level Path (so new terminals
132
+ * see it too).
133
+ * 2. Appends it to `process.env.PATH` of the current process, so that
134
+ * the very next `commandExists('claude')` call in the same Node
135
+ * instance resolves correctly — no restart required.
136
+ *
137
+ * Returns a list of human-readable lines describing what was touched.
138
+ * No-op on non-Windows (returns []).
139
+ */
140
+ function ensureWindowsUserPathHasLocalBin() {
141
+ if (process.platform !== 'win32') return []
142
+
143
+ const userProfile = process.env.USERPROFILE || os.homedir()
144
+ if (!userProfile) return []
145
+
146
+ const localBin = path.join(userProfile, '.local', 'bin')
147
+
148
+ let currentPath = ''
149
+ try {
150
+ currentPath = execSync(
151
+ 'powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "[Environment]::GetEnvironmentVariable(\'Path\', \'User\')"',
152
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
153
+ ).trim()
154
+ } catch {
155
+ currentPath = process.env.PATH || ''
156
+ }
157
+
158
+ const parts = currentPath
159
+ .split(';')
160
+ .map((item) => item.trim())
161
+ .filter(Boolean)
162
+
163
+ const hasLocalBin = parts.some((item) => item.toLowerCase() === localBin.toLowerCase())
164
+
165
+ // Always also hot-patch the in-process PATH so the immediate follow-up
166
+ // `commandExists('claude')` check sees the new directory. This covers
167
+ // the (common) case where the persisted user PATH already has it but
168
+ // our own process was spawned before the PowerShell installer ran.
169
+ const procParts = (process.env.PATH || '').split(';').map((p) => p.trim()).filter(Boolean)
170
+ const inProcessHasIt = procParts.some((item) => item.toLowerCase() === localBin.toLowerCase())
171
+ if (!inProcessHasIt) {
172
+ process.env.PATH = [...procParts, localBin].join(';')
173
+ }
174
+
175
+ if (hasLocalBin) return inProcessHasIt ? [] : ['[当前进程 PATH] %USERPROFILE%\\.local\\bin']
176
+
177
+ const nextPath = [...parts, localBin].join(';')
178
+ try {
179
+ const escapedPath = nextPath.replace(/'/g, "''")
180
+ execSync(
181
+ `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "[Environment]::SetEnvironmentVariable('Path', '${escapedPath}', 'User')"`,
182
+ { stdio: 'ignore' }
183
+ )
184
+ return ['[用户 PATH] %USERPROFILE%\\.local\\bin']
185
+ } catch {
186
+ try {
187
+ const chalk = require('chalk')
188
+ console.warn(chalk.yellow(
189
+ ` ⚠️ 无法自动更新 PATH,请手动将以下路径加入系统环境变量 PATH:\n ${localBin}`
190
+ ))
191
+ } catch {}
192
+ return []
193
+ }
194
+ }
195
+
119
196
  function installWindowsCliShims() {
120
197
  if (process.platform !== 'win32') return []
121
198
 
@@ -259,6 +336,7 @@ module.exports = {
259
336
  writeEnvToShell,
260
337
  removeEnvFromShell,
261
338
  ensureWindowsUserPathHasNpmBin,
339
+ ensureWindowsUserPathHasLocalBin,
262
340
  installWindowsCliShims,
263
341
  removeWindowsUserEnvVars
264
342
  }
@@ -60,10 +60,10 @@ const VENDOR_DIR = path.join(__dirname, 'vendor', 'aionui')
60
60
  // new CLI release, the next `hs web` invocation on user machines will detect
61
61
  // the version drift and upgrade the cache in place.
62
62
  const DEFAULT_RUNTIME_URL =
63
- 'https://mail.holysheep.ai/app/cli/aionui-runtime-v1.9.18-holysheep-hs23.tar.gz'
63
+ 'https://mail.holysheep.ai/app/cli/aionui-runtime-v1.9.18-holysheep-hs25.tar.gz'
64
64
  const DEFAULT_RUNTIME_SHA256 =
65
- '96bcf47a06c4cdda206ed6d258edd9d347a587ef300c053e7679df469ccf8856'
66
- const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-hs23'
65
+ '9e2a47adf8a151c9ab6916d046ee9f268aa5f1589a961d1b0374b15508ed527b'
66
+ const DEFAULT_RUNTIME_VERSION = '1.9.18-holysheep-hs25'
67
67
 
68
68
  function isValidRuntimeDir(dir) {
69
69
  if (!dir) return false
@@ -677,9 +677,20 @@ async function handleToolInstall(req, res) {
677
677
  // non-Windows or when already present.
678
678
  if (ok && process.platform === 'win32') {
679
679
  try {
680
- const { ensureWindowsUserPathHasNpmBin } = require('../utils/shell')
680
+ const { ensureWindowsUserPathHasNpmBin, ensureWindowsUserPathHasLocalBin } = require('../utils/shell')
681
681
  ensureWindowsUserPathHasNpmBin()
682
682
  sseEmit(res, { type: 'output', text: '\n✓ 已更新 Windows 用户 PATH(包含 npm global bin)\n' })
683
+ // [HolySheep fork v2.1.36 / hs25] Claude Code's Windows installer
684
+ // (claude.ai/install.ps1) drops claude.exe under
685
+ // `%USERPROFILE%\\.local\\bin` which is NOT on PATH by default — it
686
+ // prints a warning and exits, leaving our `commandExists` check to
687
+ // incorrectly report 未安装 even though the install succeeded.
688
+ if (toolId === 'claude-code') {
689
+ try {
690
+ ensureWindowsUserPathHasLocalBin()
691
+ sseEmit(res, { type: 'output', text: '\n✓ 已更新 Windows 用户 PATH(包含 %USERPROFILE%\\.local\\bin for Claude Code)\n' })
692
+ } catch {}
693
+ }
683
694
  } catch {}
684
695
  // Resolve the real install path via `where.exe` so the user sees
685
696
  // exactly where the binary landed. This was a high-signal 2.1.14
Binary file