openzoo 0.50.25 → 0.50.27

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
  }
@@ -663,6 +867,56 @@ function ssePush(channel, payload) {
663
867
  }
664
868
  }
665
869
 
870
+ let focusedAgentId = null;
871
+ const agentActivity = new Map(); // id -> { updatedAt, unreadCount, hasUnread, preview }
872
+ function noteFocus(id) {
873
+ if (!id) return;
874
+ focusedAgentId = id;
875
+ const a = agentActivity.get(id) || {};
876
+ a.hasUnread = false;
877
+ a.unreadCount = 0;
878
+ agentActivity.set(id, a);
879
+ }
880
+ function stampActivity(agent) {
881
+ const a = agentActivity.get(agent.id) || {};
882
+ const updatedAt = a.updatedAt || agent.updatedAt || agent.createdAt || 0;
883
+ return {
884
+ ...agent,
885
+ updatedAt,
886
+ hasUnread: !!a.hasUnread,
887
+ unreadCount: a.unreadCount || 0,
888
+ };
889
+ }
890
+ function sortAgentsByActivity(list) {
891
+ return [...list].sort((x, y) => {
892
+ const ax = agentActivity.get(x.id)?.updatedAt || x.updatedAt || x.createdAt || 0;
893
+ const ay = agentActivity.get(y.id)?.updatedAt || y.updatedAt || y.createdAt || 0;
894
+ return ay - ax;
895
+ });
896
+ }
897
+ function bumpAgent(id, { preview = '', notify = true } = {}) {
898
+ const now = Date.now();
899
+ const a = agentActivity.get(id) || { unreadCount: 0 };
900
+ a.updatedAt = now;
901
+ if (preview) a.preview = String(preview).slice(0, 120);
902
+ const other = notify && focusedAgentId && focusedAgentId !== id;
903
+ if (other) {
904
+ a.hasUnread = true;
905
+ a.unreadCount = (a.unreadCount || 0) + 1;
906
+ }
907
+ agentActivity.set(id, a);
908
+ const list = cachedAgentList() || [];
909
+ const idx = list.findIndex((x) => x.id === id);
910
+ const base = idx >= 0 ? list[idx] : { id, name: id };
911
+ const agent = stampActivity({ ...base, updatedAt: now });
912
+ if (idx >= 0) list[idx] = agent;
913
+ else list.unshift(agent);
914
+ saveAgents(sortAgentsByActivity(list));
915
+ // asar handleGatewaySseEvent: channel agent-upserted → {agent, activeAgentId}
916
+ ssePush('agent-upserted', { agent, activeAgentId: focusedAgentId || id });
917
+ ssePush('agents', { agents: sortAgentsByActivity(cachedAgentList() || []).slice(0, 80).map(stampActivity), activeAgentId: focusedAgentId || id });
918
+ }
919
+
666
920
  /** Grok Bot Helper daemon: GET /local-exec/requests is SSE, POST /local-exec/responses
667
921
  * is `{providerId, frames}`. We used to JSON-[] the GET which is "disconnected"
668
922
  * (measured: Grok Bot "can't see files on your local computer"). */
@@ -874,9 +1128,9 @@ function mergeAgentLists(remote) {
874
1128
  for (const a of [...local, ...(Array.isArray(remote) ? remote : [])]) {
875
1129
  if (!a?.id || seen.has(a.id)) continue;
876
1130
  seen.add(a.id);
877
- out.push(a);
1131
+ out.push(stampActivity(a));
878
1132
  }
879
- return out;
1133
+ return sortAgentsByActivity(out);
880
1134
  }
