hivecode-mcp 0.14.0 → 0.14.1

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/hive-transport.js CHANGED
@@ -17,6 +17,26 @@
17
17
  //
18
18
  // Plain fetch carries the requests. On Node 24 with NODE_USE_ENV_PROXY=1 -- what Antigravity's VMs
19
19
  // set -- fetch goes through HTTPS_PROXY and trusts NODE_EXTRA_CA_CERTS by itself.
20
+ //
21
+ // AN EXPLICIT PROXY (Claude-cloud, 8 Nov 2026). Where the only way out is a forward proxy named in
22
+ // HTTPS_PROXY (Claude Code cloud containers, CI runners, company laptops), outside host names do not
23
+ // even resolve. `ws` never reads HTTPS_PROXY, and fetch ignores it unless NODE_USE_ENV_PROXY=1 was
24
+ // set when node started, so the WebSocket died with ENOTFOUND, the long-poll probe died the same
25
+ // way, and hive_join said "the relay refused the connection" about a relay it never reached. Now:
26
+ // - proxyFor(url) picks the proxy: HIVE_PROXY (a URL, or off), else HTTPS_PROXY for wss:/https:
27
+ // and HTTP_PROXY for ws:/http:. NO_PROXY is honoured; loopback never goes through a proxy.
28
+ // - Both pipes ride an HTTP CONNECT tunnel through it: the WebSocket (an agent for `ws`) and the
29
+ // long-poll requests (fetchFor). Through a tunnel to a wss:// relay the proxy sees only
30
+ // "CONNECT host:443"; the path and query -- where the room token rides -- stay inside TLS.
31
+ // - A tunnel that fails says so: unreachedReason() tells "never reached the relay" (DNS, the
32
+ // proxy refused CONNECT, the proxy itself unreachable) apart from a relay that answered and
33
+ // refused. A proxy's 403/407 deliberately carries no `status`: statusFromError would read it as
34
+ // the relay's own 403 and explain a proxy problem as a bad token. Proxy credentials are sent
35
+ // and never printed.
36
+ import http from 'http'
37
+ import https from 'https'
38
+ import net from 'net'
39
+ import tls from 'tls'
20
40
  import { WebSocket as WS } from 'ws'
21
41
  import { POLL_PREFIX, POLL_VERSION, packFrames, unpackFrames } from './hive-poll-proto.js'
22
42
 
@@ -44,7 +64,203 @@ export function transportFor(relay, env = process.env) {
44
64
  return decided.get(httpOrigin(relay)) || 'ws'
45
65
  } catch { return 'ws' }
46
66
  }
