openzoo 0.50.19 → 0.50.20

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/bin/openzoo.js CHANGED
@@ -89,6 +89,10 @@ const HELP = `openzoo — local x402-paying proxy + MCP server for openzoo.fun
89
89
  usage:
90
90
  npx openzoo start the proxy: http://localhost:8402/v1 (keyless) PLUS a
91
91
  public HTTPS url for cloud IDEs (key required, printed at start)
92
+ npx openzoo bot Grok Bot.app on the zoo. Starts :8402 + aiserver on
93
+ :8443, then launches the app with
94
+ CURSOR_API_BASE_URL=https://127.0.0.1:8443
95
+ (no sudo, no /etc/hosts). Leave it running.
92
96
  npx openzoo cursor [dir] start proxy+tunnel, write MCP config + every model
93
97
  into the picker, and LAUNCH Cursor on [dir]
94
98
  (defaults to the current directory; ~ works;
@@ -164,6 +168,9 @@ async function main() {
164
168
  case 'start':
165
169
  await (await import('../lib/proxy.js')).startProxy({ autoTunnel: true });
166
170
  break;
171
+ case 'bot':
172
+ await (await import('../lib/grokcli.js')).runBot(process.argv.slice(3));
173
+ break;
167
174
  case 'editor':
168
175
  case 'cursor':
169
176
  case 'vscode':
package/lib/cursorapi.js CHANGED
@@ -177,6 +177,11 @@ export function encodeForMethod(method, models) {
177
177
  return encodeEnsureSandBox(pod);
178
178
  } catch { return null; }
179
179
  }
180
+ case 'GetGrokBotSendStatus':
181
+ // Empty body decodes status=UNSPECIFIED → UI "Failed to send".
182
+ // ACCEPTED=2 (aiserver.v1.GrokBotSendStatus). echo_entry_id (field 2)
183
+ // must echo the request's message_id or the overlay never reconciles.
184
+ return encodeGetGrokBotSendStatus();
180
185
  case 'GetPlanInfo': return encodeGetPlanInfo();
181
186
  case 'GetMe': return encodeGetMe();
182
187
  case 'GetDefaultModel': return encodeGetDefaultModel(models);
@@ -204,10 +209,80 @@ export function encodeForMethod(method, models) {
204
209
  * those. This encoder is the redirect; whether the box satisfies the protocol
205
210
  * is a separate, unfinished problem — point it at a logging box first.
206
211
  */
207
- export function encodeEnsureSandBox({ region = 'us1', accountId, podId, token, agent, vnc, p1340, p6081 }) {
212
+ /** GetGrokBotSendStatusResponse { status=ACCEPTED, echo_entry_id, accepted_at_ms } */
213
+ export function encodeGetGrokBotSendStatus(echoId = `oz-${Date.now()}`) {
214
+ return new Buf()
215
+ .int(1, 2) // GROK_BOT_SEND_STATUS_ACCEPTED
216
+ .str(2, echoId)
217
+ .int(4, Date.now())
218
+ .done();
219
+ }
220
+
221
+ /** Strip Connect-RPC 5-byte envelope if present (flags=0, big-endian length). */
222
+ export function unwrapConnect(buf) {
223
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf || []);
224
+ if (b.length >= 5 && (b[0] === 0 || b[0] === 2)) {
225
+ const n = b.readUInt32BE(1);
226
+ if (n > 0 && 5 + n <= b.length) return b.subarray(5, 5 + n);
227
+ }
228
+ return b;
229
+ }
230
+
231
+ /** Bare protobuf → {fieldNumber: string|number}. Enough for GetGrokBotSendStatusRequest
232
+ * {1 agent_id, 2 message_id} (measured body=76b = two uuid strings). */
233
+ export function decodeProtoFields(buf) {
234
+ const b = unwrapConnect(buf);
235
+ const out = {};
236
+ let i = 0;
237
+ while (i < b.length) {
238
+ let key = 0;
239
+ let shift = 0;
240
+ while (i < b.length) {
241
+ const v = b[i++];
242
+ key |= (v & 0x7f) << shift;
243
+ if (!(v & 0x80)) break;
244
+ shift += 7;
245
+ if (shift > 28) return out;
246
+ }
247
+ const field = key >>> 3;
248
+ const wire = key & 7;
249
+ if (wire === 0) {
250
+ let n = 0;
251
+ shift = 0;
252
+ while (i < b.length) {
253
+ const v = b[i++];
254
+ n |= (v & 0x7f) << shift;
255
+ if (!(v & 0x80)) break;
256
+ shift += 7;
257
+ }
258
+ out[field] = n;
259
+ } else if (wire === 2) {
260
+ let len = 0;
261
+ shift = 0;
262
+ while (i < b.length) {
263
+ const v = b[i++];
264
+ len |= (v & 0x7f) << shift;
265
+ if (!(v & 0x80)) break;
266
+ shift += 7;
267
+ }
268
+ if (i + len > b.length) break;
269
+ out[field] = b.subarray(i, i + len).toString('utf8');
270
+ i += len;
271
+ } else {
272
+ break;
273
+ }
274
+ }
275
+ return out;
276
+ }
277
+
278
+ export function encodeEnsureSandBox({ region = 'us1', accountId, podId, token, accessToken, agent, vnc, vncPath, p1340, p6081 }) {
208
279
  // per-port URLs — field 6 is the agent (1337), field 7 the VNC desktop
209
280
  // (6080), matching the live cursorvm response. RunPod fronts each port at
210
281
  // https://<id>-<port>.proxy.runpod.net, so agent !== vnc.
282
+ // Field 4 = network_token (nto-…); field 11 = agent HTTP bearer. Mixing
283
+ // them 401s /api/* on the real pod (measured sniff 2026-08-29).
284
+ const auth = accessToken || token;
285
+ const vncUrl = vncPath || `${vnc}/vnc.html?network_token=${token}&resume_lower_s=900&resume_upper_s=18000&path=websockify%3Fnetwork_token%3D${token}`;
211
286
  return new Buf()
212
287
  .str(1, region)
213
288
  .str(2, accountId)
@@ -215,11 +290,11 @@ export function encodeEnsureSandBox({ region = 'us1', accountId, podId, token, a
215
290
  .str(4, token)
216
291
  .str(5, 'local')
217
292
  .str(6, agent)
218
- .str(7, `${vnc}/vnc.html?network_token=${token}&resume_lower_s=900&resume_upper_s=18000&path=websockify%3Fnetwork_token%3D${token}`)
293
+ .str(7, vncUrl)
219
294
  .str(8, '/workspace/terminals')
220
295
  .int(9, 1)
221
296
  .str(10, p1340 || agent)
222
- .str(11, token)
297
+ .str(11, auth)
223
298
  .str(12, p6081 || vnc)
224
299
  .done();
225
300
  }
@@ -31,8 +31,10 @@ import https from 'node:https';
31
31
  import fs from 'node:fs';
32
32
  import os from 'node:os';
33
33
  import path from 'node:path';
34
+ import zlib from 'node:zlib';
34
35
  import { execFileSync } from 'node:child_process';
35
- import { encodeAvailableModels, encodeForMethod } from './cursorapi.js';
36
+ import { randomUUID } from 'node:crypto';
37
+ import { encodeAvailableModels, encodeForMethod, encodeEnsureSandBox, encodeGetGrokBotSendStatus, decodeProtoFields, unwrapConnect } from './cursorapi.js';
36
38
 
37
39
  const TLS_DIR = path.join(os.homedir(), '.openzoo', 'cursor-tls');
38
40
  const CURSOR_HOSTS = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'repo42.cursor.sh'];
@@ -284,70 +286,815 @@ const RESP_DROP = new Set([
284
286
  'transfer-encoding', 'te', 'trailer', 'content-length',
285
287
  ]);
286
288
 
289
+ const SNIFF_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-sniff.jsonl');
290
+ let realPod = null; // { agent, vnc, token, p1340, p6081, region, accountId, podId }
291
+
292
+ function sniffOn() { return process.env.OPENZOO_SNIFF === '1'; }
293
+ function sniffSelf() { return process.env.OZ_SNIFF_SELF || 'https://127.0.0.1:8443'; }
294
+ /** CURSOR_API_BASE_URL=https://127.0.0.1:8443 makes Host 127.0.0.1 — public DNS
295
+ * of that name is ENOTFOUND (measured sniff #3 /events, #4 listAgents). */
296
+ function cursorUpstream(host) {
297
+ const h = String(host || '').replace(/:\d+$/, '');
298
+ if (/cursor\.sh$/.test(h) || /anthropic\.com$/.test(h) || /cursorvm\.com$/.test(h)) return h;
299
+ return 'api2.cursor.sh';
300
+ }
301
+ function sniffDump(rec) {
302
+ if (!sniffOn()) return;
303
+ try {
304
+ fs.mkdirSync(path.dirname(SNIFF_FILE), { recursive: true, mode: 0o700 });
305
+ fs.appendFileSync(SNIFF_FILE, JSON.stringify({ at: new Date().toISOString(), ...rec }) + '\n');
306
+ } catch { /* dump must never break the proxy */ }
307
+ }
308
+ function jsonish(buf, limit = 12000) {
309
+ const s = Buffer.isBuffer(buf) ? buf.toString('utf8') : String(buf || '');
310
+ try { return JSON.parse(s); } catch { return s.slice(0, limit); }
311
+ }
312
+ function copyReqHeaders(req, host) {
313
+ const headers = {};
314
+ for (const [k, v] of Object.entries(req.headers)) {
315
+ if (k.startsWith(':') || HOP_BY_HOP.has(k)) continue;
316
+ headers[k] = Array.isArray(v) ? v.join(', ') : String(v);
317
+ }
318
+ if (host) headers.host = host;
319
+ delete headers['content-length'];
320
+ return headers;
321
+ }
322
+ function lookupPinned(ip) {
323
+ return (h, o, cb) => (o && o.all ? cb(null, [{ address: ip, family: 4 }]) : cb(null, ip, 4));
324
+ }
325
+ function inflateBody(buf, headers) {
326
+ const enc = String(headers?.['content-encoding'] || '').toLowerCase();
327
+ try {
328
+ if (enc.includes('gzip')) return zlib.gunzipSync(buf);
329
+ if (enc.includes('deflate')) return zlib.inflateSync(buf);
330
+ if (enc.includes('br')) return zlib.brotliDecompressSync(buf);
331
+ } catch { /* fall through */ }
332
+ if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) {
333
+ try { return zlib.gunzipSync(buf); } catch { /* */ }
334
+ }
335
+ return buf;
336
+ }
337
+ function writeCaptured(res, status, respHeaders, buf) {
338
+ const h = {};
339
+ for (const [k, v] of Object.entries(respHeaders || {})) {
340
+ if (RESP_DROP.has(k.toLowerCase())) continue;
341
+ h[k] = v;
342
+ }
343
+ res.writeHead(status, h);
344
+ res.end(buf);
345
+ }
346
+
347
+ async function upstreamUnary({ host, path: pth, method, headers, body, timeoutMs = 20000 }) {
348
+ const ip = await resolveRealAnthropic(host);
349
+ return new Promise((resolve, reject) => {
350
+ const up = https.request({
351
+ hostname: host, servername: host, port: 443, path: pth,
352
+ method, headers, lookup: lookupPinned(ip),
353
+ }, (r) => {
354
+ const chunks = [];
355
+ r.on('data', (d) => chunks.push(d));
356
+ r.on('end', () => resolve({
357
+ status: r.statusCode, respHeaders: r.headers, buf: Buffer.concat(chunks),
358
+ }));
359
+ r.on('error', reject);
360
+ });
361
+ up.on('error', reject);
362
+ if (timeoutMs) {
363
+ up.setTimeout(timeoutMs, () => {
364
+ reject(new Error('upstream timeout'));
365
+ try { up.destroy(); } catch { /* */ }
366
+ });
367
+ }
368
+ if (method !== 'GET' && method !== 'HEAD' && body && body.length) up.write(body);
369
+ up.end();
370
+ });
371
+ }
372
+
287
373
  async function passthroughToRealAnthropic(req, res, body, host, full, log) {
288
374
  try {
289
- const ip = await resolveRealAnthropic(host);
290
- const headers = {};
291
- for (const [k, v] of Object.entries(req.headers)) {
292
- if (k.startsWith(':') || HOP_BY_HOP.has(k)) continue;
293
- headers[k] = Array.isArray(v) ? v.join(', ') : String(v);
375
+ host = cursorUpstream(host);
376
+ const streaming = /Watch|Stream|Subscribe/i.test(full);
377
+ if (streaming) {
378
+ await passthroughPipe(req, res, body, host, full, log);
379
+ return;
294
380
  }
295
- headers.host = host;
296
-
297
- // DIAL THE ADDRESS, BUT SPEAK THE NAME. fetch(`https://${ip}/...`) cannot do
298
- // this: undici derives SNI from the URL, so the server saw a bare IP, had no
299
- // certificate to offer for it, and killed the handshake
300
- // (ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE). node:https lets the two be set
301
- // independently a custom `lookup` pins the connection to the public-DNS
302
- // address (bypassing our own /etc/hosts entry, which would otherwise loop
303
- // this request straight back into this very server), while `servername` and
304
- // the Host header keep presenting the real hostname so the cert validates.
305
- const lookup = (h, o, cb) => (o && o.all ? cb(null, [{ address: ip, family: 4 }]) : cb(null, ip, 4));
306
- const { status, respHeaders, buf } = await new Promise((resolve, reject) => {
381
+ const headers = copyReqHeaders(req, host);
382
+ const cap = await upstreamUnary({
383
+ host, path: full, method: req.method, headers, body, timeoutMs: 20000,
384
+ });
385
+ if (process.env.OPENZOO_DUMP === '1' && cap.buf.length) {
386
+ try {
387
+ const dir = '/tmp/openzoo-sniff';
388
+ fs.mkdirSync(dir, { recursive: true });
389
+ const safe = full.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 80);
390
+ fs.writeFileSync(`${dir}/${Date.now()}_${safe}.bin`, cap.buf);
391
+ } catch { /* dumping must never break the proxy */ }
392
+ }
393
+ writeCaptured(res, cap.status, cap.respHeaders, cap.buf);
394
+ log(`cursor-backend: ${req.method} ${full} -> REAL ${host} (${cap.status}, ${cap.buf.length}b)`);
395
+ } catch (e) {
396
+ res.writeHead(502, { 'content-type': 'application/json' });
397
+ res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `passthrough failed: ${e.message}` } }));
398
+ log(`cursor-backend: passthrough ${full} FAILED: ${e.message}`);
399
+ }
400
+ }
401
+
402
+ async function passthroughPipe(req, res, body, host, full, log) {
403
+ host = cursorUpstream(host);
404
+ const ip = await resolveRealAnthropic(host);
405
+ const headers = copyReqHeaders(req, host);
406
+ await new Promise((resolve, reject) => {
407
+ const up = https.request({
408
+ hostname: host, servername: host, port: 443, path: full,
409
+ method: req.method, headers, lookup: lookupPinned(ip),
410
+ }, (r) => {
411
+ const h = {};
412
+ for (const [k, v] of Object.entries(r.headers || {})) {
413
+ if (RESP_DROP.has(k.toLowerCase())) continue;
414
+ h[k] = v;
415
+ }
416
+ res.writeHead(r.statusCode, h);
417
+ r.pipe(res);
418
+ r.on('end', resolve);
419
+ r.on('error', reject);
420
+ });
421
+ up.on('error', reject);
422
+ req.on('close', () => { try { up.destroy(); } catch { /* */ } });
423
+ if (req.method !== 'GET' && req.method !== 'HEAD' && body && body.length) up.write(body);
424
+ up.end();
425
+ });
426
+ log(`cursor-backend: -> PIPE ${full} -> REAL ${host}`);
427
+ }
428
+
429
+ function rememberPod(fields, log) {
430
+ if (!fields || !fields[6]) return null;
431
+ const execDaemon = String(fields[6] || '');
432
+ const gateway = String(fields[10] || fields[6] || '');
433
+ realPod = {
434
+ execDaemon,
435
+ agent: gateway, // /api/sendPrompt lives on gateway_url (1340), not exec_daemon (1337)
436
+ vnc: fields[7] ? String(fields[7]).split('/vnc.html')[0] : gateway,
437
+ vncPath: fields[7] ? String(fields[7]) : undefined,
438
+ token: String(fields[4] || ''),
439
+ accessToken: String(fields[11] || ''),
440
+ p1340: gateway,
441
+ p6081: fields[12] ? String(fields[12]) : undefined,
442
+ region: String(fields[1] || 'us1'),
443
+ accountId: String(fields[2] || ''),
444
+ podId: String(fields[3] || ''),
445
+ };
446
+ sniffDump({ kind: 'pod', fields, realPod });
447
+ log(`cursor-backend: SNIFF real pod ${realPod.agent}`);
448
+ return realPod;
449
+ }
450
+
451
+ function rewrittenBox() {
452
+ const self = sniffSelf();
453
+ const p = realPod || {};
454
+ return encodeEnsureSandBox({
455
+ region: p.region || 'us1',
456
+ accountId: p.accountId || 'openzoo',
457
+ podId: p.podId || 'openzoo-sniff',
458
+ token: p.token || 'openzoo',
459
+ accessToken: p.accessToken || p.token || 'openzoo',
460
+ agent: self,
461
+ vnc: self,
462
+ p1340: self,
463
+ p6081: self,
464
+ });
465
+ }
466
+
467
+ async function sniffEnsureSandBox(req, res, body, host, full, log) {
468
+ const upstream = cursorUpstream(host);
469
+ const headers = copyReqHeaders(req, upstream);
470
+ const cap = await upstreamUnary({
471
+ host: upstream, path: full, method: req.method, headers, body, timeoutMs: 30000,
472
+ });
473
+ const raw = inflateBody(cap.buf, cap.respHeaders);
474
+ const proto = unwrapConnect(raw);
475
+ const fields = decodeProtoFields(proto);
476
+ rememberPod(fields, log);
477
+ sniffDump({
478
+ kind: 'EnsureSandBox',
479
+ status: cap.status,
480
+ bytes: cap.buf.length,
481
+ inflated: raw.length,
482
+ encoding: cap.respHeaders?.['content-encoding'] || '',
483
+ head: Buffer.from(raw.subarray(0, 24)).toString('hex'),
484
+ fields,
485
+ rewrittenTo: sniffSelf(),
486
+ });
487
+ const payload = realPod ? rewrittenBox() : proto;
488
+ const ct = String(req.headers['content-type'] || cap.respHeaders?.['content-type'] || '');
489
+ if (/WatchSandBoxMigration/.test(full) || ct.includes('connect+proto')) {
490
+ res.writeHead(200, {
491
+ 'content-type': 'application/connect+proto',
492
+ 'grpc-status': '0',
493
+ ...CORS,
494
+ });
495
+ const end = Buffer.from('{}');
496
+ const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
497
+ res.end(Buffer.concat([envelope(payload), h, end]));
498
+ } else if (ct.includes('grpc-web')) {
499
+ res.writeHead(200, {
500
+ 'content-type': ct.includes('text') ? 'application/grpc-web-text+proto' : 'application/grpc-web+proto',
501
+ 'grpc-status': '0', ...CORS,
502
+ });
503
+ res.end(Buffer.concat([envelope(payload), grpcWebTrailer()]));
504
+ } else {
505
+ res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
506
+ res.end(payload);
507
+ }
508
+ log(`cursor-backend: SNIFF EnsureSandBox -> rewrite ${sniffSelf()} (${cap.status}, real ${cap.buf.length}b)`);
509
+ }
510
+
511
+ async function proxyPodHttp(req, res, full, body, log) {
512
+ if (!realPod?.agent) return false;
513
+ const agent = new URL(realPod.agent);
514
+ const headers = copyReqHeaders(req, agent.host);
515
+ if (realPod.accessToken && !headers.authorization && !headers.Authorization) {
516
+ headers.authorization = `Bearer ${realPod.accessToken}`;
517
+ }
518
+ if (realPod.token && !headers['x-anyrun-network-token']) {
519
+ headers['x-anyrun-network-token'] = realPod.token;
520
+ }
521
+ if (!headers['x-sand-slim-avatars']) headers['x-sand-slim-avatars'] = '1';
522
+ const path0 = (full || '').split('?')[0];
523
+ const interesting = /sendPrompt|Transcript|listAgents|promptAcceptance|openAgentTail|createAgent/i.test(path0);
524
+ if (path0 === '/events') {
525
+ const ip = await resolveRealAnthropic(agent.hostname);
526
+ await new Promise((resolve, reject) => {
307
527
  const up = https.request({
308
- hostname: host, servername: host, port: 443, path: full,
309
- method: req.method, headers, lookup,
528
+ hostname: agent.hostname, servername: agent.hostname, port: 443, path: full,
529
+ method: req.method, headers, lookup: lookupPinned(ip),
310
530
  }, (r) => {
531
+ const h = { ...CORS };
532
+ for (const [k, v] of Object.entries(r.headers || {})) {
533
+ if (RESP_DROP.has(k.toLowerCase())) continue;
534
+ h[k] = v;
535
+ }
536
+ res.writeHead(r.statusCode, h);
311
537
  const chunks = [];
312
- r.on('data', (d) => chunks.push(d));
313
- r.on('end', () => resolve({
314
- status: r.statusCode, respHeaders: r.headers, buf: Buffer.concat(chunks),
315
- }));
538
+ r.on('data', (d) => {
539
+ res.write(d);
540
+ if (chunks.length < 40) chunks.push(d);
541
+ });
542
+ r.on('end', () => {
543
+ sniffDump({ kind: 'sse', path: full, sample: Buffer.concat(chunks).toString('utf8').slice(0, 8000) });
544
+ try { res.end(); } catch { /* */ }
545
+ resolve();
546
+ });
316
547
  r.on('error', reject);
317
548
  });
318
549
  up.on('error', reject);
319
- // WatchSandBoxMigration is a LONG POLL a 20s cap aborts it every time and
320
- // the app never learns its sandbox is ready. Streams get no deadline.
321
- const streaming = /Watch|Stream|Subscribe/i.test(full);
322
- if (!streaming) up.setTimeout(20000, () => up.destroy(new Error('upstream timeout')));
323
- if (req.method !== 'GET' && req.method !== 'HEAD' && body && body.length) up.write(body);
550
+ req.on('close', () => { try { up.destroy(); } catch { /* */ } });
324
551
  up.end();
325
552
  });
553
+ log('cursor-backend: SNIFF /events -> real pod (piped)');
554
+ return true;
555
+ }
556
+ try {
557
+ const cap = await upstreamUnary({
558
+ host: agent.hostname,
559
+ path: full,
560
+ method: req.method,
561
+ headers,
562
+ body,
563
+ timeoutMs: path0 === '/health' ? 8000 : 120000,
564
+ });
565
+ writeCaptured(res, cap.status, cap.respHeaders, cap.buf);
566
+ const rec = {
567
+ kind: 'pod-http',
568
+ method: req.method,
569
+ path: path0,
570
+ status: cap.status,
571
+ reqBytes: body?.length || 0,
572
+ resBytes: cap.buf.length,
573
+ req: jsonish(body, 4000),
574
+ res: jsonish(inflateBody(cap.buf, cap.respHeaders), 16000),
575
+ };
576
+ sniffDump(rec);
577
+ if (interesting) {
578
+ log(`cursor-backend: SNIFF ${path0} ${cap.status} req=${JSON.stringify(rec.req).slice(0, 220)} res=${JSON.stringify(rec.res).slice(0, 500)}`);
579
+ } else {
580
+ log(`cursor-backend: SNIFF ${path0} -> real pod (${cap.status}, ${cap.buf.length}b)`);
581
+ }
582
+ return true;
583
+ } catch (e) {
584
+ log(`cursor-backend: SNIFF ${path0} FAILED ${e.message}`);
585
+ return false;
586
+ }
587
+ }
326
588
 
