openzoo 0.50.19 → 0.50.21
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 +7 -0
- package/lib/cursorapi.js +78 -3
- package/lib/cursorbackend.js +1180 -58
- package/lib/grokcli.js +106 -0
- package/package.json +1 -1
package/lib/cursorbackend.js
CHANGED
|
@@ -31,8 +31,12 @@ 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
|
|
35
|
-
import {
|
|
34
|
+
import zlib from 'node:zlib';
|
|
35
|
+
import { execFileSync, execFile } from 'node:child_process';
|
|
36
|
+
import { promisify } from 'node:util';
|
|
37
|
+
import tls from 'node:tls';
|
|
38
|
+
import { randomUUID } from 'node:crypto';
|
|
39
|
+
import { encodeAvailableModels, encodeForMethod, encodeEnsureSandBox, encodeGetGrokBotSendStatus, decodeProtoFields, unwrapConnect } from './cursorapi.js';
|
|
36
40
|
|
|
37
41
|
const TLS_DIR = path.join(os.homedir(), '.openzoo', 'cursor-tls');
|
|
38
42
|
const CURSOR_HOSTS = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'repo42.cursor.sh'];
|
|
@@ -284,70 +288,1073 @@ const RESP_DROP = new Set([
|
|
|
284
288
|
'transfer-encoding', 'te', 'trailer', 'content-length',
|
|
285
289
|
]);
|
|
286
290
|
|
|
291
|
+
const SNIFF_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-sniff.jsonl');
|
|
292
|
+
let realPod = null; // { agent, vnc, token, p1340, p6081, region, accountId, podId }
|
|
293
|
+
|
|
294
|
+
function sniffOn() { return process.env.OPENZOO_SNIFF === '1'; }
|
|
295
|
+
function sniffSelf() { return process.env.OZ_SNIFF_SELF || 'https://127.0.0.1:8443'; }
|
|
296
|
+
/** CURSOR_API_BASE_URL=https://127.0.0.1:8443 makes Host 127.0.0.1 — public DNS
|
|
297
|
+
* of that name is ENOTFOUND (measured sniff #3 /events, #4 listAgents). */
|
|
298
|
+
function cursorUpstream(host) {
|
|
299
|
+
const h = String(host || '').replace(/:\d+$/, '');
|
|
300
|
+
if (/cursor\.sh$/.test(h) || /anthropic\.com$/.test(h) || /cursorvm\.com$/.test(h)) return h;
|
|
301
|
+
return 'api2.cursor.sh';
|
|
302
|
+
}
|
|
303
|
+
function sniffDump(rec) {
|
|
304
|
+
if (!sniffOn()) return;
|
|
305
|
+
try {
|
|
306
|
+
fs.mkdirSync(path.dirname(SNIFF_FILE), { recursive: true, mode: 0o700 });
|
|
307
|
+
fs.appendFileSync(SNIFF_FILE, JSON.stringify({ at: new Date().toISOString(), ...rec }) + '\n');
|
|
308
|
+
} catch { /* dump must never break the proxy */ }
|
|
309
|
+
}
|
|
310
|
+
function jsonish(buf, limit = 12000) {
|
|
311
|
+
const s = Buffer.isBuffer(buf) ? buf.toString('utf8') : String(buf || '');
|
|
312
|
+
try { return JSON.parse(s); } catch { return s.slice(0, limit); }
|
|
313
|
+
}
|
|
314
|
+
function copyReqHeaders(req, host) {
|
|
315
|
+
const headers = {};
|
|
316
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
317
|
+
if (k.startsWith(':') || HOP_BY_HOP.has(k)) continue;
|
|
318
|
+
headers[k] = Array.isArray(v) ? v.join(', ') : String(v);
|
|
319
|
+
}
|
|
320
|
+
if (host) headers.host = host;
|
|
321
|
+
delete headers['content-length'];
|
|
322
|
+
return headers;
|
|
323
|
+
}
|
|
324
|
+
function lookupPinned(ip) {
|
|
325
|
+
return (h, o, cb) => (o && o.all ? cb(null, [{ address: ip, family: 4 }]) : cb(null, ip, 4));
|
|
326
|
+
}
|
|
327
|
+
function inflateBody(buf, headers) {
|
|
328
|
+
const enc = String(headers?.['content-encoding'] || '').toLowerCase();
|
|
329
|
+
try {
|
|
330
|
+
if (enc.includes('gzip')) return zlib.gunzipSync(buf);
|
|
331
|
+
if (enc.includes('deflate')) return zlib.inflateSync(buf);
|
|
332
|
+
if (enc.includes('br')) return zlib.brotliDecompressSync(buf);
|
|
333
|
+
} catch { /* fall through */ }
|
|
334
|
+
if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) {
|
|
335
|
+
try { return zlib.gunzipSync(buf); } catch { /* */ }
|
|
336
|
+
}
|
|
337
|
+
return buf;
|
|
338
|
+
}
|
|
339
|
+
function writeCaptured(res, status, respHeaders, buf) {
|
|
340
|
+
const h = {};
|
|
341
|
+
for (const [k, v] of Object.entries(respHeaders || {})) {
|
|
342
|
+
if (RESP_DROP.has(k.toLowerCase())) continue;
|
|
343
|
+
h[k] = v;
|
|
344
|
+
}
|
|
345
|
+
res.writeHead(status, h);
|
|
346
|
+
res.end(buf);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function upstreamUnary({ host, path: pth, method, headers, body, timeoutMs = 20000 }) {
|
|
350
|
+
const ip = await resolveRealAnthropic(host);
|
|
351
|
+
return new Promise((resolve, reject) => {
|
|
352
|
+
const up = https.request({
|
|
353
|
+
hostname: host, servername: host, port: 443, path: pth,
|
|
354
|
+
method, headers, lookup: lookupPinned(ip),
|
|
355
|
+
}, (r) => {
|
|
356
|
+
const chunks = [];
|
|
357
|
+
r.on('data', (d) => chunks.push(d));
|
|
358
|
+
r.on('end', () => resolve({
|
|
359
|
+
status: r.statusCode, respHeaders: r.headers, buf: Buffer.concat(chunks),
|
|
360
|
+
}));
|
|
361
|
+
r.on('error', reject);
|
|
362
|
+
});
|
|
363
|
+
up.on('error', reject);
|
|
364
|
+
if (timeoutMs) {
|
|
365
|
+
up.setTimeout(timeoutMs, () => {
|
|
366
|
+
reject(new Error('upstream timeout'));
|
|
367
|
+
try { up.destroy(); } catch { /* */ }
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
if (method !== 'GET' && method !== 'HEAD' && body && body.length) up.write(body);
|
|
371
|
+
up.end();
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
287
375
|
async function passthroughToRealAnthropic(req, res, body, host, full, log) {
|
|
288
376
|
try {
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
377
|
+
host = cursorUpstream(host);
|
|
378
|
+
const streaming = /Watch|Stream|Subscribe/i.test(full);
|
|
379
|
+
if (streaming) {
|
|
380
|
+
await passthroughPipe(req, res, body, host, full, log);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const headers = copyReqHeaders(req, host);
|
|
384
|
+
const cap = await upstreamUnary({
|
|
385
|
+
host, path: full, method: req.method, headers, body, timeoutMs: 20000,
|
|
386
|
+
});
|
|
387
|
+
if (process.env.OPENZOO_DUMP === '1' && cap.buf.length) {
|
|
388
|
+
try {
|
|
389
|
+
const dir = '/tmp/openzoo-sniff';
|
|
390
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
391
|
+
const safe = full.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 80);
|
|
392
|
+
fs.writeFileSync(`${dir}/${Date.now()}_${safe}.bin`, cap.buf);
|
|
393
|
+
} catch { /* dumping must never break the proxy */ }
|
|
294
394
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
395
|
+
writeCaptured(res, cap.status, cap.respHeaders, cap.buf);
|
|
396
|
+
log(`cursor-backend: ${req.method} ${full} -> REAL ${host} (${cap.status}, ${cap.buf.length}b)`);
|
|
397
|
+
} catch (e) {
|
|
398
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
399
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message: `passthrough failed: ${e.message}` } }));
|
|
400
|
+
log(`cursor-backend: passthrough ${full} FAILED: ${e.message}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function passthroughPipe(req, res, body, host, full, log) {
|
|
405
|
+
host = cursorUpstream(host);
|
|
406
|
+
const ip = await resolveRealAnthropic(host);
|
|
407
|
+
const headers = copyReqHeaders(req, host);
|
|
408
|
+
await new Promise((resolve, reject) => {
|
|
409
|
+
const up = https.request({
|
|
410
|
+
hostname: host, servername: host, port: 443, path: full,
|
|
411
|
+
method: req.method, headers, lookup: lookupPinned(ip),
|
|
412
|
+
}, (r) => {
|
|
413
|
+
const h = {};
|
|
414
|
+
for (const [k, v] of Object.entries(r.headers || {})) {
|
|
415
|
+
if (RESP_DROP.has(k.toLowerCase())) continue;
|
|
416
|
+
h[k] = v;
|
|
417
|
+
}
|
|
418
|
+
res.writeHead(r.statusCode, h);
|
|
419
|
+
r.pipe(res);
|
|
420
|
+
r.on('end', resolve);
|
|
421
|
+
r.on('error', reject);
|
|
422
|
+
});
|
|
423
|
+
up.on('error', reject);
|
|
424
|
+
req.on('close', () => { try { up.destroy(); } catch { /* */ } });
|
|
425
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && body && body.length) up.write(body);
|
|
426
|
+
up.end();
|
|
427
|
+
});
|
|
428
|
+
log(`cursor-backend: -> PIPE ${full} -> REAL ${host}`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function rememberPod(fields, log) {
|
|
432
|
+
if (!fields || !fields[6]) return null;
|
|
433
|
+
const execDaemon = String(fields[6] || '');
|
|
434
|
+
const gateway = String(fields[10] || fields[6] || '');
|
|
435
|
+
realPod = {
|
|
436
|
+
execDaemon,
|
|
437
|
+
agent: gateway, // /api/sendPrompt lives on gateway_url (1340), not exec_daemon (1337)
|
|
438
|
+
vnc: fields[7] ? String(fields[7]).split('/vnc.html')[0] : gateway,
|
|
439
|
+
vncPath: fields[7] ? String(fields[7]) : undefined,
|
|
440
|
+
token: String(fields[4] || ''),
|
|
441
|
+
accessToken: String(fields[11] || ''),
|
|
442
|
+
p1340: gateway,
|
|
443
|
+
p6081: fields[12] ? String(fields[12]) : undefined,
|
|
444
|
+
region: String(fields[1] || 'us1'),
|
|
445
|
+
accountId: String(fields[2] || ''),
|
|
446
|
+
podId: String(fields[3] || ''),
|
|
447
|
+
};
|
|
448
|
+
sniffDump({ kind: 'pod', fields, realPod });
|
|
449
|
+
log(`cursor-backend: SNIFF real pod ${realPod.agent}`);
|
|
450
|
+
return realPod;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function rewrittenBox() {
|
|
454
|
+
const self = sniffSelf();
|
|
455
|
+
const p = realPod || {};
|
|
456
|
+
return encodeEnsureSandBox({
|
|
457
|
+
region: p.region || 'us1',
|
|
458
|
+
accountId: p.accountId || 'openzoo',
|
|
459
|
+
podId: p.podId || 'openzoo-sniff',
|
|
460
|
+
token: p.token || 'openzoo',
|
|
461
|
+
accessToken: p.accessToken || p.token || 'openzoo',
|
|
462
|
+
agent: self,
|
|
463
|
+
vnc: self,
|
|
464
|
+
p1340: self,
|
|
465
|
+
p6081: self,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function sniffEnsureSandBox(req, res, body, host, full, log) {
|
|
470
|
+
const upstream = cursorUpstream(host);
|
|
471
|
+
const headers = copyReqHeaders(req, upstream);
|
|
472
|
+
const cap = await upstreamUnary({
|
|
473
|
+
host: upstream, path: full, method: req.method, headers, body, timeoutMs: 30000,
|
|
474
|
+
});
|
|
475
|
+
const raw = inflateBody(cap.buf, cap.respHeaders);
|
|
476
|
+
const proto = unwrapConnect(raw);
|
|
477
|
+
const fields = decodeProtoFields(proto);
|
|
478
|
+
rememberPod(fields, log);
|
|
479
|
+
sniffDump({
|
|
480
|
+
kind: 'EnsureSandBox',
|
|
481
|
+
status: cap.status,
|
|
482
|
+
bytes: cap.buf.length,
|
|
483
|
+
inflated: raw.length,
|
|
484
|
+
encoding: cap.respHeaders?.['content-encoding'] || '',
|
|
485
|
+
head: Buffer.from(raw.subarray(0, 24)).toString('hex'),
|
|
486
|
+
fields,
|
|
487
|
+
rewrittenTo: sniffSelf(),
|
|
488
|
+
});
|
|
489
|
+
const payload = realPod ? rewrittenBox() : proto;
|
|
490
|
+
const ct = String(req.headers['content-type'] || cap.respHeaders?.['content-type'] || '');
|
|
491
|
+
if (/WatchSandBoxMigration/.test(full) || ct.includes('connect+proto')) {
|
|
492
|
+
res.writeHead(200, {
|
|
493
|
+
'content-type': 'application/connect+proto',
|
|
494
|
+
'grpc-status': '0',
|
|
495
|
+
...CORS,
|
|
496
|
+
});
|
|
497
|
+
const end = Buffer.from('{}');
|
|
498
|
+
const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
|
|
499
|
+
res.end(Buffer.concat([envelope(payload), h, end]));
|
|
500
|
+
} else if (ct.includes('grpc-web')) {
|
|
501
|
+
res.writeHead(200, {
|
|
502
|
+
'content-type': ct.includes('text') ? 'application/grpc-web-text+proto' : 'application/grpc-web+proto',
|
|
503
|
+
'grpc-status': '0', ...CORS,
|
|
504
|
+
});
|
|
505
|
+
res.end(Buffer.concat([envelope(payload), grpcWebTrailer()]));
|
|
506
|
+
} else {
|
|
507
|
+
res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
|
|
508
|
+
res.end(payload);
|
|
509
|
+
}
|
|
510
|
+
log(`cursor-backend: SNIFF EnsureSandBox -> rewrite ${sniffSelf()} (${cap.status}, real ${cap.buf.length}b)`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function proxyPodHttp(req, res, full, body, log) {
|
|
514
|
+
if (!realPod?.agent) return false;
|
|
515
|
+
const agent = new URL(realPod.agent);
|
|
516
|
+
const headers = copyReqHeaders(req, agent.host);
|
|
517
|
+
if (realPod.accessToken && !headers.authorization && !headers.Authorization) {
|
|
518
|
+
headers.authorization = `Bearer ${realPod.accessToken}`;
|
|
519
|
+
}
|
|
520
|
+
if (realPod.token && !headers['x-anyrun-network-token']) {
|
|
521
|
+
headers['x-anyrun-network-token'] = realPod.token;
|
|
522
|
+
}
|
|
523
|
+
if (!headers['x-sand-slim-avatars']) headers['x-sand-slim-avatars'] = '1';
|
|
524
|
+
const path0 = (full || '').split('?')[0];
|
|
525
|
+
const interesting = /sendPrompt|Transcript|listAgents|promptAcceptance|openAgentTail|createAgent/i.test(path0);
|
|
526
|
+
if (path0 === '/events') {
|
|
527
|
+
const ip = await resolveRealAnthropic(agent.hostname);
|
|
528
|
+
await new Promise((resolve, reject) => {
|
|
307
529
|
const up = https.request({
|
|
308
|
-
hostname:
|
|
309
|
-
method: req.method, headers, lookup,
|
|
530
|
+
hostname: agent.hostname, servername: agent.hostname, port: 443, path: full,
|
|
531
|
+
method: req.method, headers, lookup: lookupPinned(ip),
|
|
310
532
|
}, (r) => {
|
|
533
|
+
const h = { ...CORS };
|
|
534
|
+
for (const [k, v] of Object.entries(r.headers || {})) {
|
|
535
|
+
if (RESP_DROP.has(k.toLowerCase())) continue;
|
|
536
|
+
h[k] = v;
|
|
537
|
+
}
|
|
538
|
+
res.writeHead(r.statusCode, h);
|
|
311
539
|
const chunks = [];
|
|
312
|
-
r.on('data', (d) =>
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
})
|
|
540
|
+
r.on('data', (d) => {
|
|
541
|
+
res.write(d);
|
|
542
|
+
if (chunks.length < 40) chunks.push(d);
|
|
543
|
+
});
|
|
544
|
+
r.on('end', () => {
|
|
545
|
+
sniffDump({ kind: 'sse', path: full, sample: Buffer.concat(chunks).toString('utf8').slice(0, 8000) });
|
|
546
|
+
try { res.end(); } catch { /* */ }
|
|
547
|
+
resolve();
|
|
548
|
+
});
|
|
316
549
|
r.on('error', reject);
|
|
317
550
|
});
|
|
318
551
|
up.on('error', reject);
|
|
319
|
-
|
|
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);
|
|
552
|
+
req.on('close', () => { try { up.destroy(); } catch { /* */ } });
|
|
324
553
|
up.end();
|
|
325
554
|
});
|
|
555
|
+
log('cursor-backend: SNIFF /events -> real pod (piped)');
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
const cap = await upstreamUnary({
|
|
560
|
+
host: agent.hostname,
|
|
561
|
+
path: full,
|
|
562
|
+
method: req.method,
|
|
563
|
+
headers,
|
|
564
|
+
body,
|
|
565
|
+
timeoutMs: path0 === '/health' ? 8000 : 120000,
|
|
566
|
+
});
|
|
567
|
+
writeCaptured(res, cap.status, cap.respHeaders, cap.buf);
|
|
568
|
+
const rec = {
|
|
569
|
+
kind: 'pod-http',
|
|
570
|
+
method: req.method,
|
|
571
|
+
path: path0,
|
|
572
|
+
status: cap.status,
|
|
573
|
+
reqBytes: body?.length || 0,
|
|
574
|
+
resBytes: cap.buf.length,
|
|
575
|
+
req: jsonish(body, 4000),
|
|
576
|
+
res: jsonish(inflateBody(cap.buf, cap.respHeaders), 16000),
|
|
577
|
+
};
|
|
578
|
+
sniffDump(rec);
|
|
579
|
+
if (interesting) {
|
|
580
|
+
log(`cursor-backend: SNIFF ${path0} ${cap.status} req=${JSON.stringify(rec.req).slice(0, 220)} res=${JSON.stringify(rec.res).slice(0, 500)}`);
|
|
581
|
+
} else {
|
|
582
|
+
log(`cursor-backend: SNIFF ${path0} -> real pod (${cap.status}, ${cap.buf.length}b)`);
|
|
583
|
+
}
|
|
584
|
+
return true;
|
|
585
|
+
} catch (e) {
|
|
586
|
+
log(`cursor-backend: SNIFF ${path0} FAILED ${e.message}`);
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** JSON for the in-pod agent HTTP API. Hijack points field-6 at THIS
|
|
592
|
+
* server, so GET /health and POST /api/* land here — empty protobuf on
|
|
593
|
+
* those is "unhealthy" and Grok Bot retries EnsureSandBox forever
|
|
594
|
+
* (measured: /health empty-ok then EnsureSandBox #1945+). */
|
|
595
|
+
function jsonSend(res, obj) {
|
|
596
|
+
res.writeHead(200, { 'content-type': 'application/json', ...CORS });
|
|
597
|
+
res.end(JSON.stringify(obj));
|
|
598
|
+
}
|
|
599
|
+
/** Box HTTP API envelope. Client parse (CVr) returns null unless both
|
|
600
|
+
* status==="ok" AND "value" in n — our unwrapped {ok:true}/{entries} was
|
|
601
|
+
* dropped, so Grok Bot never painted the zoo reply (measured: << zoo 200 91c
|
|
602
|
+
* then tail polls with Failed to send). */
|
|
603
|
+
function jsonApi(res, value) {
|
|
604
|
+
jsonSend(res, { status: 'ok', value });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const sseClients = new Set();
|
|
608
|
+
/** Gateway SSE parser (asar dispatchEventBlock) only reads `data:` lines and
|
|
609
|
+
* requires `{channel, payload}`. `event:` names are ignored. */
|
|
610
|
+
function ssePush(channel, payload) {
|
|
611
|
+
const chunk = `data: ${JSON.stringify({ channel, payload })}\n\n`;
|
|
612
|
+
for (const c of sseClients) {
|
|
613
|
+
try { c.write(chunk); } catch { sseClients.delete(c); }
|
|
614
|
+
}
|
|
615
|
+
}
|
|
326
616
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
617
|
+
/** Grok Bot Helper daemon: GET /local-exec/requests is SSE, POST /local-exec/responses
|
|
618
|
+
* is `{providerId, frames}`. We used to JSON-[] the GET which is "disconnected"
|
|
619
|
+
* (measured: Grok Bot "can't see files on your local computer"). */
|
|
620
|
+
const localExecSse = new Set();
|
|
621
|
+
const localExecWaiters = new Map();
|
|
622
|
+
let localExecHello = null;
|
|
623
|
+
function localExecPush(frame) {
|
|
624
|
+
const chunk = `data: ${JSON.stringify(frame)}\n\n`;
|
|
625
|
+
for (const c of localExecSse) {
|
|
626
|
+
try { c.write(chunk); } catch { localExecSse.delete(c); }
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function handleLocalExecFrames(frames, log) {
|
|
630
|
+
for (const f of frames || []) {
|
|
631
|
+
if (!f || typeof f !== 'object') continue;
|
|
632
|
+
if (f.kind === 'hello') {
|
|
633
|
+
localExecHello = f;
|
|
634
|
+
log(`cursor-backend: local-exec hello computer=${f.computerId || f.label || '?'} root=${f.localRoot || '?'}`);
|
|
331
635
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
636
|
+
const w = f.requestId && localExecWaiters.get(f.requestId);
|
|
637
|
+
if (!w) continue;
|
|
638
|
+
if (f.kind === 'file') {
|
|
639
|
+
const buf = Buffer.from(f.bytesBase64 || '', 'base64');
|
|
640
|
+
w.resolve({ kind: 'file', bytes: buf, text: buf.toString('utf8') });
|
|
641
|
+
localExecWaiters.delete(f.requestId);
|
|
642
|
+
} else if (f.kind === 'file-error' || f.kind === 'messages-error') {
|
|
643
|
+
w.reject(new Error(f.error || 'local-exec error'));
|
|
644
|
+
localExecWaiters.delete(f.requestId);
|
|
645
|
+
} else if (f.kind === 'client' || f.kind === 'control' || f.kind === 'result' || f.kind === 'exec-result') {
|
|
646
|
+
w.resolve({
|
|
647
|
+
kind: f.kind,
|
|
648
|
+
message: f.message || f.text || f.stdout || JSON.stringify(f),
|
|
649
|
+
stdout: f.stdout,
|
|
650
|
+
stderr: f.stderr,
|
|
651
|
+
exitCode: f.exitCode,
|
|
652
|
+
});
|
|
653
|
+
localExecWaiters.delete(f.requestId);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
function localExecAsk(frame, timeoutMs = 45000) {
|
|
658
|
+
const requestId = frame.requestId || randomUUID();
|
|
659
|
+
const job = { ...frame, requestId };
|
|
660
|
+
return new Promise((resolve, reject) => {
|
|
661
|
+
const t = setTimeout(() => {
|
|
662
|
+
localExecWaiters.delete(requestId);
|
|
663
|
+
reject(new Error('local-exec timeout — is Grok Bot Helper connected?'));
|
|
664
|
+
}, timeoutMs);
|
|
665
|
+
localExecWaiters.set(requestId, {
|
|
666
|
+
resolve: (v) => { clearTimeout(t); resolve(v); },
|
|
667
|
+
reject: (e) => { clearTimeout(t); reject(e); },
|
|
668
|
+
});
|
|
669
|
+
localExecPush(job);
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
async function handleLocalExecHttp(req, res, path0, body, log) {
|
|
673
|
+
if (!/^\/local-exec\//.test(path0)) return false;
|
|
674
|
+
if (req.method === 'GET' && /\/requests$/.test(path0)) {
|
|
675
|
+
res.writeHead(200, {
|
|
676
|
+
'content-type': 'text/event-stream',
|
|
677
|
+
'cache-control': 'no-cache',
|
|
678
|
+
...CORS,
|
|
679
|
+
});
|
|
680
|
+
localExecSse.add(res);
|
|
681
|
+
// Daemon parser (asar CNt) ignores unknown welcome; a comment ping is enough
|
|
682
|
+
// to keep the stream alive until it POSTs hello to /responses.
|
|
683
|
+
res.write(': openzoo local-exec\n\n');
|
|
684
|
+
const iv = setInterval(() => {
|
|
685
|
+
try { res.write(': ping\n\n'); } catch { clearInterval(iv); localExecSse.delete(res); }
|
|
686
|
+
}, 10000);
|
|
687
|
+
req.on('close', () => { clearInterval(iv); localExecSse.delete(res); log('cursor-backend: local-exec sse closed'); });
|
|
688
|
+
log(`cursor-backend: -> local-exec /requests sse n=${localExecSse.size}`);
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
if (/\/responses$/.test(path0)) {
|
|
692
|
+
let parsed = {};
|
|
693
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
694
|
+
handleLocalExecFrames(parsed.frames, log);
|
|
695
|
+
jsonSend(res, { ok: true });
|
|
696
|
+
log(`cursor-backend: -> local-exec /responses n=${(parsed.frames || []).length} kinds=${(parsed.frames || []).map((f) => f?.kind).join(',')}`);
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
jsonSend(res, { ok: true });
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
function extractLocalPaths(text) {
|
|
703
|
+
const out = [];
|
|
704
|
+
const re = /(?:~\/|\/Users\/)[^\s,;:!?()[\]{}"'`]+/g;
|
|
705
|
+
let m;
|
|
706
|
+
while ((m = re.exec(String(text || '')))) {
|
|
707
|
+
out.push(m[0].replace(/[.,;:]+$/, ''));
|
|
708
|
+
}
|
|
709
|
+
return [...new Set(out)];
|
|
710
|
+
}
|
|
711
|
+
function expandUserPath(p) {
|
|
712
|
+
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
713
|
+
return p;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Per-agent transcript. sendPrompt is async: accept immediately, zooComplete
|
|
717
|
+
* fills this, getAgentTranscriptTail is what the UI actually polls.
|
|
718
|
+
*
|
|
719
|
+
* Gateway `isValidTranscriptEntry` (asar Fgi/RC) DROPS anything that is not
|
|
720
|
+
* `{id, kind:"message", content}` / `{id, kind:"send-message", message}` —
|
|
721
|
+
* proto wrappers with entryKind/body were counted n=2/2 here and painted
|
|
722
|
+
* as zero on the client, so overlay timed out → "Failed to send". */
|
|
723
|
+
const transcripts = new Map();
|
|
724
|
+
const tailedAgents = new Set();
|
|
725
|
+
let lastSendEchoId = `oz-${Date.now()}`;
|
|
726
|
+
function agentTranscript(id) {
|
|
727
|
+
let t = transcripts.get(id);
|
|
728
|
+
if (!t) { t = { seq: 0, entries: [] }; transcripts.set(id, t); }
|
|
729
|
+
return t;
|
|
730
|
+
}
|
|
731
|
+
function appendLine(agentId, role, text, extra = {}) {
|
|
732
|
+
const t = agentTranscript(agentId);
|
|
733
|
+
t.seq += 1;
|
|
734
|
+
const nonce = extra.clientNonce ? String(extra.clientNonce) : undefined;
|
|
735
|
+
const ts = Date.now();
|
|
736
|
+
const requestId = extra.requestId || nonce || `oz-req-${t.seq}`;
|
|
737
|
+
let e;
|
|
738
|
+
if (role === 'user') {
|
|
739
|
+
// Live cursorvm getAgentTranscriptTail (2026-08-29): user echo is
|
|
740
|
+
// kind:"message" + role:"user" + clientNonce. Assistant is kind:"send-message".
|
|
741
|
+
e = {
|
|
742
|
+
seq: t.seq,
|
|
743
|
+
kind: 'message',
|
|
744
|
+
id: extra.id || `t${t.seq}u`,
|
|
745
|
+
role: 'user',
|
|
746
|
+
content: String(text || ''),
|
|
747
|
+
richText: extra.richText || JSON.stringify({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: String(text || '') }] }] }),
|
|
748
|
+
isStreaming: false,
|
|
749
|
+
timestampMs: ts,
|
|
750
|
+
...(nonce ? { clientNonce: nonce } : {}),
|
|
751
|
+
requestId,
|
|
752
|
+
};
|
|
753
|
+
} else {
|
|
754
|
+
e = {
|
|
755
|
+
seq: t.seq,
|
|
756
|
+
kind: 'send-message',
|
|
757
|
+
id: extra.id || `t${t.seq}s0`,
|
|
758
|
+
message: { type: 'text', content: String(text || '') },
|
|
759
|
+
timestampMs: ts,
|
|
760
|
+
requestId,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
t.entries.push(e);
|
|
764
|
+
return e;
|
|
765
|
+
}
|
|
766
|
+
function fanoutLine(primaryId, role, text, extra = {}) {
|
|
767
|
+
const ids = new Set([primaryId, ...tailedAgents]);
|
|
768
|
+
let last = null;
|
|
769
|
+
for (const id of ids) last = appendLine(id, role, text, extra);
|
|
770
|
+
return last;
|
|
771
|
+
}
|
|
772
|
+
function gatewayEntry(e) {
|
|
773
|
+
const { seq, ...rest } = e;
|
|
774
|
+
return rest;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function promptFromSendBody(raw) {
|
|
778
|
+
let obj = raw;
|
|
779
|
+
if (Buffer.isBuffer(raw) || typeof raw === 'string') {
|
|
780
|
+
try { obj = JSON.parse(String(raw)); } catch { return String(raw || ''); }
|
|
781
|
+
}
|
|
782
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
783
|
+
const pick = (v) => (typeof v === 'string' && v.trim() ? v : '');
|
|
784
|
+
let fromMsgs = '';
|
|
785
|
+
if (Array.isArray(obj.messages)) {
|
|
786
|
+
for (let i = obj.messages.length - 1; i >= 0; i--) {
|
|
787
|
+
const m = obj.messages[i];
|
|
788
|
+
fromMsgs = pick(m?.content) || pick(m?.text) || pick(m?.prompt);
|
|
789
|
+
if (fromMsgs) break;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return pick(obj.prompt) || pick(obj.text) || pick(obj.message)
|
|
793
|
+
|| pick(obj.content) || pick(obj.input)
|
|
794
|
+
|| pick(obj.message?.content) || pick(obj.message?.text)
|
|
795
|
+
|| fromMsgs
|
|
796
|
+
|| '';
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
let walletUsdCache = { usd: null, at: 0 };
|
|
800
|
+
async function walletUsdCached() {
|
|
801
|
+
if (walletUsdCache.usd != null && Date.now() - walletUsdCache.at < 60_000) return walletUsdCache.usd;
|
|
802
|
+
try {
|
|
803
|
+
const { affordableUsd } = await import('./info.js');
|
|
804
|
+
const n = await Promise.race([
|
|
805
|
+
affordableUsd(),
|
|
806
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error('balance timeout')), 4000)),
|
|
807
|
+
]);
|
|
808
|
+
if (Number.isFinite(n)) {
|
|
809
|
+
walletUsdCache = { usd: Number(n), at: Date.now() };
|
|
810
|
+
return walletUsdCache.usd;
|
|
344
811
|
}
|
|
345
|
-
|
|
812
|
+
} catch { /* keep last */ }
|
|
813
|
+
return walletUsdCache.usd;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async function zooSpendOverlay(data) {
|
|
817
|
+
const x = data?.x402 || {};
|
|
818
|
+
let info = {};
|
|
819
|
+
try {
|
|
820
|
+
const r = await fetch('http://127.0.0.1:8402/v1/info', { signal: AbortSignal.timeout(1500) });
|
|
821
|
+
if (r.ok) info = await r.json();
|
|
822
|
+
} catch { /* */ }
|
|
823
|
+
let session = {};
|
|
824
|
+
try {
|
|
825
|
+
session = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.openzoo', 'session.json'), 'utf8'));
|
|
826
|
+
} catch { /* */ }
|
|
827
|
+
const spent = Number(info.spendUsd ?? session.spentUsd ?? x.billedUsd ?? 0);
|
|
828
|
+
const would = Number(info.directUsd ?? session.directUsd ?? x.directUsd ?? 0);
|
|
829
|
+
const saved = Number(info.savedUsd ?? Math.max(0, would - spent));
|
|
830
|
+
const pct = would > 0 ? (100 * saved / would) : 0;
|
|
831
|
+
const credit = Number(info.creditUsd);
|
|
832
|
+
const wallet = await walletUsdCached();
|
|
833
|
+
const bal = Number.isFinite(wallet) && wallet > 0.004
|
|
834
|
+
? wallet
|
|
835
|
+
: (Number.isFinite(credit) && credit > 0.004 ? credit : null);
|
|
836
|
+
const lines = ['', ''];
|
|
837
|
+
if (x.billedUsd != null) {
|
|
838
|
+
lines.push(`this call $${Number(x.billedUsd).toFixed(6)} · OpenRouter $${Number(x.directUsd || 0).toFixed(6)}`);
|
|
839
|
+
}
|
|
840
|
+
const balTxt = bal != null ? ` · balance $${bal.toFixed(2)}` : '';
|
|
841
|
+
lines.push(`spent $${spent.toFixed(4)}${balTxt} · OpenRouter would $${would.toFixed(4)} · saved $${saved.toFixed(4)} (${pct.toFixed(0)}%)`);
|
|
842
|
+
return lines.join('\n');
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const MODELS_PATH = path.join(os.homedir(), '.openzoo', 'grokbot-models.json');
|
|
846
|
+
function loadAgentModels() {
|
|
847
|
+
try {
|
|
848
|
+
return new Map(Object.entries(JSON.parse(fs.readFileSync(MODELS_PATH, 'utf8'))));
|
|
849
|
+
} catch { return new Map(); }
|
|
850
|
+
}
|
|
851
|
+
function saveAgentModels() {
|
|
852
|
+
try {
|
|
853
|
+
fs.mkdirSync(path.dirname(MODELS_PATH), { recursive: true });
|
|
854
|
+
fs.writeFileSync(MODELS_PATH, JSON.stringify(Object.fromEntries(agentModels)));
|
|
855
|
+
} catch { /* */ }
|
|
856
|
+
}
|
|
857
|
+
const agentModels = loadAgentModels();
|
|
858
|
+
const MODEL_ALIASES = {
|
|
859
|
+
fable: 'anthropic/claude-fable-5',
|
|
860
|
+
'fable-5': 'anthropic/claude-fable-5',
|
|
861
|
+
'claude-fable-5': 'anthropic/claude-fable-5',
|
|
862
|
+
opus: 'anthropic/claude-opus-5',
|
|
863
|
+
'opus-5': 'anthropic/claude-opus-5',
|
|
864
|
+
sonnet: 'anthropic/claude-sonnet-5',
|
|
865
|
+
grok: 'x-ai/grok-4.6',
|
|
866
|
+
'grok-4': 'x-ai/grok-4.6',
|
|
867
|
+
'grok-4.6': 'x-ai/grok-4.6',
|
|
868
|
+
};
|
|
869
|
+
function resolveModelId(raw) {
|
|
870
|
+
const s = String(raw || '').trim();
|
|
871
|
+
if (!s) return null;
|
|
872
|
+
const lower = s.toLowerCase().replace(/^\/+/, '');
|
|
873
|
+
if (MODEL_ALIASES[lower]) return MODEL_ALIASES[lower];
|
|
874
|
+
if (s.includes('/')) return s;
|
|
875
|
+
return null;
|
|
876
|
+
}
|
|
877
|
+
function currentModel(agentId) {
|
|
878
|
+
return agentModels.get(agentId)
|
|
879
|
+
|| process.env.OPENZOO_DEFAULT_MODEL
|
|
880
|
+
|| 'x-ai/grok-4.6';
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const execFileAsync = promisify(execFile);
|
|
884
|
+
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|heic|svg)$/i;
|
|
885
|
+
const IMAGE_MAGIC = [
|
|
886
|
+
[Buffer.from([0x89, 0x50, 0x4e, 0x47]), 'image/png'],
|
|
887
|
+
[Buffer.from([0xff, 0xd8, 0xff]), 'image/jpeg'],
|
|
888
|
+
[Buffer.from('GIF8'), 'image/gif'],
|
|
889
|
+
[Buffer.from('RIFF'), 'image/webp'],
|
|
890
|
+
];
|
|
891
|
+
function mimeFromBytes(buf, p = '') {
|
|
892
|
+
if (IMAGE_EXT.test(p)) {
|
|
893
|
+
const ext = path.extname(p).toLowerCase();
|
|
894
|
+
return { 'png': 'image/png', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp', '.heic': 'image/heic', '.svg': 'image/svg+xml' }[ext] || 'image/png';
|
|
895
|
+
}
|
|
896
|
+
for (const [magic, mime] of IMAGE_MAGIC) {
|
|
897
|
+
if (buf.length >= magic.length && buf.subarray(0, magic.length).equals(magic)) return mime;
|
|
898
|
+
}
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
function dataUrlsFromRichText(rt) {
|
|
902
|
+
const s = String(rt || '');
|
|
903
|
+
const out = [];
|
|
904
|
+
const re = /data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/]+=*/g;
|
|
905
|
+
let m;
|
|
906
|
+
while ((m = re.exec(s))) out.push(m[0]);
|
|
907
|
+
return out;
|
|
908
|
+
}
|
|
909
|
+
function attachmentList(parsed, prompt) {
|
|
910
|
+
const out = [];
|
|
911
|
+
const seen = new Set();
|
|
912
|
+
const add = (raw, name) => {
|
|
913
|
+
if (!raw || seen.has(raw)) return;
|
|
914
|
+
seen.add(raw);
|
|
915
|
+
out.push({ raw, name });
|
|
916
|
+
};
|
|
917
|
+
const paths = parsed?.attachmentPaths;
|
|
918
|
+
const names = parsed?.attachmentNames;
|
|
919
|
+
if (Array.isArray(paths)) {
|
|
920
|
+
for (let i = 0; i < paths.length; i++) add(String(paths[i]), names?.[i]);
|
|
921
|
+
}
|
|
922
|
+
for (const p of extractLocalPaths(prompt)) {
|
|
923
|
+
if (/[*?]/.test(p)) continue;
|
|
924
|
+
add(p);
|
|
925
|
+
}
|
|
926
|
+
const rt = String(parsed?.richText || '');
|
|
927
|
+
const srcRe = /(?:src|path|filePath|url)"?\s*[:=]\s*"((?:file:\/\/|\/(?:var|tmp|private|Users)|~\/)[^"]+\.(?:png|jpe?g|gif|webp|bmp|heic))"/gi;
|
|
928
|
+
let sm;
|
|
929
|
+
while ((sm = srcRe.exec(rt))) {
|
|
930
|
+
add(sm[1].replace(/^file:\/\//, ''));
|
|
931
|
+
}
|
|
932
|
+
return out;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
async function readLocalBytes(abs, log) {
|
|
936
|
+
if (localExecSse.size > 0) {
|
|
937
|
+
log(`cursor-backend: local-exec download ${abs}`);
|
|
938
|
+
const got = await localExecAsk({ kind: 'download', path: abs });
|
|
939
|
+
return got.bytes || Buffer.from(got.text || '', 'utf8');
|
|
940
|
+
}
|
|
941
|
+
return fs.readFileSync(abs);
|
|
942
|
+
}
|
|
943
|
+
async function writeLocalBytes(abs, bytes, log) {
|
|
944
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes ?? ''), 'utf8');
|
|
945
|
+
if (localExecSse.size > 0) {
|
|
946
|
+
log(`cursor-backend: local-exec upload ${abs} ${buf.length}b`);
|
|
947
|
+
await localExecAsk({
|
|
948
|
+
kind: 'upload',
|
|
949
|
+
path: abs,
|
|
950
|
+
bytesBase64: buf.toString('base64'),
|
|
951
|
+
});
|
|
952
|
+
return `wrote ${abs} (${buf.length} bytes) via local-exec`;
|
|
953
|
+
}
|
|
954
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
955
|
+
fs.writeFileSync(abs, buf);
|
|
956
|
+
return `wrote ${abs} (${buf.length} bytes)`;
|
|
957
|
+
}
|
|
958
|
+
async function execLocal(command, cwd, log) {
|
|
959
|
+
const dir = cwd ? expandUserPath(cwd) : os.homedir();
|
|
960
|
+
if (localExecSse.size > 0) {
|
|
961
|
+
log(`cursor-backend: local-exec exec ${JSON.stringify(command).slice(0, 80)}`);
|
|
962
|
+
const got = await localExecAsk({
|
|
963
|
+
kind: 'exec',
|
|
964
|
+
serverMessage: { command, cwd: dir, workingDirectory: dir },
|
|
965
|
+
}, 60000);
|
|
966
|
+
const out = got.stdout || got.message || '';
|
|
967
|
+
const err = got.stderr ? `\nstderr:\n${got.stderr}` : '';
|
|
968
|
+
return `${out}${err}`.trim() || `(exit ${got.exitCode ?? 0})`;
|
|
969
|
+
}
|
|
970
|
+
const { stdout, stderr } = await execFileAsync('/bin/zsh', ['-lc', command], {
|
|
971
|
+
cwd: dir,
|
|
972
|
+
timeout: 30000,
|
|
973
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
974
|
+
env: process.env,
|
|
975
|
+
});
|
|
976
|
+
return `${stdout || ''}${stderr ? `\nstderr:\n${stderr}` : ''}`.trim() || '(no output)';
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const LOCAL_TOOLS = [
|
|
980
|
+
{
|
|
981
|
+
type: 'function',
|
|
982
|
+
function: {
|
|
983
|
+
name: 'read_file',
|
|
984
|
+
description: 'Read a file on the user\'s Mac. Use this for any local path.',
|
|
985
|
+
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
986
|
+
},
|
|
987
|
+
},
|
|
988
|
+
{
|
|
989
|
+
type: 'function',
|
|
990
|
+
function: {
|
|
991
|
+
name: 'write_file',
|
|
992
|
+
description: 'Write a file on the user\'s Mac. Put HTML/games/code on disk — do not dump huge files in chat.',
|
|
993
|
+
parameters: {
|
|
994
|
+
type: 'object',
|
|
995
|
+
properties: { path: { type: 'string' }, content: { type: 'string' } },
|
|
996
|
+
required: ['path', 'content'],
|
|
997
|
+
},
|
|
998
|
+
},
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
type: 'function',
|
|
1002
|
+
function: {
|
|
1003
|
+
name: 'exec',
|
|
1004
|
+
description: 'Run a shell command on the user\'s Mac (zsh -lc). Use for npm, curl, ls, git, installing MCP, etc.',
|
|
1005
|
+
parameters: {
|
|
1006
|
+
type: 'object',
|
|
1007
|
+
properties: { command: { type: 'string' }, cwd: { type: 'string' } },
|
|
1008
|
+
required: ['command'],
|
|
1009
|
+
},
|
|
1010
|
+
},
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
type: 'function',
|
|
1014
|
+
function: {
|
|
1015
|
+
name: 'list_dir',
|
|
1016
|
+
description: 'List a directory on the user\'s Mac.',
|
|
1017
|
+
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
];
|
|
1021
|
+
|
|
1022
|
+
async function runLocalTool(name, args, log) {
|
|
1023
|
+
try {
|
|
1024
|
+
if (name === 'read_file') {
|
|
1025
|
+
const buf = await readLocalBytes(expandUserPath(args.path), log);
|
|
1026
|
+
if (buf.length > 180000) return `file ${args.path} is ${buf.length} bytes; first 180000:\n${buf.subarray(0, 180000).toString('utf8')}`;
|
|
1027
|
+
return buf.toString('utf8');
|
|
1028
|
+
}
|
|
1029
|
+
if (name === 'write_file') {
|
|
1030
|
+
return await writeLocalBytes(expandUserPath(args.path), args.content ?? '', log);
|
|
1031
|
+
}
|
|
1032
|
+
if (name === 'list_dir') {
|
|
1033
|
+
const abs = expandUserPath(args.path || os.homedir());
|
|
1034
|
+
if (localExecSse.size > 0) {
|
|
1035
|
+
const got = await localExecAsk({ kind: 'exec', serverMessage: { command: `ls -la ${JSON.stringify(abs)}`, cwd: os.homedir() } });
|
|
1036
|
+
return got.stdout || got.message || '';
|
|
1037
|
+
}
|
|
1038
|
+
return fs.readdirSync(abs, { withFileTypes: true })
|
|
1039
|
+
.map((e) => `${e.isDirectory() ? 'd' : '-'} ${e.name}`)
|
|
1040
|
+
.join('\n');
|
|
1041
|
+
}
|
|
1042
|
+
if (name === 'exec') {
|
|
1043
|
+
return await execLocal(String(args.command || ''), args.cwd, log);
|
|
1044
|
+
}
|
|
1045
|
+
return `unknown tool ${name}`;
|
|
346
1046
|
} catch (e) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
1047
|
+
return `ERROR ${e.message}`;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function zooTextFromMessage(msg, data) {
|
|
1052
|
+
let c = msg?.content;
|
|
1053
|
+
if (Array.isArray(c)) {
|
|
1054
|
+
c = c.map((p) => (typeof p === 'string' ? p : (p?.text || p?.content || ''))).join('');
|
|
350
1055
|
}
|
|
1056
|
+
if (typeof c === 'string' && c.trim()) return c;
|
|
1057
|
+
if (typeof data?.error?.message === 'string' && data.error.message) return data.error.message;
|
|
1058
|
+
return '';
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
async function zooComplete(prompt, log, agentId, parsed = {}) {
|
|
1062
|
+
const model = currentModel(agentId);
|
|
1063
|
+
const helper = localExecSse.size > 0;
|
|
1064
|
+
log(`cursor-backend: zoo POST :8402 model=${model} helper=${helper ? localExecSse.size : 0} ${JSON.stringify((prompt || '').slice(0, 60))}`);
|
|
1065
|
+
|
|
1066
|
+
const images = [];
|
|
1067
|
+
const textFiles = [];
|
|
1068
|
+
for (const { raw, name } of attachmentList(parsed, prompt)) {
|
|
1069
|
+
const abs = expandUserPath(raw);
|
|
1070
|
+
try {
|
|
1071
|
+
const buf = await readLocalBytes(abs, log);
|
|
1072
|
+
const mime = mimeFromBytes(buf, name || raw);
|
|
1073
|
+
if (mime) {
|
|
1074
|
+
images.push({ path: raw, mime, dataUrl: `data:${mime};base64,${buf.toString('base64')}` });
|
|
1075
|
+
log(`cursor-backend: attached image ${raw} ${mime} ${buf.length}b`);
|
|
1076
|
+
} else {
|
|
1077
|
+
textFiles.push({ path: raw, abs, text: buf.toString('utf8') });
|
|
1078
|
+
}
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
textFiles.push({ path: raw, abs, error: e.message });
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
for (const url of dataUrlsFromRichText(parsed.richText)) {
|
|
1084
|
+
images.push({ path: '(richText)', mime: 'image', dataUrl: url });
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const via = helper ? 'Grok Bot Helper local-exec SSE' : 'this Mac (hijack process; Helper SSE not connected yet)';
|
|
1088
|
+
const messages = [
|
|
1089
|
+
{
|
|
1090
|
+
role: 'system',
|
|
1091
|
+
content: [
|
|
1092
|
+
`You are ${model} served through openzoo inside Grok Bot.`,
|
|
1093
|
+
`You HAVE local tools on the user's computer via ${via}.`,
|
|
1094
|
+
'Tools: read_file, write_file, exec, list_dir. USE THEM. Write files to disk instead of pasting giant HTML into chat.',
|
|
1095
|
+
'Pasted images arrive as attachments — you can see them when present. Do not claim you cannot see images if they are in this turn.',
|
|
1096
|
+
'Never claim you lack filesystem access or local-exec. If a tool errors, report the error.',
|
|
1097
|
+
'A spend footer is appended after your reply by the host — ignore it.',
|
|
1098
|
+
].join(' '),
|
|
1099
|
+
},
|
|
1100
|
+
];
|
|
1101
|
+
if (textFiles.length) {
|
|
1102
|
+
const bits = textFiles.map((a) => (
|
|
1103
|
+
a.error
|
|
1104
|
+
? `FILE ${a.path} ERROR: ${a.error}`
|
|
1105
|
+
: `FILE ${a.path} (${a.abs})\n${String(a.text).slice(0, 180000)}`
|
|
1106
|
+
));
|
|
1107
|
+
messages.push({ role: 'user', content: bits.join('\n\n') });
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const userContent = [];
|
|
1111
|
+
for (const img of images) {
|
|
1112
|
+
userContent.push({ type: 'image_url', image_url: { url: img.dataUrl } });
|
|
1113
|
+
}
|
|
1114
|
+
userContent.push({ type: 'text', text: prompt || (images.length ? '(see attached image)' : 'hello') });
|
|
1115
|
+
messages.push({
|
|
1116
|
+
role: 'user',
|
|
1117
|
+
content: userContent.length === 1 && userContent[0].type === 'text'
|
|
1118
|
+
? userContent[0].text
|
|
1119
|
+
: userContent,
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
const maxTok = Number(process.env.OPENZOO_ASK_MAX_TOKENS || 4096);
|
|
1123
|
+
let lastData = {};
|
|
1124
|
+
let text = '';
|
|
1125
|
+
for (let step = 0; step < 8; step++) {
|
|
1126
|
+
const payload = {
|
|
1127
|
+
model,
|
|
1128
|
+
messages,
|
|
1129
|
+
tools: LOCAL_TOOLS,
|
|
1130
|
+
tool_choice: 'auto',
|
|
1131
|
+
max_tokens: maxTok,
|
|
1132
|
+
};
|
|
1133
|
+
const post = () => fetch('http://127.0.0.1:8402/v1/chat/completions', {
|
|
1134
|
+
method: 'POST',
|
|
1135
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
|
|
1136
|
+
body: JSON.stringify(payload),
|
|
1137
|
+
signal: AbortSignal.timeout(120000),
|
|
1138
|
+
});
|
|
1139
|
+
let r = await post();
|
|
1140
|
+
if (r.status === 402) {
|
|
1141
|
+
log('cursor-backend: x402 402 — dwell/retry');
|
|
1142
|
+
await new Promise((ok) => setTimeout(ok, 2500));
|
|
1143
|
+
r = await post();
|
|
1144
|
+
}
|
|
1145
|
+
const data = await r.json();
|
|
1146
|
+
lastData = data;
|
|
1147
|
+
const msg = data.choices?.[0]?.message || {};
|
|
1148
|
+
const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
|
1149
|
+
if (calls.length) {
|
|
1150
|
+
log(`cursor-backend: zoo tools step=${step} n=${calls.length} ${calls.map((c) => c.function?.name || c.name).join(',')}`);
|
|
1151
|
+
messages.push(msg);
|
|
1152
|
+
for (const c of calls) {
|
|
1153
|
+
let args = {};
|
|
1154
|
+
try { args = JSON.parse(c.function?.arguments || c.arguments || '{}'); } catch { args = {}; }
|
|
1155
|
+
const name = c.function?.name || c.name || '';
|
|
1156
|
+
const result = await runLocalTool(name, args, log);
|
|
1157
|
+
messages.push({
|
|
1158
|
+
role: 'tool',
|
|
1159
|
+
tool_call_id: c.id,
|
|
1160
|
+
content: String(result).slice(0, 120000),
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
text = zooTextFromMessage(msg, data) || (step ? '(tool loop ended with empty content)' : '(empty zoo reply)');
|
|
1166
|
+
log(`cursor-backend: << zoo ${r.status} ${text.length}c model=${data.model || model} finish=${data.choices?.[0]?.finish_reason || '?'}`);
|
|
1167
|
+
break;
|
|
1168
|
+
}
|
|
1169
|
+
try { text += await zooSpendOverlay(lastData); } catch { /* overlay must never eat the reply */ }
|
|
1170
|
+
return { text, data: lastData };
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async function handleHijackedPodHttp(req, res, full, body, log) {
|
|
1174
|
+
if (sniffOn()) {
|
|
1175
|
+
const waiting = (full || '').split('?')[0];
|
|
1176
|
+
const podPath = waiting === '/health' || waiting === '/healthz' || waiting === '/events'
|
|
1177
|
+
|| waiting.startsWith('/api/') || waiting.startsWith('/webauthn/')
|
|
1178
|
+
|| waiting.startsWith('/cookie-origin-approval/');
|
|
1179
|
+
if (waiting.startsWith('/local-exec/')) return handleLocalExecHttp(req, res, waiting, body, log);
|
|
1180
|
+
if (realPod?.agent && podPath) return proxyPodHttp(req, res, full, body, log);
|
|
1181
|
+
if (!podPath) return false;
|
|
1182
|
+
if (waiting === '/health' || waiting === '/healthz') {
|
|
1183
|
+
jsonSend(res, { ok: true, status: 'ok', ready: true });
|
|
1184
|
+
return true;
|
|
1185
|
+
}
|
|
1186
|
+
return false;
|
|
1187
|
+
}
|
|
1188
|
+
const path0 = (full || '').split('?')[0];
|
|
1189
|
+
if (path0 === '/health' || path0 === '/healthz') {
|
|
1190
|
+
jsonSend(res, { ok: true, status: 'ok', ready: true });
|
|
1191
|
+
log('cursor-backend: -> pod /health ok');
|
|
1192
|
+
return true;
|
|
1193
|
+
}
|
|
1194
|
+
if (path0 === '/events') {
|
|
1195
|
+
res.writeHead(200, {
|
|
1196
|
+
'content-type': 'text/event-stream',
|
|
1197
|
+
'cache-control': 'no-cache',
|
|
1198
|
+
...CORS,
|
|
1199
|
+
});
|
|
1200
|
+
res.write('data: {"channel":"ping","payload":{}}\n\n');
|
|
1201
|
+
sseClients.add(res);
|
|
1202
|
+
const iv = setInterval(() => {
|
|
1203
|
+
try { res.write('data: {"channel":"ping","payload":{}}\n\n'); } catch { clearInterval(iv); sseClients.delete(res); }
|
|
1204
|
+
}, 15000);
|
|
1205
|
+
req.on('close', () => { clearInterval(iv); sseClients.delete(res); });
|
|
1206
|
+
log('cursor-backend: -> pod /events sse');
|
|
1207
|
+
return true;
|
|
1208
|
+
}
|
|
1209
|
+
if (path0.startsWith('/webauthn/') || path0.startsWith('/cookie-origin-approval/')) {
|
|
1210
|
+
if (req.method === 'GET') jsonSend(res, []);
|
|
1211
|
+
else jsonSend(res, { ok: true });
|
|
1212
|
+
log(`cursor-backend: -> pod ${path0}`);
|
|
1213
|
+
return true;
|
|
1214
|
+
}
|
|
1215
|
+
if (!path0.startsWith('/api/')) return false;
|
|
1216
|
+
const name = path0.slice('/api/'.length);
|
|
1217
|
+
// Roster/settings come from the REAL 1340 gateway (names, avatars, trays).
|
|
1218
|
+
// Chat stays local so inference is zoo. Discovered on EnsureSandBox rewrite.
|
|
1219
|
+
const roster = new Set([
|
|
1220
|
+
'listAgents', 'countAgents', 'searchAgents', 'getTrays', 'getHostSettings',
|
|
1221
|
+
'setHostSettings', 'getAgentChannels', 'getAgentWorkflows', 'skillsCatalog',
|
|
1222
|
+
'getSubagents', 'getAsyncTasks', 'getForeverBoxStatus', 'getSharingState',
|
|
1223
|
+
'getBotTemplateExportPolicy', 'getTeachRecordingStatus', 'isGlobalSearchEnabled',
|
|
1224
|
+
'isEgressTunnelAvailable', 'listBoxMcpServers', 'getHostStatus',
|
|
1225
|
+
'setWindowFocused', 'getAgentAutomations',
|
|
1226
|
+
'createAgent', 'createAgentFromTemplate', 'createGroup', 'setGroupMembers',
|
|
1227
|
+
'updateAgent', 'deleteAgents', 'duplicateAgent', 'kickstartAgent',
|
|
1228
|
+
'interruptAgentRun', 'requestDiskSaverAudit', 'broadcastToAgents',
|
|
1229
|
+
'setAgentUnread', 'setAgentHiddenFromSidebar', 'setAgentNotificationsEnabled',
|
|
1230
|
+
'setAgentNotifyOnUpdates', 'setAgentAvatarBytes', 'getAgentAvatar',
|
|
1231
|
+
]);
|
|
1232
|
+
if (!sniffOn() && realPod?.agent && roster.has(name)) {
|
|
1233
|
+
return proxyPodHttp(req, res, full, body, log);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// THE ACTUAL CHAT PATH. Grok Bot does not send StreamUnifiedChat on this
|
|
1237
|
+
// surface — measured: POST /api/sendPrompt 374b/462b after EnsureSandBox
|
|
1238
|
+
// hijack. Stubbing {ok:true} ate the prompt. Forward to the paying proxy.
|
|
1239
|
+
if (name === 'sendPrompt') {
|
|
1240
|
+
let parsed = {};
|
|
1241
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
1242
|
+
const prompt = promptFromSendBody(parsed);
|
|
1243
|
+
const agentId = String(parsed.agentId || parsed.id || 'openzoo');
|
|
1244
|
+
const nonce = parsed.clientNonce || `oz-${Date.now()}`;
|
|
1245
|
+
const attN = Array.isArray(parsed.attachmentPaths) ? parsed.attachmentPaths.length : 0;
|
|
1246
|
+
log(`cursor-backend: >> sendPrompt agent=${agentId} keys=${Object.keys(parsed).join(',')} attachments=${attN} prompt=${JSON.stringify((prompt || '').slice(0, 80))}`);
|
|
1247
|
+
lastSendEchoId = String(nonce);
|
|
1248
|
+
const userLine = fanoutLine(agentId, 'user', prompt, {
|
|
1249
|
+
clientNonce: nonce,
|
|
1250
|
+
requestId: nonce,
|
|
1251
|
+
richText: parsed.richText,
|
|
1252
|
+
});
|
|
1253
|
+
ssePush('transcript', { ...gatewayEntry(userLine), agentId });
|
|
1254
|
+
jsonSend(res, { accepted: true });
|
|
1255
|
+
const modelCmd = /^\s*\/model(?:\s+(\S+))?\s*$/i.exec(prompt || '');
|
|
1256
|
+
setImmediate(async () => {
|
|
1257
|
+
let text = '';
|
|
1258
|
+
try {
|
|
1259
|
+
if (modelCmd) {
|
|
1260
|
+
const want = modelCmd[1];
|
|
1261
|
+
if (!want) {
|
|
1262
|
+
const cur = currentModel(agentId);
|
|
1263
|
+
text = `current model: ${cur}\nset with /model fable | opus | sonnet | grok | provider/id`;
|
|
1264
|
+
} else {
|
|
1265
|
+
const id = resolveModelId(want) || (want.includes('/') ? want : null);
|
|
1266
|
+
if (!id) {
|
|
1267
|
+
text = `unknown model "${want}". try /model fable | opus | sonnet | grok or a full id like anthropic/claude-fable-5`;
|
|
1268
|
+
} else {
|
|
1269
|
+
agentModels.set(agentId, id);
|
|
1270
|
+
saveAgentModels();
|
|
1271
|
+
text = `model set to ${id}`;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
try { text += await zooSpendOverlay({}); } catch { /* */ }
|
|
1275
|
+
} else {
|
|
1276
|
+
const z = await zooComplete(prompt, log, agentId, parsed);
|
|
1277
|
+
text = z.text;
|
|
1278
|
+
}
|
|
1279
|
+
} catch (e) {
|
|
1280
|
+
text = `openzoo error: ${e.message}`;
|
|
1281
|
+
log(`cursor-backend: sendPrompt zoo failed: ${e.message}`);
|
|
1282
|
+
}
|
|
1283
|
+
const line = fanoutLine(agentId, 'assistant', text, { clientNonce: nonce, requestId: nonce });
|
|
1284
|
+
ssePush('transcript', { ...gatewayEntry(line), agentId });
|
|
1285
|
+
log(`cursor-backend: sendPrompt done agent=${agentId} seq=${line.seq} text=${JSON.stringify(text.slice(0, 80))}`);
|
|
1286
|
+
});
|
|
1287
|
+
return true;
|
|
1288
|
+
}
|
|
1289
|
+
if (name === 'getAgentTranscriptTail' || name === 'getAgentTranscriptWindow' || name === 'openAgentTail') {
|
|
1290
|
+
let parsed = {};
|
|
1291
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
1292
|
+
let id = String(parsed.id || parsed.agentId || 'openzoo');
|
|
1293
|
+
tailedAgents.add(id);
|
|
1294
|
+
let t = agentTranscript(id);
|
|
1295
|
+
if (!t.entries.length) {
|
|
1296
|
+
for (const [other, ot] of transcripts) {
|
|
1297
|
+
if (ot.entries.length) { id = other; t = ot; break; }
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const limit = Math.min(Number(parsed.limit) || 50, 200);
|
|
1301
|
+
const before = parsed.beforeSeq != null ? Number(parsed.beforeSeq) : Infinity;
|
|
1302
|
+
const sliced = t.entries.filter((e) => e.seq < before).slice(-limit);
|
|
1303
|
+
const page = { entries: sliced.map(gatewayEntry) };
|
|
1304
|
+
if (t.entries.length > sliced.length && sliced.length) page.nextBeforeSeq = sliced[0].seq;
|
|
1305
|
+
// Live pod returns RAW {entries, nextBeforeSeq} — no CVr envelope
|
|
1306
|
+
// (measured 1340 getAgentTranscriptTail 2026-08-29). Wrapping {status,value}
|
|
1307
|
+
// made transcript-page validation fail and the canvas stayed empty.
|
|
1308
|
+
jsonSend(res, page);
|
|
1309
|
+
log(`cursor-backend: -> transcript ${id} n=${sliced.length}/${t.seq}`);
|
|
1310
|
+
return true;
|
|
1311
|
+
}
|
|
1312
|
+
if (name === 'promptAcceptanceStatus') {
|
|
1313
|
+
let parsed = {};
|
|
1314
|
+
try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
|
|
1315
|
+
const nonce = String(parsed.clientNonce || lastSendEchoId);
|
|
1316
|
+
const agentId = String(parsed.agentId || '');
|
|
1317
|
+
jsonSend(res, {
|
|
1318
|
+
outcome: 'found',
|
|
1319
|
+
record: {
|
|
1320
|
+
status: 'accepted',
|
|
1321
|
+
acceptedAtMs: Date.now(),
|
|
1322
|
+
echoEntryId: nonce,
|
|
1323
|
+
clientNonce: nonce,
|
|
1324
|
+
agentId,
|
|
1325
|
+
inputDigest: '',
|
|
1326
|
+
},
|
|
1327
|
+
});
|
|
1328
|
+
log(`cursor-backend: -> promptAcceptanceStatus found echo=${nonce}`);
|
|
1329
|
+
return true;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const stubs = {
|
|
1333
|
+
getHostStatus: { status: 'ready', ready: true, state: 'ready', hostStatus: 'ready' },
|
|
1334
|
+
getHostSettings: { settings: {} },
|
|
1335
|
+
setHostSettings: { ok: true },
|
|
1336
|
+
setBoxSecrets: { ok: true },
|
|
1337
|
+
listAgents: [...new Set([...transcripts.keys(), ...tailedAgents])].map((id) => ({ id, name: id, status: 'ready' })),
|
|
1338
|
+
getAgentTranscriptTail: { tail: '', lines: [], dropped: false, ok: true },
|
|
1339
|
+
getTeachRecordingStatus: { recording: false },
|
|
1340
|
+
getTrays: { trays: [] },
|
|
1341
|
+
isGlobalSearchEnabled: { enabled: false },
|
|
1342
|
+
isEgressTunnelAvailable: { available: false },
|
|
1343
|
+
getSharingState: { sharing: false },
|
|
1344
|
+
getBotTemplateExportPolicy: { allowed: true },
|
|
1345
|
+
getAgentAutomations: { automations: [] },
|
|
1346
|
+
getForeverBoxStatus: { enabled: false },
|
|
1347
|
+
getSubagents: { subagents: [] },
|
|
1348
|
+
getAsyncTasks: { tasks: [] },
|
|
1349
|
+
getAgentWorkflows: { workflows: [] },
|
|
1350
|
+
setWindowFocused: { ok: true },
|
|
1351
|
+
};
|
|
1352
|
+
const payload = stubs[name] !== undefined ? stubs[name] : { ok: true };
|
|
1353
|
+
// Live 1340 listAgents is a RAW array, not CVr (measured 104674b 2026-08-29).
|
|
1354
|
+
if (name === 'listAgents') jsonSend(res, payload);
|
|
1355
|
+
else jsonApi(res, payload);
|
|
1356
|
+
log(`cursor-backend: -> pod /api/${name}`);
|
|
1357
|
+
return true;
|
|
351
1358
|
}
|
|
352
1359
|
|
|
353
1360
|
/**
|
|
@@ -408,6 +1415,9 @@ function respond(req, res, method, models) {
|
|
|
408
1415
|
*/
|
|
409
1416
|
export function startCursorBackend({ port = 8443, models, log = () => {} } = {}) {
|
|
410
1417
|
const { cert, key } = ensureCert(log);
|
|
1418
|
+
const certPem = fs.readFileSync(cert);
|
|
1419
|
+
const keyPem = fs.readFileSync(key);
|
|
1420
|
+
const secureContext = tls.createSecureContext({ cert: certPem, key: keyPem });
|
|
411
1421
|
let conns = 0;
|
|
412
1422
|
// SERVE BOTH h2 AND h1. An earlier build forced h1-only after concluding the
|
|
413
1423
|
// editor's h2 connections reset — but that log was the STALE 8443 backend the
|
|
@@ -416,13 +1426,20 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
|
|
|
416
1426
|
// (ERR_SSL_NO_APPLICATION_PROTOCOL when we advertise just h1), so we must
|
|
417
1427
|
// negotiate both. allowHTTP1 keeps the h1 fetch() calls (stripe/updates)
|
|
418
1428
|
// working; ALPN h2 first satisfies the Connect gRPC client.
|
|
1429
|
+
// SNICallback MUST pass the SecureContext — cb(null) with no ctx is why the
|
|
1430
|
+
// Helper daemon's Node fetch never completed GET /local-exec/requests (TLS
|
|
1431
|
+
// ECONNRESET, no request log). Chromium --ignore-certificate-errors hid this
|
|
1432
|
+
// for the UI process.
|
|
419
1433
|
const server = http2.createSecureServer(
|
|
420
1434
|
{
|
|
421
|
-
cert:
|
|
422
|
-
key:
|
|
1435
|
+
cert: certPem,
|
|
1436
|
+
key: keyPem,
|
|
423
1437
|
allowHTTP1: true,
|
|
424
1438
|
ALPNProtocols: ['h2', 'http/1.1'],
|
|
425
|
-
SNICallback: (servername, cb) => {
|
|
1439
|
+
SNICallback: (servername, cb) => {
|
|
1440
|
+
log(`cursor-tls: <- ClientHello SNI=${servername || '?'}`);
|
|
1441
|
+
cb(null, secureContext);
|
|
1442
|
+
},
|
|
426
1443
|
},
|
|
427
1444
|
async (req, res) => {
|
|
428
1445
|
conns += 1;
|
|
@@ -494,13 +1511,38 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
|
|
|
494
1511
|
{
|
|
495
1512
|
const path0 = full.split('?')[0];
|
|
496
1513
|
const oauth = /^\/oauth(\/|$)/.test(path0);
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
1514
|
+
// Coordinator talks to /sand-box/local-exec-daemon-credential. empty-ok
|
|
1515
|
+
// here is ControlPortCallError: main-execution-failure: fetch failed.
|
|
1516
|
+
const sandBox = /^\/sand-box(\/|$)/.test(path0);
|
|
1517
|
+
const grokCred = /IssueGrokBotUserComputerCredential/.test(full);
|
|
1518
|
+
const sniffBox = sniffOn() && /GrokBotService\/(EnsureSandBox|WatchSandBoxMigration)/.test(full);
|
|
1519
|
+
const needRealPod = /GrokBotService\/EnsureSandBox/.test(full)
|
|
1520
|
+
&& !process.env.OZ_HIJACK_POD
|
|
1521
|
+
&& !sniffBox;
|
|
1522
|
+
if (sniffBox) {
|
|
1523
|
+
if (/WatchSandBoxMigration/.test(full) && realPod) {
|
|
1524
|
+
const payload = rewrittenBox();
|
|
1525
|
+
res.writeHead(200, {
|
|
1526
|
+
'content-type': 'application/connect+proto',
|
|
1527
|
+
'grpc-status': '0',
|
|
1528
|
+
...CORS,
|
|
1529
|
+
});
|
|
1530
|
+
const end = Buffer.from('{}');
|
|
1531
|
+
const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
|
|
1532
|
+
res.end(Buffer.concat([envelope(payload), h, end]));
|
|
1533
|
+
log('cursor-backend: SNIFF WatchSandBoxMigration ready (rewritten)');
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
await sniffEnsureSandBox(req, res, body, host, full, log);
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
if (oauth || sandBox || grokCred || needRealPod) {
|
|
500
1540
|
const upstream = /cursor\.sh$/.test(host) ? host : 'api2.cursor.sh';
|
|
501
1541
|
await passthroughToRealAnthropic(req, res, body, upstream, full, log);
|
|
502
1542
|
return;
|
|
503
1543
|
}
|
|
1544
|
+
if (await handleLocalExecHttp(req, res, path0, body, log)) return;
|
|
1545
|
+
if (await handleHijackedPodHttp(req, res, full, body, log)) return;
|
|
504
1546
|
}
|
|
505
1547
|
// HIJACK EnsureSandBox FIRST — before passthrough, or passthrough eats it.
|
|
506
1548
|
// MEASURED: with OPENZOO_PASSTHRU=1 set, EnsureSandBox went `-> REAL
|
|
@@ -508,8 +1550,44 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
|
|
|
508
1550
|
// never our box. This method (and ONLY this one) must be answered locally
|
|
509
1551
|
// with OUR box so Grok Bot's UI wires to our sandbox; everything else
|
|
510
1552
|
// still passes through so the app loads normally.
|
|
511
|
-
if (/GrokBotService\/EnsureSandBox/.test(full) && process.env.OZ_HIJACK_POD) {
|
|
512
|
-
|
|
1553
|
+
if (/GrokBotService\/(EnsureSandBox|WatchSandBoxMigration)/.test(full) && process.env.OZ_HIJACK_POD) {
|
|
1554
|
+
if (/WatchSandBoxMigration/.test(full) && realPod) {
|
|
1555
|
+
const payload = rewrittenBox();
|
|
1556
|
+
res.writeHead(200, {
|
|
1557
|
+
'content-type': 'application/connect+proto',
|
|
1558
|
+
'grpc-status': '0',
|
|
1559
|
+
...CORS,
|
|
1560
|
+
});
|
|
1561
|
+
const end = Buffer.from('{}');
|
|
1562
|
+
const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
|
|
1563
|
+
res.end(Buffer.concat([envelope(payload), h, end]));
|
|
1564
|
+
log('cursor-backend: -> WatchSandBoxMigration ready (hijack, real roster)');
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
try {
|
|
1568
|
+
process.env.OZ_SNIFF_SELF = process.env.OZ_SNIFF_SELF || 'https://127.0.0.1:8443';
|
|
1569
|
+
await sniffEnsureSandBox(req, res, body, host, full, log);
|
|
1570
|
+
log('cursor-backend: -> HIJACKED EnsureSandBox -> our box (roster from real 1340)');
|
|
1571
|
+
return;
|
|
1572
|
+
} catch (e) {
|
|
1573
|
+
log(`cursor-backend: EnsureSandBox discover failed (${e.message}) — env box`);
|
|
1574
|
+
}
|
|
1575
|
+
let pod;
|
|
1576
|
+
try { pod = JSON.parse(process.env.OZ_HIJACK_POD); } catch { pod = null; }
|
|
1577
|
+
if (pod && /WatchSandBoxMigration/.test(full)) {
|
|
1578
|
+
const payload = encodeEnsureSandBox(pod);
|
|
1579
|
+
res.writeHead(200, {
|
|
1580
|
+
'content-type': 'application/connect+proto',
|
|
1581
|
+
'grpc-status': '0',
|
|
1582
|
+
...CORS,
|
|
1583
|
+
});
|
|
1584
|
+
const end = Buffer.from('{}');
|
|
1585
|
+
const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
|
|
1586
|
+
res.end(Buffer.concat([envelope(payload), h, end]));
|
|
1587
|
+
log('cursor-backend: -> WatchSandBoxMigration ready (hijack)');
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
respond(req, res, 'EnsureSandBox', models);
|
|
513
1591
|
log(`cursor-backend: -> HIJACKED EnsureSandBox -> our box`);
|
|
514
1592
|
return;
|
|
515
1593
|
}
|
|
@@ -519,6 +1597,50 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
|
|
|
519
1597
|
await handleStreamChat(req, res, body, log);
|
|
520
1598
|
return;
|
|
521
1599
|
}
|
|
1600
|
+
// Telegram/"Failed to send": GetGrokBotSendStatus empty proto is
|
|
1601
|
+
// UNSPECIFIED. ACCEPTED=2. WatchGrokBotUserComputerRequests is a
|
|
1602
|
+
// long-lived stream — empty-ok closes it and the channel looks offline.
|
|
1603
|
+
if (/GetGrokBotSendStatus/.test(full)) {
|
|
1604
|
+
if (sniffOn()) {
|
|
1605
|
+
const fields = decodeProtoFields(body);
|
|
1606
|
+
sniffDump({ kind: 'GetGrokBotSendStatus.req', fields });
|
|
1607
|
+
const upstream = /cursor\.sh$/.test(host) ? host : 'api2.cursor.sh';
|
|
1608
|
+
await passthroughToRealAnthropic(req, res, body, upstream, full, log);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
const fields = decodeProtoFields(body);
|
|
1612
|
+
const echoId = String(fields[2] || lastSendEchoId);
|
|
1613
|
+
lastSendEchoId = echoId;
|
|
1614
|
+
const payload = encodeGetGrokBotSendStatus(echoId);
|
|
1615
|
+
const reqCt = String(req.headers['content-type'] || '');
|
|
1616
|
+
if (reqCt.includes('grpc-web')) {
|
|
1617
|
+
res.writeHead(200, {
|
|
1618
|
+
'content-type': reqCt.includes('text') ? 'application/grpc-web-text+proto' : 'application/grpc-web+proto',
|
|
1619
|
+
'grpc-status': '0', ...CORS,
|
|
1620
|
+
});
|
|
1621
|
+
res.end(Buffer.concat([envelope(payload), grpcWebTrailer()]));
|
|
1622
|
+
} else {
|
|
1623
|
+
res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
|
|
1624
|
+
res.end(payload);
|
|
1625
|
+
}
|
|
1626
|
+
log(`cursor-backend: -> GetGrokBotSendStatus ACCEPTED echo=${echoId}`);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
if (/WatchGrokBotUserComputerRequests/.test(full)) {
|
|
1630
|
+
if (sniffOn()) {
|
|
1631
|
+
const upstream = /cursor\.sh$/.test(host) ? host : 'api2.cursor.sh';
|
|
1632
|
+
await passthroughPipe(req, res, body, upstream, full, log);
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
res.writeHead(200, {
|
|
1636
|
+
'content-type': 'application/connect+proto',
|
|
1637
|
+
'grpc-status': '0',
|
|
1638
|
+
...CORS,
|
|
1639
|
+
});
|
|
1640
|
+
req.on('close', () => { try { res.end(); } catch { /* */ } });
|
|
1641
|
+
log('cursor-backend: -> WatchGrokBotUserComputerRequests held');
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
522
1644
|
// FULL PASSTHROUGH MODE — observe, do not stub.
|
|
523
1645
|
//
|
|
524
1646
|
// Stubbing unknown methods with empty protobufs BREAKS Grok Bot: it never
|