openzoo 0.50.26 → 0.50.28

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.
@@ -37,6 +37,9 @@ import { promisify } from 'node:util';
37
37
  import tls from 'node:tls';
38
38
  import { randomUUID } from 'node:crypto';
39
39
  import { encodeAvailableModels, encodeForMethod, encodeEnsureSandBox, encodeGetGrokBotSendStatus, decodeProtoFields, unwrapConnect } from './cursorapi.js';
40
+ import {
41
+ accountPodPath, accountAgentsPath, rosterForAccount,
42
+ } from './grokbotAccount.js';
40
43
 
41
44
  const TLS_DIR = path.join(os.homedir(), '.openzoo', 'cursor-tls');
42
45
  const CURSOR_HOSTS = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'repo42.cursor.sh'];
@@ -290,27 +293,76 @@ const RESP_DROP = new Set([
290
293
 
291
294
  const SNIFF_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-sniff.jsonl');
292
295
  const POD_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-pod.json');
296
+ const AGENTS_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-agents.json');
297
+ const HOME = os.homedir();
298
+
299
+ function readJsonFile(p) {
300
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
301
+ }
302
+ function writeJsonFile(p, v) {
303
+ try {
304
+ fs.mkdirSync(path.dirname(p), { recursive: true });
305
+ fs.writeFileSync(p, JSON.stringify(v));
306
+ } catch { /* */ }
307
+ }
308
+
309
+ let activeAccountId = null;
310
+
311
+ function migrateLegacyPod(accountId) {
312
+ if (!accountId) return;
313
+ const dest = accountPodPath(HOME, accountId);
314
+ const destA = accountAgentsPath(HOME, accountId);
315
+ if (!dest) return;
316
+ const legacy = readJsonFile(POD_FILE);
317
+ if (legacy?.agent && (!legacy.accountId || legacy.accountId === accountId) && !readJsonFile(dest)) {
318
+ writeJsonFile(dest, legacy);
319
+ }
320
+ if (legacy?.accountId === accountId && !readJsonFile(destA)) {
321
+ const agents = readJsonFile(AGENTS_FILE);
322
+ if (Array.isArray(agents) && agents.length) writeJsonFile(destA, agents);
323
+ }
324
+ }
325
+
293
326
  function loadPod() {
294
- try { return JSON.parse(fs.readFileSync(POD_FILE, 'utf8')); } catch { return null; }
327
+ const legacy = readJsonFile(POD_FILE);
328
+ const id = legacy?.accountId || activeAccountId;
329
+ if (id) {
330
+ migrateLegacyPod(id);
331
+ const scoped = readJsonFile(accountPodPath(HOME, id));
332
+ if (scoped?.agent) return scoped;
333
+ }
334
+ return legacy?.agent ? legacy : null;
295
335
  }
296
336
  function savePod(p) {
297
337
  if (!p?.agent) return;
298
- try {
299
- fs.mkdirSync(path.dirname(POD_FILE), { recursive: true });
300
- fs.writeFileSync(POD_FILE, JSON.stringify(p));
301
- } catch { /* */ }
338
+ writeJsonFile(POD_FILE, p); // last-used pointer for same-user relaunch
339
+ if (p.accountId) writeJsonFile(accountPodPath(HOME, p.accountId), p);
340
+ }
341
+ let realPod = loadPod();
342
+ activeAccountId = realPod?.accountId || null;
343
+ if (activeAccountId) migrateLegacyPod(activeAccountId);
344
+
345
+ function agentsPath() {
346
+ if (!activeAccountId) return null;
347
+ return accountAgentsPath(HOME, activeAccountId) || AGENTS_FILE;
302
348
  }
303
- let realPod = loadPod(); // { agent, vnc, token, p1340, ... } — persist so api2 timeout does not UUID-stub the sidebar
304
- const AGENTS_FILE = path.join(os.homedir(), '.openzoo', 'grokbot-agents.json');
305
349
  function loadAgents() {
306
- try {
307
- const a = JSON.parse(fs.readFileSync(AGENTS_FILE, 'utf8'));
350
+ // No live account → no tray. Serving the machine-global file here is how a
351
+ // second household login saw the wrong chats (or none of theirs).
352
+ if (!activeAccountId) return null;
353
+ const scoped = readJsonFile(accountAgentsPath(HOME, activeAccountId));
354
+ if (Array.isArray(scoped)) return scoped;
355
+ const legacyPod = readJsonFile(POD_FILE);
356
+ if (legacyPod?.accountId === activeAccountId) {
357
+ const a = readJsonFile(AGENTS_FILE);
308
358
  return Array.isArray(a) ? a : null;
309
- } catch { return null; }
359
+ }
360
+ return null;
310
361
  }
311
362
  function saveAgents(a) {
312
- if (!Array.isArray(a) || !a.length) return;
313
- try { fs.writeFileSync(AGENTS_FILE, JSON.stringify(a)); } catch { /* */ }
363
+ if (!Array.isArray(a) || !a.length || !activeAccountId) return;
364
+ const p = agentsPath();
365
+ if (p) writeJsonFile(p, a);
314
366
  }
315
367
  function cachedAgentList() {
316
368
  const a = loadAgents();
@@ -346,6 +398,10 @@ function copyReqHeaders(req, host) {
346
398
  }
347
399
  if (host) headers.host = host;
348
400
  delete headers['content-length'];
401
+ // Electron POSTs Expect: 100-continue. cursorvm 1340 answers 417 and the
402
+ // splash never leaves "Setting up Grok Bot's computer" (measured 2026-08-29).
403
+ delete headers.expect;
404
+ delete headers.Expect;
349
405
  return headers;
350
406
  }
351
407
  function lookupPinned(ip) {
@@ -459,6 +515,13 @@ function rememberPod(fields, log) {
459
515
  if (!fields || !fields[6]) return null;
460
516
  const execDaemon = String(fields[6] || '');
461
517
  const gateway = String(fields[10] || fields[6] || '');
518
+ const accountId = String(fields[2] || '');
519
+ if (activeAccountId && accountId && accountId !== activeAccountId) {
520
+ log(`cursor-backend: account ${activeAccountId} -> ${accountId} (drop previous tray)`);
521
+ cacheFallbackOk = true;
522
+ }
523
+ activeAccountId = accountId || activeAccountId;
524
+ if (activeAccountId) migrateLegacyPod(activeAccountId);
462
525
  realPod = {
463
526
  execDaemon,
464
527
  agent: gateway, // /api/sendPrompt lives on gateway_url (1340), not exec_daemon (1337)
@@ -469,18 +532,19 @@ function rememberPod(fields, log) {
469
532
  p1340: gateway,
470
533
  p6081: fields[12] ? String(fields[12]) : undefined,
471
534
  region: String(fields[1] || 'us1'),
472
- accountId: String(fields[2] || ''),
535
+ accountId,
473
536
  podId: String(fields[3] || ''),
474
537
  };
475
538
  sniffDump({ kind: 'pod', fields, realPod });
476
539
  savePod(realPod);
477
- log(`cursor-backend: SNIFF real pod ${realPod.agent}`);
540
+ podStale = false;
541
+ log(`cursor-backend: SNIFF real pod account=${accountId || '?'} ${realPod.agent}`);
478
542
  return realPod;
479
543
  }
480
544
 
481
- function rewrittenBox() {
545
+ function rewrittenBox({ confirmed = true } = {}) {
482
546
  const self = sniffSelf();
483
- const p = realPod || {};
547
+ const p = (confirmed && realPod) ? realPod : {};
484
548
  return encodeEnsureSandBox({
485
549
  region: p.region || 'us1',
486
550
  accountId: p.accountId || 'openzoo',
@@ -494,6 +558,126 @@ function rewrittenBox() {
494
558
  });
495
559
  }
496
560
 
561
+ function replyEnsureBox(req, res, payload, full = '') {
562
+ const ct = String(req.headers['content-type'] || '');
563
+ if (/WatchSandBoxMigration/.test(full) || ct.includes('connect+proto')) {
564
+ res.writeHead(200, {
565
+ 'content-type': 'application/connect+proto',
566
+ 'grpc-status': '0',
567
+ ...CORS,
568
+ });
569
+ const end = Buffer.from('{}');
570
+ const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
571
+ res.end(Buffer.concat([envelope(payload), h, end]));
572
+ } else if (ct.includes('grpc-web')) {
573
+ res.writeHead(200, {
574
+ 'content-type': ct.includes('text') ? 'application/grpc-web-text+proto' : 'application/grpc-web+proto',
575
+ 'grpc-status': '0', ...CORS,
576
+ });
577
+ res.end(Buffer.concat([envelope(payload), grpcWebTrailer()]));
578
+ } else {
579
+ res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
580
+ res.end(payload);
581
+ }
582
+ }
583
+
584
+ const discover = { promise: null, at: 0, ok: false };
585
+ let lastBoxReq = null;
586
+ let podStale = false;
587
+ let lastOauthAt = 0;
588
+ // Same-user relaunch may fall back to the last pod if api2 times out.
589
+ // A second login is detected by EnsureSandBox accountId, not by rotating Bearer.
590
+ let cacheFallbackOk = true;
591
+
592
+ function boxDiscoverHeaders(req) {
593
+ const headers = copyReqHeaders(req, 'api2.cursor.sh');
594
+ headers['content-type'] = 'application/proto';
595
+ headers.accept = 'application/proto';
596
+ delete headers['connect-protocol-version'];
597
+ delete headers['connect-timeout-ms'];
598
+ delete headers['grpc-timeout'];
599
+ delete headers.te;
600
+ return headers;
601
+ }
602
+
603
+ async function fetchEnsureSandBox(req, body, log) {
604
+ const upstream = 'api2.cursor.sh';
605
+ const headers = boxDiscoverHeaders(req);
606
+ const cap = await upstreamUnary({
607
+ host: upstream,
608
+ path: '/aiserver.v1.GrokBotService/EnsureSandBox',
609
+ method: 'POST',
610
+ headers,
611
+ body: body && body.length ? body : Buffer.alloc(0),
612
+ timeoutMs: 60000,
613
+ });
614
+ const raw = inflateBody(cap.buf, cap.respHeaders);
615
+ const proto = unwrapConnect(raw);
616
+ const fields = decodeProtoFields(proto);
617
+ if (!fields?.[6]) {
618
+ throw new Error(`EnsureSandBox empty (${cap.status}, ${cap.buf.length}b)`);
619
+ }
620
+ rememberPod(fields, log);
621
+ try {
622
+ const remote = await podJson('/api/listAgents', {}, log);
623
+ if (Array.isArray(remote) && remote.length) {
624
+ const merged = mergeAgentLists(remote);
625
+ saveAgents(merged);
626
+ const active = focusedAgentId || merged[0]?.id;
627
+ ssePush('agents', { agents: merged.slice(0, 80).map(stampActivity), activeAgentId: active });
628
+ log(`cursor-backend: discovered roster n=${merged.length} account=${realPod.accountId}`);
629
+ }
630
+ } catch (e) {
631
+ log(`cursor-backend: discover roster ${e.message}`);
632
+ }
633
+ return realPod;
634
+ }
635
+
636
+ function ensureDiscover(req, body, log) {
637
+ lastBoxReq = { headers: { ...req.headers }, body: Buffer.from(body || []) };
638
+ if (discover.promise) return discover.promise;
639
+ const oauthFresh = lastOauthAt > discover.at;
640
+ if (discover.ok && realPod?.agent && !podStale && !oauthFresh) {
641
+ return Promise.resolve(realPod);
642
+ }
643
+ discover.at = Date.now();
644
+ const fakeReq = { headers: lastBoxReq.headers };
645
+ discover.promise = fetchEnsureSandBox(fakeReq, Buffer.alloc(0), log)
646
+ .then((p) => {
647
+ discover.ok = !!p?.agent;
648
+ discover.promise = null;
649
+ return p;
650
+ })
651
+ .catch((e) => {
652
+ discover.ok = false;
653
+ discover.promise = null;
654
+ log(`cursor-backend: EnsureSandBox discover failed (${e.message})`);
655
+ return null;
656
+ });
657
+ return discover.promise;
658
+ }
659
+
660
+ async function waitForAccountPod(log, ms = 45000) {
661
+ if (realPod?.agent && !podStale && discover.ok) return realPod;
662
+ if (!discover.promise && lastBoxReq) {
663
+ ensureDiscover({ headers: lastBoxReq.headers }, lastBoxReq.body, log);
664
+ }
665
+ if (!discover.promise) return realPod && !podStale ? realPod : null;
666
+ try {
667
+ const got = await Promise.race([
668
+ discover.promise,
669
+ new Promise((resolve) => setTimeout(() => resolve('timeout'), ms)),
670
+ ]);
671
+ if (got === 'timeout') {
672
+ log('cursor-backend: listAgents waited on discover — still going');
673
+ return realPod && !podStale && cacheFallbackOk ? realPod : null;
674
+ }
675
+ return got && !podStale ? got : (realPod && !podStale && cacheFallbackOk ? realPod : null);
676
+ } catch {
677
+ return realPod && !podStale && cacheFallbackOk ? realPod : null;
678
+ }
679
+ }
680
+
497
681
  async function sniffEnsureSandBox(req, res, body, host, full, log) {
498
682
  const upstream = cursorUpstream(host);
499
683
  const headers = copyReqHeaders(req, upstream);
@@ -539,7 +723,7 @@ async function sniffEnsureSandBox(req, res, body, host, full, log) {
539
723
  }
540
724
 
541
725
  async function proxyPodHttp(req, res, full, body, log) {
542
- if (!realPod?.agent) return false;
726
+ if (podStale || !realPod?.agent) return false;
543
727
  const agent = new URL(realPod.agent);
544
728
  const headers = copyReqHeaders(req, agent.host);
545
729
  // Incoming Authorization is Grok Bot oauth to api2. 1340 wants EnsureSandBox
@@ -593,6 +777,22 @@ async function proxyPodHttp(req, res, full, body, log) {
593
777
  body,
594
778
  timeoutMs: path0 === '/health' ? 8000 : 120000,
595
779
  });
780
+ if (cap.status === 417 || cap.status === 404) {
781
+ podStale = true;
782
+ log(`cursor-backend: 1340 ${cap.status} ${path0} — pod stale, local`);
783
+ if (path0 === '/api/listAgents') {
784
+ const cached = rosterForAccount({
785
+ liveAccountId: activeAccountId,
786
+ cachedAccountId: activeAccountId,
787
+ cached: loadAgents(),
788
+ });
789
+ if (cached.length) {
790
+ jsonSend(res, mergeAgentLists(cached));
791
+ return true;
792
+ }
793
+ }
794
+ return false;
795
+ }
596
796
  if (path0 === '/api/listAgents' && cap.status === 200) {
597
797
  try {
598
798
  const parsed = JSON.parse(String(inflateBody(cap.buf, cap.respHeaders)));
@@ -600,16 +800,20 @@ async function proxyPodHttp(req, res, full, body, log) {
600
800
  const merged = mergeAgentLists(parsed);
601
801
  saveAgents(merged);
602
802
  jsonSend(res, merged);
603
- log(`cursor-backend: listAgents 200 merged n=${merged.length}`);
803
+ log(`cursor-backend: listAgents 200 merged n=${merged.length} account=${activeAccountId || '?'}`);
604
804
  return true;
605
805
  }
606
806
  } catch { /* */ }
607
807
  }
608
- if (cap.status === 401 && path0 === '/api/listAgents') {
609
- const cached = mergeAgentLists([]);
808
+ if (cap.status !== 200 && path0 === '/api/listAgents') {
809
+ const cached = rosterForAccount({
810
+ liveAccountId: activeAccountId,
811
+ cachedAccountId: activeAccountId,
812
+ cached: loadAgents(),
813
+ });
610
814
  if (cached.length) {
611
- jsonSend(res, cached);
612
- log(`cursor-backend: listAgents 401 — cached ${cached.length} named agents`);
815
+ jsonSend(res, mergeAgentLists(cached));
816
+ log(`cursor-backend: listAgents ${cap.status} — cached ${cached.length} named agents account=${activeAccountId}`);
613
817
  return true;
614
818
  }
615
819
  }
@@ -1169,7 +1373,13 @@ async function writeLocalBytes(abs, bytes, log) {
1169
1373
  return `wrote ${abs} (${buf.length} bytes)`;
1170
1374
  }
1171
1375
  async function execLocal(command, cwd, log) {
1172
- const dir = cwd ? expandUserPath(cwd) : os.homedir();
1376
+ let dir = cwd ? expandUserPath(cwd) : os.homedir();
1377
+ try {
1378
+ if (!fs.statSync(dir).isDirectory()) dir = os.homedir();
1379
+ } catch {
1380
+ // Node reports spawn /bin/zsh ENOENT when cwd is missing — not a missing shell.
1381
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { dir = os.homedir(); }
1382
+ }
1173
1383
  if (localExecSse.size > 0) {
1174
1384
  log(`cursor-backend: local-exec exec ${JSON.stringify(command).slice(0, 80)}`);
1175
1385
  const got = await localExecAsk({
@@ -1180,6 +1390,7 @@ async function execLocal(command, cwd, log) {
1180
1390
  const err = got.stderr ? `\nstderr:\n${got.stderr}` : '';
1181
1391
  return `${out}${err}`.trim() || `(exit ${got.exitCode ?? 0})`;
1182
1392
  }
1393
+ log(`cursor-backend: exec cwd=${dir} ${JSON.stringify(String(command).slice(0, 80))}`);
1183
1394
  const { stdout, stderr } = await execFileAsync('/bin/zsh', ['-lc', command], {
1184
1395
  cwd: dir,
1185
1396
  timeout: 30000,
@@ -1351,6 +1562,22 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1351
1562
  r = await post();
1352
1563
  }
1353
1564
  const data = await r.json();
1565
+ if (r.status === 402) {
1566
+ const raw = zooTextFromMessage(data?.choices?.[0]?.message, data)
1567
+ || data?.error?.message
1568
+ || 'openzoo wallet underfunded.';
1569
+ try {
1570
+ const { withOnrampLink } = await import('./stripeOnramp.js');
1571
+ const { loadOrCreateWallet } = await import('./wallet.js');
1572
+ const w = loadOrCreateWallet();
1573
+ const usd = Number(String(raw).match(/≈\$([0-9.]+)/)?.[1]);
1574
+ data.error = data.error || {};
1575
+ data.error.message = await withOnrampLink(raw, {
1576
+ solana: w.keypair.publicKey.toBase58(),
1577
+ usd,
1578
+ });
1579
+ } catch { /* keep proxy copy */ }
1580
+ }
1354
1581
  return { r, data };
1355
1582
  };
1356
1583
  for (let step = 0; step < 8; step++) {
@@ -1465,6 +1692,11 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1465
1692
  }
1466
1693
  if (!path0.startsWith('/api/')) return false;
1467
1694
  const name = path0.slice('/api/'.length);
1695
+ if (name === 'getHostStatus') {
1696
+ jsonSend(res, { status: 'ready', ready: true, state: 'ready', hostStatus: 'ready' });
1697
+ log('cursor-backend: -> getHostStatus ready');
1698
+ return true;
1699
+ }
1468
1700
  // Roster/settings come from the REAL 1340 gateway (names, avatars, trays).
1469
1701
  // Chat stays local so inference is zoo. Discovered on EnsureSandBox rewrite.
1470
1702
  const roster = new Set([
@@ -1472,7 +1704,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1472
1704
  'setHostSettings', 'getAgentChannels', 'getAgentWorkflows', 'skillsCatalog',
1473
1705
  'getSubagents', 'getAsyncTasks', 'getForeverBoxStatus', 'getSharingState',
1474
1706
  'getBotTemplateExportPolicy', 'getTeachRecordingStatus', 'isGlobalSearchEnabled',
1475
- 'isEgressTunnelAvailable', 'listBoxMcpServers', 'getHostStatus',
1707
+ 'isEgressTunnelAvailable', 'listBoxMcpServers',
1476
1708
  'setWindowFocused', 'getAgentAutomations',
1477
1709
  'createGroup', 'setGroupMembers',
1478
1710
  'interruptAgentRun', 'requestDiskSaverAudit', 'broadcastToAgents',
@@ -1528,15 +1760,20 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1528
1760
  log(`cursor-backend: deleteAgents n=${ids.size}`);
1529
1761
  return true;
1530
1762
  }
1531
- if (name === 'listAgents') {
1532
- if (!sniffOn() && realPod?.agent) {
1533
- const proxied = await proxyPodHttp(req, res, full, body, log);
1534
- if (proxied) return true;
1763
+ if (name === 'listAgents' || name === 'getTrays' || name === 'countAgents' || name === 'searchAgents') {
1764
+ if (!sniffOn()) {
1765
+ const pod = await waitForAccountPod(log);
1766
+ if (pod?.agent) {
1767
+ const proxied = await proxyPodHttp(req, res, full, body, log);
1768
+ if (proxied) return true;
1769
+ }
1770
+ }
1771
+ if (name === 'listAgents') {
1772
+ const list = mergeAgentLists([]);
1773
+ jsonSend(res, list);
1774
+ log(`cursor-backend: listAgents local n=${list.length} account=${activeAccountId || 'none'}`);
1775
+ return true;
1535
1776
  }
1536
- const list = mergeAgentLists([]);
1537
- jsonSend(res, list);
1538
- log(`cursor-backend: listAgents local n=${list.length}`);
1539
- return true;
1540
1777
  }
1541
1778
 
1542
1779
  if (!sniffOn() && realPod?.agent && roster.has(name)) {
@@ -1595,7 +1832,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1595
1832
  const line = fanoutLine(agentId, 'assistant', text, { clientNonce: nonce, requestId: nonce });
1596
1833
  ssePush('transcript', { ...gatewayEntry(line), agentId });
1597
1834
  bumpAgent(agentId, { preview: text, notify: true });
1598
- log(`cursor-backend: sendPrompt done agent=${agentId} seq=${line.seq} text=${JSON.stringify(text.slice(0, 80))}`);
1835
+ log(`cursor-backend: sendPrompt done agent=${agentId} seq=${line.seq} text=${JSON.stringify(text.slice(0, 220))}`);
1599
1836
  });
1600
1837
  return true;
1601
1838
  }
@@ -1855,6 +2092,7 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
1855
2092
  if (oauth || sandBox || grokCred || needRealPod) {
1856
2093
  const upstream = /cursor\.sh$/.test(host) ? host : 'api2.cursor.sh';
1857
2094
  await passthroughToRealAnthropic(req, res, body, upstream, full, log);
2095
+ if (oauth) lastOauthAt = Date.now();
1858
2096
  return;
1859
2097
  }
1860
2098
  if (await handleLocalExecHttp(req, res, path0, body, log)) return;
@@ -1867,50 +2105,27 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
1867
2105
  // with OUR box so Grok Bot's UI wires to our sandbox; everything else
1868
2106
  // still passes through so the app loads normally.
1869
2107
  if (/GrokBotService\/(EnsureSandBox|WatchSandBoxMigration)/.test(full) && process.env.OZ_HIJACK_POD) {
1870
- try {
1871
- process.env.OZ_SNIFF_SELF = process.env.OZ_SNIFF_SELF || 'https://127.0.0.1:8443';
1872
- await sniffEnsureSandBox(req, res, body, host, full, log);
1873
- log('cursor-backend: -> HIJACKED EnsureSandBox -> our box (roster from real 1340)');
2108
+ // Watch is a connect STREAM. Never await api2 on it — that is the
2109
+ // splash hang. Reply immediately, discover THIS caller's 1340 in
2110
+ // the background so listAgents can wait for their tray, not the
2111
+ // machine-global cache from the last login on this Mac.
2112
+ process.env.OZ_SNIFF_SELF = process.env.OZ_SNIFF_SELF || 'https://127.0.0.1:8443';
2113
+ const confirmed = discover.ok && realPod?.agent && !podStale;
2114
+ if (/WatchSandBoxMigration/.test(full)) {
2115
+ replyEnsureBox(req, res, rewrittenBox({ confirmed }), full);
2116
+ ensureDiscover(req, Buffer.alloc(0), log);
2117
+ log(`cursor-backend: -> WatchSandBoxMigration ready (${confirmed ? `account ${realPod.accountId}` : 'env box + discover'})`);
1874
2118
  return;
1875
- } catch (e) {
1876
- if (realPod?.agent) {
1877
- log(`cursor-backend: EnsureSandBox discover failed (${e.message}) — cached 1340 roster`);
1878
- const payload = rewrittenBox();
1879
- const reqCt = String(req.headers['content-type'] || '');
1880
- if (/WatchSandBoxMigration/.test(full) || reqCt.includes('connect+proto')) {
1881
- res.writeHead(200, {
1882
- 'content-type': 'application/connect+proto',
1883
- 'grpc-status': '0',
1884
- ...CORS,
1885
- });
1886
- const end = Buffer.from('{}');
1887
- const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
1888
- res.end(Buffer.concat([envelope(payload), h, end]));
1889
- } else {
1890
- res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
1891
- res.end(payload);
1892
- }
1893
- return;
1894
- }
1895
- log(`cursor-backend: EnsureSandBox discover failed (${e.message}) — env box`);
1896
2119
  }
1897
- let pod;
1898
- try { pod = JSON.parse(process.env.OZ_HIJACK_POD); } catch { pod = null; }
1899
- if (pod && /WatchSandBoxMigration/.test(full)) {
1900
- const payload = encodeEnsureSandBox(pod);
1901
- res.writeHead(200, {
1902
- 'content-type': 'application/connect+proto',
1903
- 'grpc-status': '0',
1904
- ...CORS,
1905
- });
1906
- const end = Buffer.from('{}');
1907
- const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
1908
- res.end(Buffer.concat([envelope(payload), h, end]));
1909
- log('cursor-backend: -> WatchSandBoxMigration ready (hijack)');
2120
+ try {
2121
+ const pod = await ensureDiscover(req, body, log);
2122
+ replyEnsureBox(req, res, rewrittenBox({ confirmed: !!pod?.agent }), full);
2123
+ log(`cursor-backend: -> HIJACKED EnsureSandBox -> our box (roster from real 1340 account=${pod?.accountId || '?'})`);
1910
2124
  return;
2125
+ } catch (e) {
2126
+ log(`cursor-backend: EnsureSandBox discover failed (${e.message}) — ${realPod?.agent && cacheFallbackOk ? 'cached 1340' : 'env box'}`);
1911
2127
  }
1912
- respond(req, res, 'EnsureSandBox', models);
1913
- log(`cursor-backend: -> HIJACKED EnsureSandBox -> our box`);
2128
+ replyEnsureBox(req, res, rewrittenBox({ confirmed: !!(realPod?.agent && cacheFallbackOk) }), full);
1914
2129
  return;
1915
2130
  }
1916
2131
  // CAPTURE the chat inference request so its schema can be decoded from
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Grok Bot tray/roster is per Cursor account, not per Mac.
3
+ *
4
+ * A second login on the same hijack was served ~/.openzoo/grokbot-agents.json
5
+ * (or an empty stub) because pod + roster were machine-global. Historical
6
+ * chats live on that account's cursorvm 1340 — never another account's cache.
7
+ */
8
+ import path from 'node:path';
9
+
10
+ export function accountSlug(accountId) {
11
+ const s = String(accountId || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64);
12
+ return s || null;
13
+ }
14
+
15
+ export function accountDir(home, accountId) {
16
+ const slug = accountSlug(accountId);
17
+ if (!slug) return null;
18
+ return path.join(home, '.openzoo', 'grokbot', slug);
19
+ }
20
+
21
+ export function accountPodPath(home, accountId) {
22
+ const dir = accountDir(home, accountId);
23
+ return dir ? path.join(dir, 'pod.json') : null;
24
+ }
25
+
26
+ export function accountAgentsPath(home, accountId) {
27
+ const dir = accountDir(home, accountId);
28
+ return dir ? path.join(dir, 'agents.json') : null;
29
+ }
30
+
31
+ /** Only serve a cached tray when it belongs to the live EnsureSandBox account. */
32
+ export function rosterForAccount({ liveAccountId, cachedAccountId, cached }) {
33
+ if (!liveAccountId || !cachedAccountId) return [];
34
+ if (liveAccountId !== cachedAccountId) return [];
35
+ return Array.isArray(cached) ? cached : [];
36
+ }
37
+
38
+ export function callerKeyFromAuth(authorization) {
39
+ const a = String(authorization || '').trim();
40
+ if (!a) return '';
41
+ // Not a secret store — a session-scoped equality key so a second oauth
42
+ // does not inherit the previous account's 1340 / tray cache.
43
+ let h = 2166136261;
44
+ for (let i = 0; i < a.length; i++) {
45
+ h ^= a.charCodeAt(i);
46
+ h = Math.imul(h, 16777619);
47
+ }
48
+ return (h >>> 0).toString(16);
49
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * MoonPay-hosted fiat→USDC onramp for the x402 402 path.
3
+ *
4
+ * Unlike Stripe, there is no session POST: we build
5
+ * https://buy.moonpay.com/?apiKey=pk_…&currencyCode=usdc_sol&walletAddress=…
6
+ * and HMAC-SHA256 sign the query string with the secret. The dest is locked
7
+ * because walletAddress + currencyCode + signature are required together —
8
+ * the widget does not prompt for another address.
9
+ *
10
+ * Keys are NEVER in the repo: MOONPAY_PUBLISHABLE_KEY + MOONPAY_SECRET_KEY,
11
+ * else ~/moonpay.json `{publishableKey,secretKey}`, else ~/moonpay.pk +
12
+ * ~/moonpay.key. No keys → caller keeps the send-to-address copy.
13
+ *
14
+ * Do NOT set allowedIpAddress. Live IP matching would bind the URL to the
15
+ * shim/gateway IP; the 402 is opened on the user's machine (or phone).
16
+ */
17
+ import crypto from 'node:crypto';
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+
22
+ const HOME = os.homedir();
23
+
24
+ function readTrim(p) {
25
+ try { return fs.readFileSync(p, 'utf8').trim(); } catch { return ''; }
26
+ }
27
+
28
+ export function moonpayKeys() {
29
+ const jsonPath = process.env.MOONPAY_KEY_FILE || path.join(HOME, 'moonpay.json');
30
+ let filePk = '';
31
+ let fileSk = '';
32
+ try {
33
+ const j = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
34
+ filePk = String(j.publishableKey || j.pk || j.apiKey || '').trim();
35
+ fileSk = String(j.secretKey || j.sk || j.secret || '').trim();
36
+ } catch { /* no json */ }
37
+ const pk = String(process.env.MOONPAY_PUBLISHABLE_KEY || process.env.MOONPAY_API_KEY || '').trim()
38
+ || filePk
39
+ || readTrim(process.env.MOONPAY_PK_FILE || path.join(HOME, 'moonpay.pk'));
40
+ const sk = String(process.env.MOONPAY_SECRET_KEY || '').trim()
41
+ || fileSk
42
+ || readTrim(process.env.MOONPAY_SECRET_FILE || path.join(HOME, 'moonpay.key'));
43
+ if (!pk || !sk) return null;
44
+ return { pk, sk };
45
+ }
46
+
47
+ function dollars(usd) {
48
+ const n = Number(usd);
49
+ // MoonPay USDC min is typically ~$20–30, not Stripe's $5.
50
+ if (!Number.isFinite(n) || n <= 0) return 30;
51
+ return Math.max(30, Math.ceil(n));
52
+ }
53
+
54
+ /** HMAC-SHA256 of `url.search` (includes leading `?`), then URL-encode. */
55
+ export function signMoonPayUrl(unsignedUrl, secret) {
56
+ const u = new URL(unsignedUrl);
57
+ const signature = crypto.createHmac('sha256', secret).update(u.search).digest('base64');
58
+ return `${unsignedUrl}${u.search ? '&' : '?'}signature=${encodeURIComponent(signature)}`;
59
+ }
60
+
61
+ /**
62
+ * Signed widget URL that buys USDC on Solana into `solana`. Sync — no network.
63
+ * Returns null if keys are missing.
64
+ */
65
+ export function moonpayUsdcOnrampLink({ solana, usd } = {}) {
66
+ const addr = String(solana || '').trim();
67
+ if (!addr) return null;
68
+ const keys = moonpayKeys();
69
+ if (!keys) return null;
70
+ const live = keys.pk.startsWith('pk_live_');
71
+ const host = live ? 'https://buy.moonpay.com' : 'https://buy-sandbox.moonpay.com';
72
+ const params = new URLSearchParams();
73
+ params.set('apiKey', keys.pk);
74
+ params.set('currencyCode', 'usdc_sol');
75
+ params.set('walletAddress', addr);
76
+ params.set('baseCurrencyCode', 'usd');
77
+ params.set('baseCurrencyAmount', String(dollars(usd)));
78
+ const unsigned = `${host}/?${params.toString()}`;
79
+ return signMoonPayUrl(unsigned, keys.sk);
80
+ }
package/lib/pay.js CHANGED
@@ -9,6 +9,7 @@ import { buildEvmPayment, evmTokenBalance } from './evm.js';
9
9
  import { privateKeyToAccount } from 'viem/accounts';
10
10
  import { withNamespace } from './namespace.js';
11
11
  import { fetchHeaders } from './fetch.js';
12
+ import { withOnrampLink } from './stripeOnramp.js';
12
13
 
13
14
  /** Drop any inherited bearer — see the call site: x402 is the only pay lane. */
14
15
  function stripAuthorization(headers = {}) {
@@ -222,7 +223,14 @@ export class PayClient {
222
223
  // to convert and no pool to walk: the wallet either holds the asset or it
223
224
  // does not. This branch used to run resolvePool + poolState + a wrap
224
225
  // (~4.5s) to mint a Token-2022 twin the gateway no longer accepts.
225
- if (bal.raw < need) throw new UnderfundedError(accept, bal.ui, this.address);
226
+ if (bal.raw < need) {
227
+ const usd = Number(accept?.extra?.billedUsd);
228
+ const line = await withOnrampLink(
229
+ `Send USDC (or TOKEN/LEOS) on Solana to ${this.address}.`,
230
+ { solana: this.address, usd },
231
+ );
232
+ throw new UnderfundedError(accept, bal.ui, this.address, { line });
233
+ }
226
234
  const built = await buildPaymentOnline(this.connection, this.keypair, accept);
227
235
  return { ...built, header: encodeEnvelope(paymentEnvelope(challenge, accept, built.payload)) };
228
236
  }
@@ -244,7 +252,10 @@ export class PayClient {
244
252
  const sym = accept?.extra?.symbol || 'the quoted asset';
245
253
  const where = rail === 'robinhood' ? 'Robinhood Chain' : rail === 'base' ? 'Base' : accept.network;
246
254
  throw new UnderfundedError(accept, null, owner, {
247
- line: `Send a few cents of ${sym} on ${where} to ${owner}.`,
255
+ line: await withOnrampLink(
256
+ `Send a few cents of ${sym} on ${where} to ${owner}.`,
257
+ { solana: this.address, usd: Number(accept?.extra?.billedUsd) },
258
+ ),
248
259
  });
249
260
  }
250
261
  }
@@ -334,8 +345,11 @@ export class PayClient {
334
345
  if (!fundErrs.length && tooHigh) throw tooHigh;
335
346
  if (fundErrs.length === 1) throw fundErrs[0].err;
336
347
  throw new UnderfundedError(candidates[0], null, this.address, {
337
- line: `No offered payment row is affordable from this wallet (tried ${fundErrs.length}):\n`
338
- + fundErrs.map(({ sym, err }) => ` · ${sym}: ${err.message.replace(/^openzoo wallet underfunded: /, '')}`).join('\n'),
348
+ line: await withOnrampLink(
349
+ `No offered payment row is affordable from this wallet (tried ${fundErrs.length}):\n`
350
+ + fundErrs.map(({ sym, err }) => ` · ${sym}: ${err.message.replace(/^openzoo wallet underfunded: /, '')}`).join('\n'),
351
+ { solana: this.address, usd: Number(candidates[0]?.extra?.billedUsd) },
352
+ ),
339
353
  });
340
354
  }
341
355
  onStage?.('paying');
package/lib/proxy.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  config, FUNDING_ASSETS, EVM_FUNDING_ASSETS, evmRpcFor, fundingLine, liveRails, railFundingHint, railFundingAddresses, unfundableRails, RAIL_FUNDING,
9
9
  } from './config.js';
10
10
  import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
11
+ import { withOnrampLink } from './stripeOnramp.js';
11
12
  import { tokenBalance } from './x402.js';
12
13
  import { evmTokenBalance } from './evm.js';
13
14
  import { autoContext } from './autobind.js';
@@ -760,15 +761,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
760
761
  // need: the price, what the wallet holds, and where to send funds.
761
762
  if (response.status === 402) {
762
763
  let quoted = '';
764
+ let usd;
763
765
  try {
764
766
  const q = await response.clone().json();
765
- const usd = Number(q?.accepts?.[0]?.extra?.billedUsd);
767
+ usd = Number(q?.accepts?.[0]?.extra?.billedUsd);
766
768
  if (Number.isFinite(usd)) quoted = ` This call needs ≈$${usd.toFixed(4)}.`;
767
769
  } catch { /* body was not the quote after all */ }
768
- jsonErr(res, 402,
770
+ const msg = await withOnrampLink(
769
771
  `openzoo wallet underfunded — payment did not settle.${quoted} `
770
772
  + `Fund it and retry: send USDC (or TOKEN/LEOS for half price) to ${client.address} on Solana, `
771
- + `or USDC to ${client.evmAddress} on Base. Check with: openzoo balance`);
773
+ + `or USDC to ${client.evmAddress} on Base. Check with: openzoo balance`,
774
+ { solana: client.address, usd },
775
+ );
776
+ log(/ties to your account/i.test(msg) ? 'onramp: whop + copy-paste solana' : 'onramp: no fund blurb');
777
+ jsonErr(res, 402, msg);
772
778
  return;
773
779
  }
774
780
  await relay(res, response, meterStreamed);
@@ -777,8 +783,11 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
777
783
  log(err.message);
778
784
  jsonErr(res, 402, err.message, { quote: err.quote });
779
785
  } else if (err instanceof UnderfundedError) {
780
- log(err.message);
781
- jsonErr(res, 402, err.message);
786
+ const msg = await withOnrampLink(err.message, {
787
+ solana: client.address, usd: Number(err.accept?.extra?.billedUsd),
788
+ });
789
+ log(msg);
790
+ jsonErr(res, 402, msg);
782
791
  } else {
783
792
  // "fetch failed" alone is undiagnosable — undici hides the real
784
793
  // network error in `cause`. Surface it or every transport hiccup looks
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Stripe-hosted fiat→USDC onramp (crypto.link.com) for the x402 402 path.
3
+ *
4
+ * Secret is NEVER in the repo: STRIPE_SECRET_KEY, else STRIPE_SECRET_FILE,
5
+ * else ~/stripey.key. Onramp sessions lock the destination to the local
6
+ * Solana burner so a card checkout lands USDC where PayClient spends.
7
+ *
8
+ * Solana-only: Stripe 400s `wallet_addresses[base]` (parameter_unknown),
9
+ * which silently dropped the hosted URL from live Grok 402 replies.
10
+ */
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+
16
+ const STRIPE_VERSION = '2026-06-24.dahlia';
17
+ const TTL_MS = 10 * 60 * 1000;
18
+ const cache = new Map(); // key -> { url, at }
19
+
20
+ function stripeSecret() {
21
+ const env = String(process.env.STRIPE_SECRET_KEY || '').trim();
22
+ if (env) return env;
23
+ const file = process.env.STRIPE_SECRET_FILE || path.join(os.homedir(), 'stripey.key');
24
+ return fs.readFileSync(file, 'utf8').trim();
25
+ }
26
+
27
+ function dollars(usd) {
28
+ const n = Number(usd);
29
+ if (!Number.isFinite(n) || n <= 0) return 10;
30
+ return Math.max(5, Math.ceil(n));
31
+ }
32
+
33
+ /** Test seam. */
34
+ export function resetOnrampCache() { cache.clear(); }
35
+
36
+ /**
37
+ * Mint (or reuse) a Stripe-hosted onramp URL that sends USDC to `solana`.
38
+ * Returns null if the key is missing or Stripe refuses — caller keeps the
39
+ * existing send-to-address copy. `evm` is ignored: Stripe does not accept
40
+ * `wallet_addresses[base]` on this API.
41
+ */
42
+ export async function stripeUsdcOnrampLink({ solana, usd } = {}) {
43
+ const addr = String(solana || '').trim();
44
+ if (!addr) return null;
45
+ const amt = dollars(usd);
46
+ const key = `${addr}|${amt}`;
47
+ const hit = cache.get(key);
48
+ if (hit && Date.now() - hit.at < TTL_MS && hit.url) return hit.url;
49
+ let secret;
50
+ try { secret = stripeSecret(); } catch { return null; }
51
+ if (!secret) return null;
52
+ const body = new URLSearchParams();
53
+ body.set('destination_currency', 'usdc');
54
+ body.set('destination_network', 'solana');
55
+ body.append('destination_currencies[]', 'usdc');
56
+ body.append('destination_networks[]', 'solana');
57
+ body.set('source_currency', 'usd');
58
+ body.set('source_amount', String(amt));
59
+ body.set('lock_wallet_address', 'true');
60
+ body.set('wallet_addresses[solana]', addr);
61
+ try {
62
+ const r = await fetch('https://api.stripe.com/v1/crypto/onramp_sessions', {
63
+ method: 'POST',
64
+ headers: {
65
+ authorization: `Bearer ${secret}`,
66
+ 'content-type': 'application/x-www-form-urlencoded',
67
+ 'Stripe-Version': STRIPE_VERSION,
68
+ },
69
+ body,
70
+ signal: AbortSignal.timeout(15000),
71
+ });
72
+ const data = await r.json();
73
+ const url = data?.redirect_url;
74
+ if (!r.ok || !url) {
75
+ const err = data?.error;
76
+ console.error(`openzoo onramp: stripe ${r.status} ${err?.code || err?.type || 'no-url'} ${(err?.message || '').slice(0, 160)}`);
77
+ return null;
78
+ }
79
+ cache.set(key, { url, at: Date.now() });
80
+ return url;
81
+ } catch (e) {
82
+ console.error(`openzoo onramp: ${e.message}`);
83
+ return null;
84
+ }
85
+ }
86
+
87
+ const DEFAULT_WHOP_CHECKOUT = 'https://whop.com/staccoverflow/openzoo';
88
+
89
+ /** Whop product / checkout. OPENZOO_WHOP_CHECKOUT overrides the default product URL. */
90
+ export function whopBuyUrl() {
91
+ return String(process.env.OPENZOO_WHOP_CHECKOUT || process.env.OPENZOO_WHOP_URL || DEFAULT_WHOP_CHECKOUT).trim();
92
+ }
93
+
94
+ export function whopFundBlurb(solana) {
95
+ const addr = String(solana || '').trim();
96
+ if (!addr) return '';
97
+ const url = whopBuyUrl();
98
+ return [
99
+ `Hey — buy this: ${url}`,
100
+ '',
101
+ 'Copy-paste THIS Solana address into "what is your Solana address?" so it ties to your account:',
102
+ addr,
103
+ ].join('\n');
104
+ }
105
+
106
+ export async function withOnrampLink(text, dest) {
107
+ const body = String(text || '').trim();
108
+ const blurb = whopFundBlurb(dest?.solana);
109
+ if (!blurb) return body;
110
+ if (/ties to your account/i.test(body) && body.includes(String(dest.solana))) return body;
111
+ return `${blurb}\n\n${body}`;
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.26",
3
+ "version": "0.50.28",
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",