327
- const h = {};
328
- for (const [k, v] of Object.entries(respHeaders)) {
329
- if (RESP_DROP.has(k.toLowerCase())) continue;
330
- h[k] = v;
589
+ /** JSON for the in-pod agent HTTP API. Hijack points field-6 at THIS
590
+ * server, so GET /health and POST /api/* land here — empty protobuf on
591
+ * those is "unhealthy" and Grok Bot retries EnsureSandBox forever
592
+ * (measured: /health empty-ok then EnsureSandBox #1945+). */
593
+ function jsonSend(res, obj) {
594
+ res.writeHead(200, { 'content-type': 'application/json', ...CORS });
595
+ res.end(JSON.stringify(obj));
596
+ }
597
+ /** Box HTTP API envelope. Client parse (CVr) returns null unless both
598
+ * status==="ok" AND "value" in n — our unwrapped {ok:true}/{entries} was
599
+ * dropped, so Grok Bot never painted the zoo reply (measured: << zoo 200 91c
600
+ * then tail polls with Failed to send). */
601
+ function jsonApi(res, value) {
602
+ jsonSend(res, { status: 'ok', value });
603
+ }
604
+
605
+ const sseClients = new Set();
606
+ /** Gateway SSE parser (asar dispatchEventBlock) only reads `data:` lines and
607
+ * requires `{channel, payload}`. `event:` names are ignored. */
608
+ function ssePush(channel, payload) {
609
+ const chunk = `data: ${JSON.stringify({ channel, payload })}\n\n`;
610
+ for (const c of sseClients) {
611
+ try { c.write(chunk); } catch { sseClients.delete(c); }
612
+ }
613
+ }
614
+
615
+ /** Grok Bot Helper daemon: GET /local-exec/requests is SSE, POST /local-exec/responses
616
+ * is `{providerId, frames}`. We used to JSON-[] the GET which is "disconnected"
617
+ * (measured: Grok Bot "can't see files on your local computer"). */
618
+ const localExecSse = new Set();
619
+ const localExecWaiters = new Map();
620
+ let localExecHello = null;
621
+ function localExecPush(frame) {
622
+ const chunk = `data: ${JSON.stringify(frame)}\n\n`;
623
+ for (const c of localExecSse) {
624
+ try { c.write(chunk); } catch { localExecSse.delete(c); }
625
+ }
626
+ }
627
+ function handleLocalExecFrames(frames, log) {
628
+ for (const f of frames || []) {
629
+ if (!f || typeof f !== 'object') continue;
630
+ if (f.kind === 'hello') {
631
+ localExecHello = f;
632
+ log(`cursor-backend: local-exec hello computer=${f.computerId || f.label || '?'} root=${f.localRoot || '?'}`);
633
+ }
634
+ const w = f.requestId && localExecWaiters.get(f.requestId);
635
+ if (!w) continue;
636
+ if (f.kind === 'file') {
637
+ const buf = Buffer.from(f.bytesBase64 || '', 'base64');
638
+ w.resolve({ kind: 'file', bytes: buf, text: buf.toString('utf8') });
639
+ localExecWaiters.delete(f.requestId);
640
+ } else if (f.kind === 'file-error' || f.kind === 'messages-error') {
641
+ w.reject(new Error(f.error || 'local-exec error'));
642
+ localExecWaiters.delete(f.requestId);
643
+ } else if (f.kind === 'client' || f.kind === 'control') {
644
+ w.resolve({ kind: f.kind, message: f.message });
645
+ localExecWaiters.delete(f.requestId);
331
646
  }
332
- res.writeHead(status, h);
333
- res.end(buf);
334
- // DUMP THE INTERESTING ONES. EnsureSandBox's response is what names the
335
- // sandbox, and the sandbox is the candidate inference path — you cannot
336
- // read that from a byte count. Written raw for offline decoding.
337
- if (process.env.OPENZOO_DUMP === '1' && buf.length) {
647
+ }
648
+ }
649
+ function localExecAsk(frame, timeoutMs = 45000) {
650
+ const requestId = frame.requestId || randomUUID();
651
+ const job = { ...frame, requestId };
652
+ return new Promise((resolve, reject) => {
653
+ const t = setTimeout(() => {
654
+ localExecWaiters.delete(requestId);
655
+ reject(new Error('local-exec timeout — is Grok Bot Helper connected?'));
656
+ }, timeoutMs);
657
+ localExecWaiters.set(requestId, {
658
+ resolve: (v) => { clearTimeout(t); resolve(v); },
659
+ reject: (e) => { clearTimeout(t); reject(e); },
660
+ });
661
+ localExecPush(job);
662
+ });
663
+ }
664
+ async function handleLocalExecHttp(req, res, path0, body, log) {
665
+ if (!/^\/local-exec\//.test(path0)) return false;
666
+ if (req.method === 'GET' && /\/requests$/.test(path0)) {
667
+ res.writeHead(200, {
668
+ 'content-type': 'text/event-stream',
669
+ 'cache-control': 'no-cache',
670
+ ...CORS,
671
+ });
672
+ localExecSse.add(res);
673
+ res.write(`data: ${JSON.stringify({ kind: 'welcome', providerId: 'openzoo' })}\n\n`);
674
+ const iv = setInterval(() => {
675
+ try { res.write(': ping\n\n'); } catch { clearInterval(iv); localExecSse.delete(res); }
676
+ }, 15000);
677
+ req.on('close', () => { clearInterval(iv); localExecSse.delete(res); });
678
+ log('cursor-backend: -> local-exec /requests sse');
679
+ return true;
680
+ }
681
+ if (/\/responses$/.test(path0)) {
682
+ let parsed = {};
683
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
684
+ handleLocalExecFrames(parsed.frames, log);
685
+ jsonSend(res, { ok: true });
686
+ log(`cursor-backend: -> local-exec /responses n=${(parsed.frames || []).length}`);
687
+ return true;
688
+ }
689
+ jsonSend(res, { ok: true });
690
+ return true;
691
+ }
692
+ function extractLocalPaths(text) {
693
+ const out = [];
694
+ const re = /(?:~\/|\/Users\/)[^\s,;:!?()[\]{}"'`]+/g;
695
+ let m;
696
+ while ((m = re.exec(String(text || '')))) {
697
+ out.push(m[0].replace(/[.,;:]+$/, ''));
698
+ }
699
+ return [...new Set(out)];
700
+ }
701
+ function expandUserPath(p) {
702
+ if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
703
+ return p;
704
+ }
705
+
706
+ /** Per-agent transcript. sendPrompt is async: accept immediately, zooComplete
707
+ * fills this, getAgentTranscriptTail is what the UI actually polls.
708
+ *
709
+ * Gateway `isValidTranscriptEntry` (asar Fgi/RC) DROPS anything that is not
710
+ * `{id, kind:"message", content}` / `{id, kind:"send-message", message}` —
711
+ * proto wrappers with entryKind/body were counted n=2/2 here and painted
712
+ * as zero on the client, so overlay timed out → "Failed to send". */
713
+ const transcripts = new Map();
714
+ const tailedAgents = new Set();
715
+ let lastSendEchoId = `oz-${Date.now()}`;
716
+ function agentTranscript(id) {
717
+ let t = transcripts.get(id);
718
+ if (!t) { t = { seq: 0, entries: [] }; transcripts.set(id, t); }
719
+ return t;
720
+ }
721
+ function appendLine(agentId, role, text, extra = {}) {
722
+ const t = agentTranscript(agentId);
723
+ t.seq += 1;
724
+ const nonce = extra.clientNonce ? String(extra.clientNonce) : undefined;
725
+ const ts = Date.now();
726
+ const requestId = extra.requestId || nonce || `oz-req-${t.seq}`;
727
+ let e;
728
+ if (role === 'user') {
729
+ // Live cursorvm getAgentTranscriptTail (2026-08-29): user echo is
730
+ // kind:"message" + role:"user" + clientNonce. Assistant is kind:"send-message".
731
+ e = {
732
+ seq: t.seq,
733
+ kind: 'message',
734
+ id: extra.id || `t${t.seq}u`,
735
+ role: 'user',
736
+ content: String(text || ''),
737
+ richText: extra.richText || JSON.stringify({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: String(text || '') }] }] }),
738
+ isStreaming: false,
739
+ timestampMs: ts,
740
+ ...(nonce ? { clientNonce: nonce } : {}),
741
+ requestId,
742
+ };
743
+ } else {
744
+ e = {
745
+ seq: t.seq,
746
+ kind: 'send-message',
747
+ id: extra.id || `t${t.seq}s0`,
748
+ message: { type: 'text', content: String(text || '') },
749
+ timestampMs: ts,
750
+ requestId,
751
+ };
752
+ }
753
+ t.entries.push(e);
754
+ return e;
755
+ }
756
+ function fanoutLine(primaryId, role, text, extra = {}) {
757
+ const ids = new Set([primaryId, ...tailedAgents]);
758
+ let last = null;
759
+ for (const id of ids) last = appendLine(id, role, text, extra);
760
+ return last;
761
+ }
762
+ function gatewayEntry(e) {
763
+ const { seq, ...rest } = e;
764
+ return rest;
765
+ }
766
+
767
+ function promptFromSendBody(raw) {
768
+ let obj = raw;
769
+ if (Buffer.isBuffer(raw) || typeof raw === 'string') {
770
+ try { obj = JSON.parse(String(raw)); } catch { return String(raw || ''); }
771
+ }
772
+ if (!obj || typeof obj !== 'object') return '';
773
+ const pick = (v) => (typeof v === 'string' && v.trim() ? v : '');
774
+ let fromMsgs = '';
775
+ if (Array.isArray(obj.messages)) {
776
+ for (let i = obj.messages.length - 1; i >= 0; i--) {
777
+ const m = obj.messages[i];
778
+ fromMsgs = pick(m?.content) || pick(m?.text) || pick(m?.prompt);
779
+ if (fromMsgs) break;
780
+ }
781
+ }
782
+ return pick(obj.prompt) || pick(obj.text) || pick(obj.message)
783
+ || pick(obj.content) || pick(obj.input)
784
+ || pick(obj.message?.content) || pick(obj.message?.text)
785
+ || fromMsgs
786
+ || '';
787
+ }
788
+
789
+ let walletUsdCache = { usd: null, at: 0 };
790
+ async function walletUsdCached() {
791
+ if (walletUsdCache.usd != null && Date.now() - walletUsdCache.at < 60_000) return walletUsdCache.usd;
792
+ try {
793
+ const { affordableUsd } = await import('./info.js');
794
+ const n = await Promise.race([
795
+ affordableUsd(),
796
+ new Promise((_, rej) => setTimeout(() => rej(new Error('balance timeout')), 4000)),
797
+ ]);
798
+ if (Number.isFinite(n)) {
799
+ walletUsdCache = { usd: Number(n), at: Date.now() };
800
+ return walletUsdCache.usd;
801
+ }
802
+ } catch { /* keep last */ }
803
+ return walletUsdCache.usd;
804
+ }
805
+
806
+ async function zooSpendOverlay(data) {
807
+ const x = data?.x402 || {};
808
+ let info = {};
809
+ try {
810
+ const r = await fetch('http://127.0.0.1:8402/v1/info', { signal: AbortSignal.timeout(1500) });
811
+ if (r.ok) info = await r.json();
812
+ } catch { /* */ }
813
+ let session = {};
814
+ try {
815
+ session = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.openzoo', 'session.json'), 'utf8'));
816
+ } catch { /* */ }
817
+ const spent = Number(info.spendUsd ?? session.spentUsd ?? x.billedUsd ?? 0);
818
+ const would = Number(info.directUsd ?? session.directUsd ?? x.directUsd ?? 0);
819
+ const saved = Number(info.savedUsd ?? Math.max(0, would - spent));
820
+ const pct = would > 0 ? (100 * saved / would) : 0;
821
+ const credit = Number(info.creditUsd);
822
+ const wallet = await walletUsdCached();
823
+ const bal = Number.isFinite(wallet) && wallet > 0.004
824
+ ? wallet
825
+ : (Number.isFinite(credit) && credit > 0.004 ? credit : null);
826
+ const lines = ['', ''];
827
+ if (x.billedUsd != null) {
828
+ lines.push(`this call $${Number(x.billedUsd).toFixed(6)} · OpenRouter $${Number(x.directUsd || 0).toFixed(6)}`);
829
+ }
830
+ const balTxt = bal != null ? ` · balance $${bal.toFixed(2)}` : '';
831
+ lines.push(`spent $${spent.toFixed(4)}${balTxt} · OpenRouter would $${would.toFixed(4)} · saved $${saved.toFixed(4)} (${pct.toFixed(0)}%)`);
832
+ return lines.join('\n');
833
+ }
834
+
835
+ const agentModels = new Map();
836
+ const MODEL_ALIASES = {
837
+ fable: 'anthropic/claude-fable-5',
838
+ 'fable-5': 'anthropic/claude-fable-5',
839
+ 'claude-fable-5': 'anthropic/claude-fable-5',
840
+ opus: 'anthropic/claude-opus-5',
841
+ 'opus-5': 'anthropic/claude-opus-5',
842
+ sonnet: 'anthropic/claude-sonnet-5',
843
+ grok: 'x-ai/grok-4.6',
844
+ 'grok-4': 'x-ai/grok-4.6',
845
+ 'grok-4.6': 'x-ai/grok-4.6',
846
+ };
847
+ function resolveModelId(raw) {
848
+ const s = String(raw || '').trim();
849
+ if (!s) return null;
850
+ const lower = s.toLowerCase().replace(/^\/+/, '');
851
+ if (MODEL_ALIASES[lower]) return MODEL_ALIASES[lower];
852
+ if (s.includes('/')) return s;
853
+ return null;
854
+ }
855
+
856
+ async function zooComplete(prompt, log, agentId) {
857
+ log(`cursor-backend: zoo POST :8402 ${JSON.stringify((prompt || '').slice(0, 60))}`);
858
+ const model = agentModels.get(agentId)
859
+ || process.env.OPENZOO_DEFAULT_MODEL
860
+ || 'anthropic/claude-opus-5';
861
+ const attachments = [];
862
+ for (const raw of extractLocalPaths(prompt)) {
863
+ const abs = expandUserPath(raw);
864
+ try {
865
+ if (localExecSse.size > 0) {
866
+ log(`cursor-backend: local-exec download ${abs}`);
867
+ const got = await localExecAsk({ kind: 'download', path: abs });
868
+ attachments.push({ path: raw, abs, text: got.text || '' });
869
+ } else {
870
+ const text = fs.readFileSync(abs, 'utf8');
871
+ attachments.push({ path: raw, abs, text });
872
+ }
873
+ } catch (e) {
874
+ attachments.push({ path: raw, abs, error: e.message });
875
+ }
876
+ }
877
+ const messages = [];
878
+ if (attachments.length) {
879
+ const bits = attachments.map((a) => (
880
+ a.error
881
+ ? `FILE ${a.path} ERROR: ${a.error}`
882
+ : `FILE ${a.path} (${a.abs})\n${String(a.text).slice(0, 180000)}`
883
+ ));
884
+ messages.push({
885
+ role: 'system',
886
+ content: 'Local files below were fetched via Grok Bot local-exec (the user\'s computer). You CAN review them. Do not claim you lack filesystem access.',
887
+ });
888
+ messages.push({ role: 'user', content: bits.join('\n\n') });
889
+ }
890
+ messages.push({ role: 'user', content: prompt || 'hello' });
891
+ const payload = {
892
+ model,
893
+ messages,
894
+ max_tokens: Number(process.env.OPENZOO_ASK_MAX_TOKENS || 2048),
895
+ };
896
+ const post = () => fetch('http://127.0.0.1:8402/v1/chat/completions', {
897
+ method: 'POST',
898
+ headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
899
+ body: JSON.stringify(payload),
900
+ signal: AbortSignal.timeout(120000),
901
+ });
902
+ let r = await post();
903
+ // x402 dwell: proxy usually pays internally; a leftover 402 is retryable.
904
+ if (r.status === 402) {
905
+ log('cursor-backend: x402 402 — dwell/retry');
906
+ await new Promise((ok) => setTimeout(ok, 2500));
907
+ r = await post();
908
+ }
909
+ const data = await r.json();
910
+ let text = data.choices?.[0]?.message?.content || data.error?.message || '(empty zoo reply)';
911
+ try { text += await zooSpendOverlay(data); } catch { /* overlay must never eat the reply */ }
912
+ log(`cursor-backend: << zoo ${r.status} ${text.length}c`);
913
+ return { text, data };
914
+ }
915
+
916
+ async function handleHijackedPodHttp(req, res, full, body, log) {
917
+ if (sniffOn()) {
918
+ const waiting = (full || '').split('?')[0];
919
+ const podPath = waiting === '/health' || waiting === '/healthz' || waiting === '/events'
920
+ || waiting.startsWith('/api/') || waiting.startsWith('/webauthn/')
921
+ || waiting.startsWith('/cookie-origin-approval/') || waiting.startsWith('/local-exec/');
922
+ if (realPod?.agent && podPath) return proxyPodHttp(req, res, full, body, log);
923
+ if (!podPath) return false;
924
+ if (waiting === '/health' || waiting === '/healthz') {
925
+ jsonSend(res, { ok: true, status: 'ok', ready: true });
926
+ return true;
927
+ }
928
+ return false;
929
+ }
930
+ const path0 = (full || '').split('?')[0];
931
+ if (path0 === '/health' || path0 === '/healthz') {
932
+ jsonSend(res, { ok: true, status: 'ok', ready: true });
933
+ log('cursor-backend: -> pod /health ok');
934
+ return true;
935
+ }
936
+ if (path0 === '/events') {
937
+ res.writeHead(200, {
938
+ 'content-type': 'text/event-stream',
939
+ 'cache-control': 'no-cache',
940
+ ...CORS,
941
+ });
942
+ res.write('data: {"channel":"ping","payload":{}}\n\n');
943
+ sseClients.add(res);
944
+ const iv = setInterval(() => {
945
+ try { res.write('data: {"channel":"ping","payload":{}}\n\n'); } catch { clearInterval(iv); sseClients.delete(res); }
946
+ }, 15000);
947
+ req.on('close', () => { clearInterval(iv); sseClients.delete(res); });
948
+ log('cursor-backend: -> pod /events sse');
949
+ return true;
950
+ }
951
+ if (path0.startsWith('/webauthn/') || path0.startsWith('/cookie-origin-approval/')) {
952
+ if (req.method === 'GET') jsonSend(res, []);
953
+ else jsonSend(res, { ok: true });
954
+ log(`cursor-backend: -> pod ${path0}`);
955
+ return true;
956
+ }
957
+ if (!path0.startsWith('/api/')) return false;
958
+ const name = path0.slice('/api/'.length);
959
+ // Roster/settings come from the REAL 1340 gateway (names, avatars, trays).
960
+ // Chat stays local so inference is zoo. Discovered on EnsureSandBox rewrite.
961
+ const roster = new Set([
962
+ 'listAgents', 'countAgents', 'searchAgents', 'getTrays', 'getHostSettings',
963
+ 'setHostSettings', 'getAgentChannels', 'getAgentWorkflows', 'skillsCatalog',
964
+ 'getSubagents', 'getAsyncTasks', 'getForeverBoxStatus', 'getSharingState',
965
+ 'getBotTemplateExportPolicy', 'getTeachRecordingStatus', 'isGlobalSearchEnabled',
966
+ 'isEgressTunnelAvailable', 'listBoxMcpServers', 'getHostStatus',
967
+ 'setWindowFocused', 'getAgentAutomations',
968
+ 'createAgent', 'createAgentFromTemplate', 'createGroup', 'setGroupMembers',
969
+ 'updateAgent', 'deleteAgents', 'duplicateAgent', 'kickstartAgent',
970
+ 'interruptAgentRun', 'requestDiskSaverAudit', 'broadcastToAgents',
971
+ 'setAgentUnread', 'setAgentHiddenFromSidebar', 'setAgentNotificationsEnabled',
972
+ 'setAgentNotifyOnUpdates', 'setAgentAvatarBytes', 'getAgentAvatar',
973
+ ]);
974
+ if (!sniffOn() && realPod?.agent && roster.has(name)) {
975
+ return proxyPodHttp(req, res, full, body, log);
976
+ }
977
+
978
+ // THE ACTUAL CHAT PATH. Grok Bot does not send StreamUnifiedChat on this
979
+ // surface — measured: POST /api/sendPrompt 374b/462b after EnsureSandBox
980
+ // hijack. Stubbing {ok:true} ate the prompt. Forward to the paying proxy.
981
+ if (name === 'sendPrompt') {
982
+ let parsed = {};
983
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
984
+ const prompt = promptFromSendBody(parsed);
985
+ const agentId = String(parsed.agentId || parsed.id || 'openzoo');
986
+ const nonce = parsed.clientNonce || `oz-${Date.now()}`;
987
+ log(`cursor-backend: >> sendPrompt agent=${agentId} keys=${Object.keys(parsed).join(',')} prompt=${JSON.stringify((prompt || '').slice(0, 80))}`);
988
+ lastSendEchoId = String(nonce);
989
+ const userLine = fanoutLine(agentId, 'user', prompt, {
990
+ clientNonce: nonce,
991
+ requestId: nonce,
992
+ richText: parsed.richText,
993
+ });
994
+ ssePush('transcript', { ...gatewayEntry(userLine), agentId });
995
+ jsonSend(res, { accepted: true });
996
+ const modelCmd = /^\s*\/model(?:\s+(\S+))?\s*$/i.exec(prompt || '');
997
+ setImmediate(async () => {
998
+ let text = '';
338
999
  try {
339
- const dir = '/tmp/openzoo-sniff';
340
- fs.mkdirSync(dir, { recursive: true });
341
- const safe = full.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 80);
342
- fs.writeFileSync(`${dir}/${Date.now()}_${safe}.bin`, buf);
343
- } catch { /* dumping must never break the proxy */ }
1000
+ if (modelCmd) {
1001
+ const want = modelCmd[1];
1002
+ if (!want) {
1003
+ const cur = agentModels.get(agentId) || process.env.OPENZOO_DEFAULT_MODEL || 'anthropic/claude-opus-5';
1004
+ text = `current model: ${cur}\nset with /model fable | opus | sonnet | grok | provider/id`;
1005
+ } else {
1006
+ const id = resolveModelId(want) || (want.includes('/') ? want : null);
1007
+ if (!id) {
1008
+ text = `unknown model "${want}". try /model fable | opus | sonnet | grok or a full id like anthropic/claude-fable-5`;
1009
+ } else {
1010
+ agentModels.set(agentId, id);
1011
+ text = `model set to ${id}`;
1012
+ }
1013
+ }
1014
+ try { text += await zooSpendOverlay({}); } catch { /* */ }
1015
+ } else {
1016
+ const z = await zooComplete(prompt, log, agentId);
1017
+ text = z.text;
1018
+ }
1019
+ } catch (e) {
1020
+ text = `openzoo error: ${e.message}`;
1021
+ log(`cursor-backend: sendPrompt zoo failed: ${e.message}`);
1022
+ }
1023
+ const line = fanoutLine(agentId, 'assistant', text, { clientNonce: nonce, requestId: nonce });
1024
+ ssePush('transcript', { ...gatewayEntry(line), agentId });
1025
+ log(`cursor-backend: sendPrompt done agent=${agentId} seq=${line.seq} text=${JSON.stringify(text.slice(0, 80))}`);
1026
+ });
1027
+ return true;
1028
+ }
1029
+ if (name === 'getAgentTranscriptTail' || name === 'getAgentTranscriptWindow' || name === 'openAgentTail') {
1030
+ let parsed = {};
1031
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
1032
+ let id = String(parsed.id || parsed.agentId || 'openzoo');
1033
+ tailedAgents.add(id);
1034
+ let t = agentTranscript(id);
1035
+ if (!t.entries.length) {
1036
+ for (const [other, ot] of transcripts) {
1037
+ if (ot.entries.length) { id = other; t = ot; break; }
1038
+ }
344
1039
  }
345
- log(`cursor-backend: ${req.method} ${full} -> REAL ${host} (${status}, ${buf.length}b)`);
346
- } catch (e) {
347
- res.writeHead(502, { 'content-type': 'application/json' });
348
- res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `passthrough failed: ${e.message}` } }));
349
- log(`cursor-backend: passthrough ${full} FAILED: ${e.message}`);
1040
+ const limit = Math.min(Number(parsed.limit) || 50, 200);
1041
+ const before = parsed.beforeSeq != null ? Number(parsed.beforeSeq) : Infinity;
1042
+ const sliced = t.entries.filter((e) => e.seq < before).slice(-limit);
1043
+ const page = { entries: sliced.map(gatewayEntry) };
1044
+ if (t.entries.length > sliced.length && sliced.length) page.nextBeforeSeq = sliced[0].seq;
1045
+ // Live pod returns RAW {entries, nextBeforeSeq} — no CVr envelope
1046
+ // (measured 1340 getAgentTranscriptTail 2026-08-29). Wrapping {status,value}
1047
+ // made transcript-page validation fail and the canvas stayed empty.
1048
+ jsonSend(res, page);
1049
+ log(`cursor-backend: -> transcript ${id} n=${sliced.length}/${t.seq}`);
1050
+ return true;
1051
+ }
1052
+ if (name === 'promptAcceptanceStatus') {
1053
+ let parsed = {};
1054
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
1055
+ const nonce = String(parsed.clientNonce || lastSendEchoId);
1056
+ const agentId = String(parsed.agentId || '');
1057
+ jsonSend(res, {
1058
+ outcome: 'found',
1059
+ record: {
1060
+ status: 'accepted',
1061
+ acceptedAtMs: Date.now(),
1062
+ echoEntryId: nonce,
1063
+ clientNonce: nonce,
1064
+ agentId,
1065
+ inputDigest: '',
1066
+ },
1067
+ });
1068
+ log(`cursor-backend: -> promptAcceptanceStatus found echo=${nonce}`);
1069
+ return true;
350
1070
  }
