openzoo 0.34.9 → 0.34.11

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.
@@ -27,6 +27,7 @@
27
27
  * + trailers for grpc-web. Both are handled below.
28
28
  */
29
29
  import http2 from 'node:http2';
30
+ import https from 'node:https';
30
31
  import fs from 'node:fs';
31
32
  import os from 'node:os';
32
33
  import path from 'node:path';
@@ -210,6 +211,114 @@ async function handleStreamChat(req, res, body, log) {
210
211
  else { const end = Buffer.from('{}'); const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1); res.end(Buffer.concat([h, end])); }
211
212
  }
212
213
 
214
+ /** Headers that are illegal to copy onto an HTTP/2 response (RFC 7540 §8.1.2.2),
215
+ * plus the length/encoding fields that no longer describe our re-buffered body. */
216
+ const HOP_BY_HOP = new Set([
217
+ 'connection', 'keep-alive', 'upgrade', 'proxy-connection',
218
+ 'transfer-encoding', 'te', 'trailer', 'proxy-authenticate',
219
+ 'proxy-authorization', 'content-encoding', 'content-length',
220
+ ]);
221
+
222
+ /**
223
+ * FORWARD A NON-INFERENCE REQUEST TO THE REAL ANTHROPIC API.
224
+ *
225
+ * We hijack api.anthropic.com at the DNS level, which catches far more than
226
+ * inference: the desktop app is OAuth/subscription-bound and boots by calling
227
+ * /api/organizations, /api/bootstrap, /api/account on the SAME host. Those are
228
+ * not ours to answer — the local proxy 404s them, the app cannot establish a
229
+ * session, and it hangs until macOS offers Force Quit.
230
+ *
231
+ * So for anything that is not inference we act as a plain reverse proxy to the
232
+ * genuine API, preserving the client's auth headers verbatim so the app's own
233
+ * OAuth session keeps working untouched.
234
+ *
235
+ * WE CANNOT RESOLVE THE NAME NORMALLY — /etc/hosts points it at us, so a plain
236
+ * fetch would loop straight back into this server. Resolve against public DNS
237
+ * over UDP (which ignores /etc/hosts entirely), cache it, and dial the address
238
+ * with the Host/SNI still set to the real hostname so TLS and routing succeed.
239
+ */
240
+ // KEYED BY HOST. A single shared slot meant whichever of api/api-staging
241
+ // resolved first pinned its address for the other one too.
242
+ const realIpCache = new Map();
243
+ async function resolveRealAnthropic(host) {
244
+ const hit = realIpCache.get(host);
245
+ if (hit) return hit;
246
+ const { Resolver } = await import('node:dns/promises');
247
+ const r = new Resolver();
248
+ r.setServers(['1.1.1.1', '8.8.8.8']);
249
+ const [ip] = await r.resolve4(host);
250
+ realIpCache.set(host, ip);
251
+ return ip;
252
+ }
253
+
254
+ /**
255
+ * Headers we must NOT copy back from the upstream response.
256
+ *
257
+ * DELIBERATELY NARROWER THAN HOP_BY_HOP: `content-encoding` is absent here on
258
+ * purpose. Unlike fetch(), node:https hands us the body EXACTLY as it arrived —
259
+ * still gzip/brotli compressed. Stripping the encoding header while forwarding
260
+ * compressed bytes tells the app "this is plain JSON" over binary garbage, and
261
+ * it fails to parse the bootstrap payload. Forward body and label together.
262
+ * `content-length` still goes, because we set it from the buffer we actually
263
+ * have, and the h2 layer rejects the connection-specific ones outright.
264
+ */
265
+ const RESP_DROP = new Set([
266
+ 'connection', 'keep-alive', 'upgrade', 'proxy-connection',
267
+ 'transfer-encoding', 'te', 'trailer', 'content-length',
268
+ ]);
269
+
270
+ async function passthroughToRealAnthropic(req, res, body, host, full, log) {
271
+ try {
272
+ const ip = await resolveRealAnthropic(host);
273
+ const headers = {};
274
+ for (const [k, v] of Object.entries(req.headers)) {
275
+ if (k.startsWith(':') || HOP_BY_HOP.has(k)) continue;
276
+ headers[k] = Array.isArray(v) ? v.join(', ') : String(v);
277
+ }
278
+ headers.host = host;
279
+
280
+ // DIAL THE ADDRESS, BUT SPEAK THE NAME. fetch(`https://${ip}/...`) cannot do
281
+ // this: undici derives SNI from the URL, so the server saw a bare IP, had no
282
+ // certificate to offer for it, and killed the handshake
283
+ // (ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE). node:https lets the two be set
284
+ // independently — a custom `lookup` pins the connection to the public-DNS
285
+ // address (bypassing our own /etc/hosts entry, which would otherwise loop
286
+ // this request straight back into this very server), while `servername` and
287
+ // the Host header keep presenting the real hostname so the cert validates.
288
+ const lookup = (h, o, cb) => (o && o.all ? cb(null, [{ address: ip, family: 4 }]) : cb(null, ip, 4));
289
+ const { status, respHeaders, buf } = await new Promise((resolve, reject) => {
290
+ const up = https.request({
291
+ hostname: host, servername: host, port: 443, path: full,
292
+ method: req.method, headers, lookup,
293
+ }, (r) => {
294
+ const chunks = [];
295
+ r.on('data', (d) => chunks.push(d));
296
+ r.on('end', () => resolve({
297
+ status: r.statusCode, respHeaders: r.headers, buf: Buffer.concat(chunks),
298
+ }));
299
+ r.on('error', reject);
300
+ });
301
+ up.on('error', reject);
302
+ up.setTimeout(20000, () => up.destroy(new Error('upstream timeout')));
303
+ if (req.method !== 'GET' && req.method !== 'HEAD' && body && body.length) up.write(body);
304
+ up.end();
305
+ });
306
+
307
+ const h = {};
308
+ for (const [k, v] of Object.entries(respHeaders)) {
309
+ if (RESP_DROP.has(k.toLowerCase())) continue;
310
+ h[k] = v;
311
+ }
312
+ res.writeHead(status, h);
313
+ res.end(buf);
314
+ log(`cursor-backend: ${req.method} ${full} -> REAL anthropic (${status}, ${buf.length}b)`);
315
+ } catch (e) {
316
+ res.writeHead(502, { 'content-type': 'application/json' });
317
+ res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `passthrough failed: ${e.message}` } }));
318
+ log(`cursor-backend: passthrough ${full} FAILED: ${e.message}`);
319
+ }
320
+ }
321
+
213
322
  /**
214
323
  * Answer one Connect/gRPC-web call. `method` is the trailing method name,
215
324
  * `models` the catalog to publish. Non-catalog methods get an empty-OK body.
@@ -295,8 +404,28 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
295
404
  // verbatim to the local paying proxy and relay the response — reusing the
296
405
  // whole translate+x402 path. (SNI is checked because the Host header may be
297
406
  // absent on h2.)
298
- const host = String(req.headers[':authority'] || req.headers.host || req.socket?.servername || '');
299
- if (/anthropic\.com$/.test(host) || /\/v1\/messages\b/.test(full)) {
407
+ // STRIP THE PORT BEFORE MATCHING. :authority / Host carry `host:port`
408
+ // whenever the client dials a non-default port, so an anchored
409
+ // /anthropic\.com$/ silently failed to match and the request fell through
410
+ // to the gRPC branch, which answered empty-OK. The desktop app then saw a
411
+ // 200 with no body for its session bootstrap — indistinguishable from the
412
+ // 404 it used to get, and it wedged the same way.
413
+ const rawHost = String(req.headers[':authority'] || req.headers.host || req.socket?.servername || '');
414
+ const host = rawHost.replace(/:\d+$/, '');
415
+ const isAnthropic = /(^|\.)anthropic\.com$/.test(host);
416
+ // INTERCEPT INFERENCE ONLY. Matching the whole HOST sent every request the
417
+ // desktop app makes to the local proxy — including the session bootstrap
418
+ // (/api/organizations, /api/bootstrap, /api/account), which the proxy does
419
+ // not implement and answered 404. The app is OAuth/subscription-bound, so
420
+ // a 404 on bootstrap means it can never establish a session: it wedges and
421
+ // macOS offers Force Quit. Only /v1/messages (and /v1/complete) are ours;
422
+ // everything else on this host MUST reach the real Anthropic API.
423
+ const isInference = /\/v1\/(messages|complete)\b/.test(full);
424
+ if (isAnthropic && !isInference) {
425
+ await passthroughToRealAnthropic(req, res, body, host, full, log);
426
+ return;
427
+ }
428
+ if (isAnthropic || isInference) {
300
429
  try {
301
430
  const up = await fetch(`http://127.0.0.1:8402${full}`, {
302
431
  method: req.method,
@@ -313,12 +442,7 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
313
442
  // desktop request killed the whole backend process (uncaught, since
314
443
  // this is inside the async handler), and every later attempt got
315
444
  // ECONNRESET with nothing listening. Content-length goes too: the
316
- // body was re-buffered, so let Node recompute it.
317
- const HOP_BY_HOP = new Set([
318
- 'connection', 'keep-alive', 'upgrade', 'proxy-connection',
319
- 'transfer-encoding', 'te', 'trailer', 'proxy-authenticate',
320
- 'proxy-authorization', 'content-encoding', 'content-length',
321
- ]);
445
+ // body was re-buffered, so let Node recompute it. (Shared set above.)
322
446
  up.headers.forEach((v, k) => { if (!HOP_BY_HOP.has(k.toLowerCase())) h[k] = v; });
323
447
  res.writeHead(up.status, h);
324
448
  res.end(buf);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.34.9",
3
+ "version": "0.34.11",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",