47
- const proxyConfigured = (env = process.env) => !!(env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy)
67
+ const PROXY_OFF = /^(off|0|false|no|none|direct)$/i
68
+ const proxyConfigured = (env = process.env) => {
69
+ const hive = String(env.HIVE_PROXY || '').trim()
70
+ if (hive) return !PROXY_OFF.test(hive)
71
+ return !!(env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy)
72
+ }
73
+
74
+ // ---- the proxy, when there is one -----------------------------------------------------------
75
+
76
+ const bareHost = (h) => String(h || '').replace(/^\[|\]$/g, '').toLowerCase()
77
+ const isLoopback = (h) => h === 'localhost' || h.endsWith('.localhost') || /^127\./.test(h) || h === '::1' || h === '0:0:0:0:0:0:0:1'
78
+
79
+ /** curl's NO_PROXY rules: "*", "host", ".suffix" / "*.suffix", "host:port", "[v6]:port"; a bare name also covers its subdomains. */
80
+ function noProxyMatches(host, port, list) {
81
+ for (const raw of String(list).split(/[\s,]+/)) {
82
+ const e = raw.trim().toLowerCase()
83
+ if (!e) continue
84
+ if (e === '*') return true
85
+ let h = e, p = ''
86
+ const v6 = /^\[([^\]]+)\](?::(\d+))?$/.exec(e)
87
+ if (v6) { h = v6[1]; p = v6[2] || '' }
88
+ else if ((e.match(/:/g) || []).length === 1) { [h, p] = e.split(':') }
89
+ if (p && p !== String(port)) continue
90
+ h = h.replace(/^\*\./, '.')
91
+ if (h.startsWith('.') ? (host === h.slice(1) || host.endsWith(h)) : (host === h || host.endsWith('.' + h))) return true
92
+ }
93
+ return false
94
+ }
95
+
96
+ /** The proxy to reach `target` (a ws:, wss:, http: or https: URL) through, as a URL, or null for direct. */
97
+ export function proxyFor(target, env = process.env) {
98
+ let u
99
+ try { u = new URL(target) } catch { return null }
100
+ const secure = u.protocol === 'wss:' || u.protocol === 'https:'
101
+ const host = bareHost(u.hostname)
102
+ const port = u.port || (secure ? '443' : '80')
103
+ if (!host || isLoopback(host)) return null
104
+ const hive = String(env.HIVE_PROXY || '').trim()
105
+ if (hive && PROXY_OFF.test(hive)) return null
106
+ const raw = hive || (secure ? (env.HTTPS_PROXY || env.https_proxy) : (env.HTTP_PROXY || env.http_proxy))
107
+ if (!raw) return null
108
+ const np = env.NO_PROXY != null ? env.NO_PROXY : env.no_proxy
109
+ if (np && noProxyMatches(host, port, np)) return null
110
+ try {
111
+ const p = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `http://${raw}`)
112
+ return p.protocol === 'http:' || p.protocol === 'https:' ? p : null
113
+ } catch { return null }
114
+ }
115
+
116
+ /** host:port of a proxy, never its user:pass. */
117
+ const proxyLabel = (p) => `${p.hostname}:${p.port || (p.protocol === 'https:' ? 443 : 80)}`
118
+
119
+ function unreachedError(proxy, detail, extra = {}) {
120
+ // No `status` here, on purpose (see the header): this is the proxy talking, not the relay.
121
+ return Object.assign(new Error(`never reached the relay: proxy ${proxyLabel(proxy)} ${detail}`), { hiveUnreached: true, proxy: proxyLabel(proxy) }, extra)
122
+ }
123
+
124
+ /** An HTTP CONNECT tunnel to host:port through `proxy`: resolves with the raw socket at the far end. */
125
+ function openTunnel(proxy, host, port, timeoutMs = 15000) {
126
+ return new Promise((resolve, reject) => {
127
+ const target = `${net.isIPv6(host) ? `[${host}]` : host}:${port}`
128
+ const headers = { host: target }
129
+ if (proxy.username || proxy.password) {
130
+ const cred = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`
131
+ headers['proxy-authorization'] = 'Basic ' + Buffer.from(cred).toString('base64')
132
+ }
133
+ const secureProxy = proxy.protocol === 'https:'
134
+ const req = (secureProxy ? https : http).request({
135
+ host: bareHost(proxy.hostname), port: proxy.port || (secureProxy ? 443 : 80),
136
+ method: 'CONNECT', path: target, headers, agent: false,
137
+ })
138
+ let settled = false
139
+ const done = (fn, v) => { if (!settled) { settled = true; clearTimeout(timer); fn(v) } }
140
+ const timer = setTimeout(() => {
141
+ req.destroy()
142
+ done(reject, unreachedError(proxy, `did not answer CONNECT ${target} within ${Math.round(timeoutMs / 1000)}s`, { code: 'ETIMEDOUT' }))
143
+ }, timeoutMs)
144
+ req.once('connect', (res, sock, head) => {
145
+ if (res.statusCode !== 200) {
146
+ sock.destroy()
147
+ const hint = res.statusCode === 407 ? ' (it wants credentials: put user:pass in the proxy URL)' : ''
148
+ return done(reject, unreachedError(proxy, `refused CONNECT ${target} with HTTP ${res.statusCode}${hint}`, { proxyStatus: res.statusCode }))
149
+ }
150
+ // Bytes the far end already sent after the proxy's 200 belong to the tunnelled stream.
151
+ if (head && head.length) sock.unshift(head)
152
+ done(resolve, sock)
153
+ })
154
+ req.once('error', (e) => done(reject, unreachedError(proxy, `could not be reached: ${(e && (e.code || e.message)) || e}`, { code: e && e.code })))
155
+ req.end()
156
+ })
157
+ }
158
+
159
+ const TLS_KEYS = ['ca', 'cert', 'key', 'pfx', 'passphrase', 'rejectUnauthorized', 'ciphers', 'ALPNProtocols', 'minVersion', 'maxVersion', 'secureContext', 'checkServerIdentity', 'session']
160
+ const agents = new Map()
161
+ /**
162
+ * An http(s).Agent whose every connection is a CONNECT tunnel through `proxy` (TLS on top for wss:/https:).
163
+ * kind 'fetch' keeps tunnels alive for the long-poll requests; kind 'ws' never reuses one, so a
164
+ * WebSocket upgrade always gets a tunnel of its own and never rides one a poll request opened.
165
+ */
166
+ function tunnelAgent(proxy, secure, kind = 'fetch') {
167
+ const key = `${kind}|${secure ? 's' : 'p'}|${proxy.href}`
168
+ let a = agents.get(key)
169
+ if (a) return a
170
+ a = new (secure ? https.Agent : http.Agent)(kind === 'fetch' ? { keepAlive: true, maxFreeSockets: 4 } : { keepAlive: false })
171
+ a.createConnection = (opts, cb) => {
172
+ const host = bareHost(opts.host || opts.hostname)
173
+ const port = Number(opts.port) || (secure ? 443 : 80)
174
+ openTunnel(proxy, host, port).then((sock) => {
175
+ if (!secure) return cb(null, sock)
176
+ const t = { socket: sock, host, servername: opts.servername || (net.isIP(host) ? undefined : host) }
177
+ for (const k of TLS_KEYS) if (opts[k] !== undefined) t[k] = opts[k]
178
+ cb(null, tls.connect(t))
179
+ }, (e) => cb(e))
180
+ }
181
+ agents.set(key, a)
182
+ return a
183
+ }
184
+
185
+ /** A fetch-shaped call carried over the tunnel agent: just what offersPoll and PollSocket use. */
186
+ function proxyFetch(proxy, url, init = {}) {
187
+ return new Promise((resolve, reject) => {
188
+ const u = new URL(url)
189
+ const secure = u.protocol === 'https:'
190
+ const body = init.body == null ? null : Buffer.from(init.body)
191
+ const headers = { ...(init.headers || {}) }
192
+ if (body) headers['content-length'] = String(body.length)
193
+ const req = (secure ? https : http).request(u, { method: init.method || 'GET', headers, agent: tunnelAgent(proxy, secure) }, (res) => {
194
+ const chunks = []
195
+ res.on('data', (c) => chunks.push(c))
196
+ res.on('error', reject)
197
+ res.on('end', () => {
198
+ const buf = Buffer.concat(chunks)
199
+ resolve({
200
+ status: res.statusCode,
201
+ ok: res.statusCode >= 200 && res.statusCode < 300,
202
+ headers: { get: (k) => { const v = res.headers[String(k).toLowerCase()]; return v == null ? null : Array.isArray(v) ? v.join(', ') : String(v) } },
203
+ json: async () => JSON.parse(buf.toString('utf8')),
204
+ text: async () => buf.toString('utf8'),
205
+ arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
206
+ })
207
+ })
208
+ })
209
+ req.on('error', reject)
210
+ const sig = init.signal
211
+ if (sig) {
212
+ // Like the built-in fetch: reject with the signal's reason (AbortSignal.timeout gives a
213
+ // TimeoutError, which callers such as hive-setup's wakeRelay test for by name).
214
+ const abort = () => req.destroy(sig.reason instanceof Error ? sig.reason : Object.assign(new Error('This operation was aborted'), { name: 'AbortError' }))
215
+ if (sig.aborted) abort(); else sig.addEventListener('abort', abort, { once: true })
216
+ }
217
+ req.end(body || undefined)
218
+ })
219
+ }
220
+
221
+ /** The fetch to reach `url` with: through the proxy tunnel when one applies, else the built-in one. */
222
+ export function fetchFor(url, env = process.env) {
223
+ const p = proxyFor(url, env)
224
+ return p ? (u, init) => proxyFetch(p, u, init) : globalThis.fetch
225
+ }
226
+
227
+ /**
228
+ * fetch(url, init) for ANY call to the relay: the route (proxy tunnel, or direct) is chosen per call
229
+ * from the environment at that moment. Every plain HTTP call to the relay goes through this; the
230
+ * built-in fetch ignores HTTPS_PROXY unless NODE_USE_ENV_PROXY=1 was set when node started
231
+ * (Claude-cloud, 8 Nov 2026: hive-mux's /health probe failing behind a proxy held every file ~75 s).
232
+ */
233
+ export function relayFetch(url, init) {
234
+ const f = fetchFor(String(url))
235
+ if (typeof f !== 'function') return Promise.reject(new Error('no fetch available in this runtime'))
236
+ return f(url, init)
237
+ }
238
+
239
+ const UNREACHED_CODES = {
240
+ ENOTFOUND: 'its host name did not resolve (DNS)',
241
+ EAI_AGAIN: 'its host name did not resolve (DNS, temporary failure)',
242
+ ECONNREFUSED: 'nothing accepted the connection',
243
+ ETIMEDOUT: 'the connection timed out',
244
+ EHOSTUNREACH: 'there is no route to its host',
245
+ ENETUNREACH: 'there is no route to its network',
246
+ }
247
+ /**
248
+ * If a connection error means the relay was NEVER REACHED -- DNS, no route, the proxy refused or was
249
+ * itself unreachable -- say so in one line; null when the relay answered (or it cannot be told).
250
+ * Takes an Error or the event a socket's onerror got.
251
+ */
252
+ export function unreachedReason(x) {
253
+ const e = x && (x.error instanceof Error ? x.error : x)
254
+ if (!e || typeof e !== 'object') return null
255
+ if (e.hiveUnreached) return String(e.message)
256
+ const code = e.code || (e.cause && e.cause.code)
257
+ const why = typeof code === 'string' && UNREACHED_CODES[code]
258
+ if (!why) return null
259
+ const host = e.hostname || (e.cause && e.cause.hostname) || e.address
260
+ let s = `never reached the relay: ${why}${host ? ` (${host})` : ''} [${code}]`
261
+ if ((code === 'ENOTFOUND' || code === 'EAI_AGAIN') && !proxyConfigured()) s += '. If this machine reaches the internet only through a proxy, set HTTPS_PROXY (or HIVE_PROXY) to it.'
262
+ return s
263
+ }
48
264
  function statusOf(ev) {
49
265
  if (!ev) return null
50
266
  if (typeof ev.status === 'number') return ev.status
@@ -53,8 +269,11 @@ function statusOf(ev) {
53
269
  }
54
270
 
55
271
  /** Does this relay offer long-poll? Read from /health over plain HTTPS; remembered for a minute. */
56
- export function offersPoll(origin, fetchImpl = globalThis.fetch) {
57
- const p = probes.get(origin)
272
+ export function offersPoll(origin, fetchImpl = fetchFor(origin)) {
273
+ // Keyed by the route too: an answer read through one proxy (or directly) says nothing about another.
274
+ const via = proxyFor(origin)
275
+ const key = `${via ? via.href : 'direct'}|${origin}`
276
+ const p = probes.get(key)
58
277
  if (p && Date.now() - p.at < 60000) return p.offers
59
278
  const offers = (async () => {
60
279
  try {
@@ -66,7 +285,7 @@ export function offersPoll(origin, fetchImpl = globalThis.fetch) {
66
285
  return Number(j && j.poll) >= 1
67
286
  } catch { return false }
68
287
  })()
69
- probes.set(origin, { at: Date.now(), offers })
288
+ probes.set(key, { at: Date.now(), offers })
70
289
  return offers
71
290
  }
72
291
 
@@ -100,7 +319,7 @@ export class PollSocket extends SocketShape {
100
319
  this.url = url
101
320
  this.readyState = CONNECTING
102
321
  this.binaryType = 'nodebuffer'
103
- this._fetch = opts.fetch || globalThis.fetch
322
+ this._fetch = opts.fetch || fetchFor(url)
104
323
  const u = new URL(url)
105
324
  this._base = httpOrigin(url) + POLL_PREFIX
106
325
  this._path = u.pathname + u.search
@@ -124,7 +343,11 @@ export class PollSocket extends SocketShape {
124
343
  if (this.readyState !== CONNECTING) return
125
344
  let r
126
345
  try { r = await this._req('open', JSON.stringify({ path: this._path, v: POLL_VERSION }), { 'content-type': 'application/json' }, 30000) }
127
- catch (e) { return this._fail(`could not reach the relay over HTTPS: ${(e && e.cause && e.cause.code) || (e && e.message) || e}`) }
346
+ catch (e) {
347
+ // The tunnel said why the relay was never reached: keep that, so unreachedReason() still can.
348
+ if (e && e.hiveUnreached) return this._fail(e.message, undefined, { hiveUnreached: true, proxy: e.proxy, proxyStatus: e.proxyStatus, code: e.code })
349
+ return this._fail(`could not reach the relay over HTTPS: ${(e && e.cause && e.cause.code) || (e && e.message) || e}`, undefined, e && e.cause && e.cause.code ? { code: e.cause.code, hostname: e.cause.hostname } : null)
350
+ }
128
351
  let j = null
129
352
  try { j = await r.json() } catch { }
130
353
  if (r.status !== 200 || !j || !j.sid) {
@@ -140,10 +363,10 @@ export class PollSocket extends SocketShape {
140
363
  this._recvLoop()
141
364
  this._flush()
142
365
  }
143
- _fail(message, status) {
366
+ _fail(message, status, extra = null) {
144
367
  if (this.readyState === CLOSED) return
145
368
  this.readyState = CLOSED
146
- const error = Object.assign(new Error(message), status ? { status } : {})
369
+ const error = Object.assign(new Error(message), extra || {}, status ? { status } : {})
147
370
  fire(this, 'error', { type: 'error', message, error, status, target: this })
148
371
  fire(this, 'close', { type: 'close', code: 1006, reason: message, wasClean: false, target: this })
149
372
  }
@@ -274,7 +497,9 @@ export class HiveSocket extends SocketShape {
274
497
  * again. The error and close that follow are the ones ws itself would emit.
275
498
  */
276
499
  _makeWS(url, protocols) {
277
- const ws = new WS(url, protocols)
500
+ // Behind an explicit proxy the socket rides a CONNECT tunnel (an agent wins over ws's own dialer).
501
+ const proxy = proxyFor(url)
502
+ const ws = proxy ? new WS(url, protocols, { agent: tunnelAgent(proxy, /^wss:/i.test(url), 'ws') }) : new WS(url, protocols)
278
503
  ws.on('unexpected-response', (req, res) => {
279
504
  this._relayRefused = !!(res.headers && res.headers['x-hive-relay'])
280
505
  res.resume()