1071
+
1072
+ const stubs = {
1073
+ getHostStatus: { status: 'ready', ready: true, state: 'ready', hostStatus: 'ready' },
1074
+ getHostSettings: { settings: {} },
1075
+ setHostSettings: { ok: true },
1076
+ setBoxSecrets: { ok: true },
1077
+ listAgents: [...new Set([...transcripts.keys(), ...tailedAgents])].map((id) => ({ id, name: id, status: 'ready' })),
1078
+ getAgentTranscriptTail: { tail: '', lines: [], dropped: false, ok: true },
1079
+ getTeachRecordingStatus: { recording: false },
1080
+ getTrays: { trays: [] },
1081
+ isGlobalSearchEnabled: { enabled: false },
1082
+ isEgressTunnelAvailable: { available: false },
1083
+ getSharingState: { sharing: false },
1084
+ getBotTemplateExportPolicy: { allowed: true },
1085
+ getAgentAutomations: { automations: [] },
1086
+ getForeverBoxStatus: { enabled: false },
1087
+ getSubagents: { subagents: [] },
1088
+ getAsyncTasks: { tasks: [] },
1089
+ getAgentWorkflows: { workflows: [] },
1090
+ setWindowFocused: { ok: true },
1091
+ };
1092
+ const payload = stubs[name] !== undefined ? stubs[name] : { ok: true };
1093
+ // Live 1340 listAgents is a RAW array, not CVr (measured 104674b 2026-08-29).
1094
+ if (name === 'listAgents') jsonSend(res, payload);
1095
+ else jsonApi(res, payload);
1096
+ log(`cursor-backend: -> pod /api/${name}`);
1097
+ return true;
351
1098
  }