881
1135
  function gatewayEntry(e) {
882
1136
  const { seq, pulledRemote, ...rest } = e;
@@ -1119,7 +1373,13 @@ async function writeLocalBytes(abs, bytes, log) {
1119
1373
  return `wrote ${abs} (${buf.length} bytes)`;
1120
1374
  }
1121
1375
  async function execLocal(command, cwd, log) {
1122
- 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
+ }
1123
1383
  if (localExecSse.size > 0) {
1124
1384
  log(`cursor-backend: local-exec exec ${JSON.stringify(command).slice(0, 80)}`);
1125
1385
  const got = await localExecAsk({
@@ -1130,6 +1390,7 @@ async function execLocal(command, cwd, log) {
1130
1390
  const err = got.stderr ? `\nstderr:\n${got.stderr}` : '';
1131
1391
  return `${out}${err}`.trim() || `(exit ${got.exitCode ?? 0})`;
1132
1392
  }
1393
+ log(`cursor-backend: exec cwd=${dir} ${JSON.stringify(String(command).slice(0, 80))}`);
1133
1394
  const { stdout, stderr } = await execFileAsync('/bin/zsh', ['-lc', command], {
1134
1395
  cwd: dir,
1135
1396
  timeout: 30000,
@@ -1301,6 +1562,22 @@ async function zooComplete(prompt, log, agentId, parsed = {}) {
1301
1562
  r = await post();
1302
1563
  }
1303
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
+ }
1304
1581
  return { r, data };
1305
1582
  };
1306
1583
  for (let step = 0; step < 8; step++) {
@@ -1415,6 +1692,11 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1415
1692
  }
1416
1693
  if (!path0.startsWith('/api/')) return false;
1417
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
+ }
1418
1700
  // Roster/settings come from the REAL 1340 gateway (names, avatars, trays).
1419
1701
  // Chat stays local so inference is zoo. Discovered on EnsureSandBox rewrite.
1420
1702
  const roster = new Set([
@@ -1422,7 +1704,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1422
1704
  'setHostSettings', 'getAgentChannels', 'getAgentWorkflows', 'skillsCatalog',
1423
1705
  'getSubagents', 'getAsyncTasks', 'getForeverBoxStatus', 'getSharingState',
1424
1706
  'getBotTemplateExportPolicy', 'getTeachRecordingStatus', 'isGlobalSearchEnabled',
1425
- 'isEgressTunnelAvailable', 'listBoxMcpServers', 'getHostStatus',
1707
+ 'isEgressTunnelAvailable', 'listBoxMcpServers',
1426
1708
  'setWindowFocused', 'getAgentAutomations',
1427
1709
  'createGroup', 'setGroupMembers',
1428
1710
  'interruptAgentRun', 'requestDiskSaverAudit', 'broadcastToAgents',
@@ -1452,6 +1734,21 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1452
1734
  log(`cursor-backend: updateAgent local id=${agent.id}`);
1453
1735
  return true;
1454
1736
  }
1737
+ if (name === 'setAgentUnread') {
1738
+ let parsed = {};
1739
+ try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
1740
+ const id = String(parsed.id || '');
1741
+ const unread = parsed.isUnread === true;
1742
+ if (id) {
1743
+ const a = agentActivity.get(id) || {};
1744
+ a.hasUnread = unread;
1745
+ a.unreadCount = unread ? Math.max(1, a.unreadCount || 1) : 0;
1746
+ agentActivity.set(id, a);
1747
+ if (!unread) noteFocus(id);
1748
+ }
1749
+ jsonSend(res, { ok: true });
1750
+ return true;
1751
+ }
1455
1752
  if (name === 'deleteAgents') {
1456
1753
  let parsed = {};
1457
1754
  try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
@@ -1463,15 +1760,20 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1463
1760
  log(`cursor-backend: deleteAgents n=${ids.size}`);
1464
1761
  return true;
1465
1762
  }
1466
- if (name === 'listAgents') {
1467
- if (!sniffOn() && realPod?.agent) {
1468
- const proxied = await proxyPodHttp(req, res, full, body, log);
1469
- 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;
1470
1776
  }
1471
- const list = mergeAgentLists([]);
1472
- jsonSend(res, list);
1473
- log(`cursor-backend: listAgents local n=${list.length}`);
1474
- return true;
1475
1777
  }
1476
1778
 
1477
1779
  if (!sniffOn() && realPod?.agent && roster.has(name)) {
@@ -1495,6 +1797,8 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1495
1797
  requestId: nonce,
1496
1798
  richText: parsed.richText,
1497
1799
  });
1800
+ noteFocus(agentId);
1801
+ bumpAgent(agentId, { preview: prompt, notify: false });
1498
1802
  ssePush('transcript', { ...gatewayEntry(userLine), agentId });
1499
1803
  jsonSend(res, { accepted: true });
1500
1804
  const modelCmd = /^\s*\/model(?:\s+(\S+))?\s*$/i.exec(prompt || '');
@@ -1527,7 +1831,8 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1527
1831
  }
1528
1832
  const line = fanoutLine(agentId, 'assistant', text, { clientNonce: nonce, requestId: nonce });
1529
1833
  ssePush('transcript', { ...gatewayEntry(line), agentId });
1530
- log(`cursor-backend: sendPrompt done agent=${agentId} seq=${line.seq} text=${JSON.stringify(text.slice(0, 80))}`);
1834
+ bumpAgent(agentId, { preview: text, notify: true });
1835
+ log(`cursor-backend: sendPrompt done agent=${agentId} seq=${line.seq} text=${JSON.stringify(text.slice(0, 220))}`);
1531
1836
  });