352
1099
 
353
1100
  /**
@@ -494,13 +1241,38 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
494
1241
  {
495
1242
  const path0 = full.split('?')[0];
496
1243
  const oauth = /^\/oauth(\/|$)/.test(path0);
497
- const needRealPod = /GrokBotService\/(EnsureSandBox|WatchSandBoxMigration)/.test(full)
498
- && !process.env.OZ_HIJACK_POD;
499
- if (oauth || needRealPod) {
1244
+ // Coordinator talks to /sand-box/local-exec-daemon-credential. empty-ok
1245
+ // here is ControlPortCallError: main-execution-failure: fetch failed.
1246
+ const sandBox = /^\/sand-box(\/|$)/.test(path0);
1247
+ const grokCred = /IssueGrokBotUserComputerCredential/.test(full);
1248
+ const sniffBox = sniffOn() && /GrokBotService\/(EnsureSandBox|WatchSandBoxMigration)/.test(full);
1249
+ const needRealPod = /GrokBotService\/EnsureSandBox/.test(full)
1250
+ && !process.env.OZ_HIJACK_POD
1251
+ && !sniffBox;
1252
+ if (sniffBox) {
1253
+ if (/WatchSandBoxMigration/.test(full) && realPod) {
1254
+ const payload = rewrittenBox();
1255
+ res.writeHead(200, {
1256
+ 'content-type': 'application/connect+proto',
1257
+ 'grpc-status': '0',
1258
+ ...CORS,
1259
+ });
1260
+ const end = Buffer.from('{}');
1261
+ const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
1262
+ res.end(Buffer.concat([envelope(payload), h, end]));
1263
+ log('cursor-backend: SNIFF WatchSandBoxMigration ready (rewritten)');
1264
+ return;
1265
+ }
1266
+ await sniffEnsureSandBox(req, res, body, host, full, log);
1267
+ return;
1268
+ }
1269
+ if (oauth || sandBox || grokCred || needRealPod) {
500
1270
  const upstream = /cursor\.sh$/.test(host) ? host : 'api2.cursor.sh';
501
1271
  await passthroughToRealAnthropic(req, res, body, upstream, full, log);
502
1272
  return;
503
1273
  }
1274
+ if (await handleLocalExecHttp(req, res, path0, body, log)) return;
1275
+ if (await handleHijackedPodHttp(req, res, full, body, log)) return;
504
1276
  }
505
1277
  // HIJACK EnsureSandBox FIRST — before passthrough, or passthrough eats it.
506
1278
  // MEASURED: with OPENZOO_PASSTHRU=1 set, EnsureSandBox went `-> REAL
@@ -508,8 +1280,44 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
508
1280
  // never our box. This method (and ONLY this one) must be answered locally
509
1281
  // with OUR box so Grok Bot's UI wires to our sandbox; everything else
510
1282
  // still passes through so the app loads normally.
511
- if (/GrokBotService\/EnsureSandBox/.test(full) && process.env.OZ_HIJACK_POD) {
512
- respond(req, res, method, models); // encodeForMethod('EnsureSandBox') reads OZ_HIJACK_POD
1283
+ if (/GrokBotService\/(EnsureSandBox|WatchSandBoxMigration)/.test(full) && process.env.OZ_HIJACK_POD) {
1284
+ if (/WatchSandBoxMigration/.test(full) && realPod) {
1285
+ const payload = rewrittenBox();
1286
+ res.writeHead(200, {
1287
+ 'content-type': 'application/connect+proto',
1288
+ 'grpc-status': '0',
1289
+ ...CORS,
1290
+ });
1291
+ const end = Buffer.from('{}');
1292
+ const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
1293
+ res.end(Buffer.concat([envelope(payload), h, end]));
1294
+ log('cursor-backend: -> WatchSandBoxMigration ready (hijack, real roster)');
1295
+ return;
1296
+ }
1297
+ try {
1298
+ process.env.OZ_SNIFF_SELF = process.env.OZ_SNIFF_SELF || 'https://127.0.0.1:8443';
1299
+ await sniffEnsureSandBox(req, res, body, host, full, log);
1300
+ log('cursor-backend: -> HIJACKED EnsureSandBox -> our box (roster from real 1340)');
1301
+ return;
1302
+ } catch (e) {
1303
+ log(`cursor-backend: EnsureSandBox discover failed (${e.message}) — env box`);
1304
+ }
1305
+ let pod;
1306
+ try { pod = JSON.parse(process.env.OZ_HIJACK_POD); } catch { pod = null; }
1307
+ if (pod && /WatchSandBoxMigration/.test(full)) {
1308
+ const payload = encodeEnsureSandBox(pod);
1309
+ res.writeHead(200, {
1310
+ 'content-type': 'application/connect+proto',
1311
+ 'grpc-status': '0',
1312
+ ...CORS,
1313
+ });
1314
+ const end = Buffer.from('{}');
1315
+ const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
1316
+ res.end(Buffer.concat([envelope(payload), h, end]));
1317
+ log('cursor-backend: -> WatchSandBoxMigration ready (hijack)');
1318
+ return;
1319
+ }
1320
+ respond(req, res, 'EnsureSandBox', models);
513
1321
  log(`cursor-backend: -> HIJACKED EnsureSandBox -> our box`);
514
1322
  return;
515
1323
  }
@@ -519,6 +1327,50 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
519
1327
  await handleStreamChat(req, res, body, log);
520
1328
  return;
521
1329
  }
1330
+ // Telegram/"Failed to send": GetGrokBotSendStatus empty proto is
1331
+ // UNSPECIFIED. ACCEPTED=2. WatchGrokBotUserComputerRequests is a
1332
+ // long-lived stream — empty-ok closes it and the channel looks offline.
1333
+ if (/GetGrokBotSendStatus/.test(full)) {
1334
+ if (sniffOn()) {
1335
+ const fields = decodeProtoFields(body);
1336
+ sniffDump({ kind: 'GetGrokBotSendStatus.req', fields });
1337
+ const upstream = /cursor\.sh$/.test(host) ? host : 'api2.cursor.sh';
1338
+ await passthroughToRealAnthropic(req, res, body, upstream, full, log);
1339
+ return;
1340
+ }
1341
+ const fields = decodeProtoFields(body);
1342
+ const echoId = String(fields[2] || lastSendEchoId);
1343
+ lastSendEchoId = echoId;
1344
+ const payload = encodeGetGrokBotSendStatus(echoId);
1345
+ const reqCt = String(req.headers['content-type'] || '');
1346
+ if (reqCt.includes('grpc-web')) {
1347
+ res.writeHead(200, {
1348
+ 'content-type': reqCt.includes('text') ? 'application/grpc-web-text+proto' : 'application/grpc-web+proto',
1349
+ 'grpc-status': '0', ...CORS,
1350
+ });
1351
+ res.end(Buffer.concat([envelope(payload), grpcWebTrailer()]));
1352
+ } else {
1353
+ res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
1354
+ res.end(payload);
1355
+ }
1356
+ log(`cursor-backend: -> GetGrokBotSendStatus ACCEPTED echo=${echoId}`);
1357
+ return;
1358
+ }
1359
+ if (/WatchGrokBotUserComputerRequests/.test(full)) {
1360
+ if (sniffOn()) {
1361
+ const upstream = /cursor\.sh$/.test(host) ? host : 'api2.cursor.sh';
1362
+ await passthroughPipe(req, res, body, upstream, full, log);
1363
+ return;
1364
+ }
1365
+ res.writeHead(200, {
1366
+ 'content-type': 'application/connect+proto',
1367
+ 'grpc-status': '0',
1368
+ ...CORS,
1369
+ });
1370
+ req.on('close', () => { try { res.end(); } catch { /* */ } });
1371
+ log('cursor-backend: -> WatchGrokBotUserComputerRequests held');
1372
+ return;
1373
+ }
522
1374
  // FULL PASSTHROUGH MODE — observe, do not stub.
523
1375
  //
524
1376
  // Stubbing unknown methods with empty protobufs BREAKS Grok Bot: it never
package/lib/grokcli.js CHANGED
@@ -98,6 +98,106 @@ ${body}
98
98
  `;
99
99
  }
100
100
 
101
+ /**
102
+ * `openzoo bot` — Grok Bot.app on the zoo, no sudo, no /etc/hosts.
103
+ *
104
+ * CURSOR_API_BASE_URL (and SAND_BACKEND_URL) are what the Electron MAIN
105
+ * process actually reads. Chromium --host-resolver-rules do not. Measured:
106
+ * a bare `127.0.0.1:443` is HTTP and logs ERR_SSL_HTTP_REQUEST; the URL
107
+ * must be https://127.0.0.1:8443. The TLS warning on launch is Node
108
+ * complaining about NODE_TLS_REJECT_UNAUTHORIZED=0 — expected, the cert
109
+ * is ours and self-signed.
110
+ *
111
+ * We bind 8443 ourselves (unprivileged). :443 is the old root takeover and
112
+ * stubs oauth if it is the unpatched npx copy — do not point the app there.
113
+ */
114
+ export async function runBot(argv = []) {
115
+ const port = 8443;
116
+ const url = `https://127.0.0.1:${port}`;
117
+ const bin = `${APP}/Contents/MacOS/Grok Bot`;
118
+ if (!existsSync(bin)) {
119
+ console.error('openzoo: Grok Bot.app not found at', APP);
120
+ process.exit(1);
121
+ }
122
+
123
+ const proxyBase = `http://localhost:${config.port}/v1`;
124
+ let up = false;
125
+ try { up = (await fetch(`${proxyBase}/models`, { signal: AbortSignal.timeout(2000) })).ok; } catch { up = false; }
126
+ if (!up) {
127
+ console.error('openzoo: starting proxy on', proxyBase);
128
+ const { startProxy } = await import('./proxy.js');
129
+ await startProxy({ silent: true, autoTunnel: process.env.OPENZOO_NO_TUNNEL === '1' ? false : true });
130
+ }
131
+
132
+ process.env.OPENZOO_BYOK = '1';
133
+ const sniff = argv.includes('--sniff') || argv.includes('--passthru');
134
+ if (sniff) {
135
+ // REAL pod, we only sit in the middle. EnsureSandBox is rewritten so
136
+ // /api/sendPrompt and getAgentTranscriptTail land here, then we proxy
137
+ // them to cursorvm and dump the live JSON. That is the comparison
138
+ // surface — hijack guesses have been wrong twice.
139
+ delete process.env.OZ_HIJACK_POD;
140
+ process.env.OPENZOO_PASSTHRU = '1';
141
+ process.env.OPENZOO_SNIFF = '1';
142
+ process.env.OZ_SNIFF_SELF = url;
143
+ } else {
144
+ // Do NOT hand Grok Bot a real cursorvm pod — inference then never leaves
145
+ // Anysphere (measured: EnsureSandBox -> REAL api2, then StreamUnifiedChat
146
+ // never arrives). Point the sandbox URLs at THIS aiserver; StreamUnifiedChat
147
+ // is answered from :8402 (x402).
148
+ process.env.OZ_HIJACK_POD = JSON.stringify({
149
+ region: 'local',
150
+ accountId: 'openzoo',
151
+ podId: 'openzoo-local',
152
+ token: 'openzoo',
153
+ agent: url,
154
+ vnc: url,
155
+ p1340: url,
156
+ p6081: url,
157
+ });
158
+ process.env.OZ_SNIFF_SELF = url;
159
+ }
160
+ const { startCursorBackend } = await import('./cursorbackend.js');
161
+ const models = [
162
+ 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4.1', 'gpt-4.1-mini', 'gpt-3.5-turbo',
163
+ ].map((n) => ({ name: n, label: n }));
164
+ const log = (m) => console.error(' backend:', m);
165
+ try {
166
+ startCursorBackend({ port, models, log });
167
+ } catch (e) {
168
+ console.error('openzoo: 8443 already bound — reusing it (', e.message, ')');
169
+ }
170
+ await new Promise((r) => setTimeout(r, 400));
171
+ console.error(`openzoo: aiserver on ${url}`);
172
+ console.error(' oauth + /sand-box creds -> real api2');
173
+ if (sniff) {
174
+ console.error(' SNIFF: real EnsureSandBox, /api/* proxied, dump ~/.openzoo/grokbot-sniff.jsonl');
175
+ console.error(' send a message in Grok Bot — it should paint for real');
176
+ } else {
177
+ console.error(' EnsureSandBox HIJACKED here; StreamUnifiedChat -> :8402 (x402)');
178
+ }
179
+
180
+ try { execSync('osascript -e \'tell application "Grok Bot" to quit\'', { stdio: 'ignore' }); } catch { /* ok */ }
181
+ await new Promise((r) => setTimeout(r, 1500));
182
+
183
+ console.error('openzoo: launching Grok Bot');
184
+ console.error(` CURSOR_API_BASE_URL=${url}`);
185
+ spawn(bin, ['--ignore-certificate-errors'], {
186
+ stdio: 'ignore',
187
+ detached: true,
188
+ env: {
189
+ ...process.env,
190
+ NODE_TLS_REJECT_UNAUTHORIZED: '0',
191
+ CURSOR_API_BASE_URL: url,
192
+ SAND_BACKEND_URL: url,
193
+ },
194
+ }).unref();
195
+
196
+ console.error('openzoo: leave this running. ctrl-c stops the backend.');
197
+ if (argv.includes('--once')) return;
198
+ await new Promise(() => {});
199
+ }
200
+
101
201
  export async function setupGrokBot(argv = []) {
102
202
  const base = `http://localhost:${config.port}/v1`;
103
203
  const launch = !argv.includes('--no-launch');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.19",
3
+ "version": "0.50.20",
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",