1532
1837
  return true;
1533
1838
  }
@@ -1535,6 +1840,7 @@ async function handleHijackedPodHttp(req, res, full, body, log) {
1535
1840
  let parsed = {};
1536
1841
  try { parsed = JSON.parse(String(body || '{}')); } catch { parsed = {}; }
1537
1842
  const id = String(parsed.id || parsed.agentId || 'openzoo');
1843
+ noteFocus(id);
1538
1844
  tailedAgents.add(id);
1539
1845
  const t = agentTranscript(id);
1540
1846
  if (!t.pulledRemote && realPod?.agent) {
@@ -1786,6 +2092,7 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
1786
2092
  if (oauth || sandBox || grokCred || needRealPod) {
1787
2093
  const upstream = /cursor\.sh$/.test(host) ? host : 'api2.cursor.sh';
1788
2094
  await passthroughToRealAnthropic(req, res, body, upstream, full, log);
2095
+ if (oauth) lastOauthAt = Date.now();
1789
2096
  return;
1790
2097
  }
1791
2098
  if (await handleLocalExecHttp(req, res, path0, body, log)) return;
@@ -1798,50 +2105,27 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
1798
2105
  // with OUR box so Grok Bot's UI wires to our sandbox; everything else
1799
2106
  // still passes through so the app loads normally.
1800
2107
  if (/GrokBotService\/(EnsureSandBox|WatchSandBoxMigration)/.test(full) && process.env.OZ_HIJACK_POD) {
1801
- try {
1802
- process.env.OZ_SNIFF_SELF = process.env.OZ_SNIFF_SELF || 'https://127.0.0.1:8443';
1803
- await sniffEnsureSandBox(req, res, body, host, full, log);
1804
- 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'})`);
1805
2118
  return;
1806
- } catch (e) {
1807
- if (realPod?.agent) {
1808
- log(`cursor-backend: EnsureSandBox discover failed (${e.message}) — cached 1340 roster`);
1809
- const payload = rewrittenBox();
1810
- const reqCt = String(req.headers['content-type'] || '');
1811
- if (/WatchSandBoxMigration/.test(full) || reqCt.includes('connect+proto')) {
1812
- res.writeHead(200, {
1813
- 'content-type': 'application/connect+proto',
1814
- 'grpc-status': '0',
1815
- ...CORS,
1816
- });
1817
- const end = Buffer.from('{}');
1818
- const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
1819
- res.end(Buffer.concat([envelope(payload), h, end]));
1820
- } else {
1821
- res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
1822
- res.end(payload);
1823
- }
1824
- return;
1825
- }
1826
- log(`cursor-backend: EnsureSandBox discover failed (${e.message}) — env box`);
1827
2119
  }
1828
- let pod;
1829
- try { pod = JSON.parse(process.env.OZ_HIJACK_POD); } catch { pod = null; }
1830
- if (pod && /WatchSandBoxMigration/.test(full)) {
1831
- const payload = encodeEnsureSandBox(pod);
1832
- res.writeHead(200, {
1833
- 'content-type': 'application/connect+proto',
1834
- 'grpc-status': '0',
1835
- ...CORS,
1836
- });
1837
- const end = Buffer.from('{}');
1838
- const h = Buffer.alloc(5); h.writeUInt8(0x02, 0); h.writeUInt32BE(end.length, 1);
1839
- res.end(Buffer.concat([envelope(payload), h, end]));
1840
- 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 || '?'})`);
1841
2124
  return;
2125
+ } catch (e) {
2126
+ log(`cursor-backend: EnsureSandBox discover failed (${e.message}) — ${realPod?.agent && cacheFallbackOk ? 'cached 1340' : 'env box'}`);
1842
2127
  }
1843
- respond(req, res, 'EnsureSandBox', models);
1844
- log(`cursor-backend: -> HIJACKED EnsureSandBox -> our box`);
2128
+ replyEnsureBox(req, res, rewrittenBox({ confirmed: !!(realPod?.agent && cacheFallbackOk) }), full);
1845
2129
  return;
1846
2130
  }
1847
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
+ }
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(msg.includes('crypto.link.com') ? 'onramp: attached crypto.link.com' : 'onramp: no hosted URL');
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,92 @@
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
+ const STRIPE_VERSION = '2026-06-24.dahlia';
16
+ const TTL_MS = 10 * 60 * 1000;
17
+ const cache = new Map(); // key -> { url, at }
18
+
19
+ function stripeSecret() {
20
+ const env = String(process.env.STRIPE_SECRET_KEY || '').trim();
21
+ if (env) return env;
22
+ const file = process.env.STRIPE_SECRET_FILE || path.join(os.homedir(), 'stripey.key');
23
+ return fs.readFileSync(file, 'utf8').trim();
24
+ }
25
+
26
+ function dollars(usd) {
27
+ const n = Number(usd);
28
+ if (!Number.isFinite(n) || n <= 0) return 10;
29
+ return Math.max(5, Math.ceil(n));
30
+ }
31
+
32
+ /** Test seam. */
33
+ export function resetOnrampCache() { cache.clear(); }
34
+
35
+ /**
36
+ * Mint (or reuse) a Stripe-hosted onramp URL that sends USDC to `solana`.
37
+ * Returns null if the key is missing or Stripe refuses — caller keeps the
38
+ * existing send-to-address copy. `evm` is ignored: Stripe does not accept
39
+ * `wallet_addresses[base]` on this API.
40
+ */
41
+ export async function stripeUsdcOnrampLink({ solana, usd } = {}) {
42
+ const addr = String(solana || '').trim();
43
+ if (!addr) return null;
44
+ const amt = dollars(usd);
45
+ const key = `${addr}|${amt}`;
46
+ const hit = cache.get(key);
47
+ if (hit && Date.now() - hit.at < TTL_MS && hit.url) return hit.url;
48
+ let secret;
49
+ try { secret = stripeSecret(); } catch { return null; }
50
+ if (!secret) return null;
51
+ const body = new URLSearchParams();
52
+ body.set('destination_currency', 'usdc');
53
+ body.set('destination_network', 'solana');
54
+ body.append('destination_currencies[]', 'usdc');
55
+ body.append('destination_networks[]', 'solana');
56
+ body.set('source_currency', 'usd');
57
+ body.set('source_amount', String(amt));
58
+ body.set('lock_wallet_address', 'true');
59
+ body.set('wallet_addresses[solana]', addr);
60
+ try {
61
+ const r = await fetch('https://api.stripe.com/v1/crypto/onramp_sessions', {
62
+ method: 'POST',
63
+ headers: {
64
+ authorization: `Bearer ${secret}`,
65
+ 'content-type': 'application/x-www-form-urlencoded',
66
+ 'Stripe-Version': STRIPE_VERSION,
67
+ },
68
+ body,
69
+ signal: AbortSignal.timeout(15000),
70
+ });
71
+ const data = await r.json();
72
+ const url = data?.redirect_url;
73
+ if (!r.ok || !url) {
74
+ const err = data?.error;
75
+ console.error(`openzoo onramp: stripe ${r.status} ${err?.code || err?.type || 'no-url'} ${(err?.message || '').slice(0, 160)}`);
76
+ return null;
77
+ }
78
+ cache.set(key, { url, at: Date.now() });
79
+ return url;
80
+ } catch (e) {
81
+ console.error(`openzoo onramp: ${e.message}`);
82
+ return null;
83
+ }
84
+ }
85
+
86
+ export async function withOnrampLink(text, dest) {
87
+ const url = await stripeUsdcOnrampLink(dest);
88
+ const body = String(text || '').trim();
89
+ if (!url) return body;
90
+ if (body.includes(url) || /crypto\.link\.com/i.test(body)) return body;
91
+ return `Buy USDC: ${url}\n\n${body}`;
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.25",
3
+ "version": "0.50.27",
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",