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.
- package/lib/cursorbackend.js +359 -75
- package/lib/grokbotAccount.js +49 -0
- package/lib/pay.js +18 -4
- package/lib/proxy.js +14 -5
- package/lib/stripeOnramp.js +92 -0
- package/package.json +1 -1
package/lib/cursorbackend.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
}
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
310
361
|
}
|
|
311
362
|
function saveAgents(a) {
|
|
312
|
-
if (!Array.isArray(a) || !a.length) return;
|
|
313
|
-
|
|
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
|
|
535
|
+
accountId,
|
|
473
536
|
podId: String(fields[3] || ''),
|
|
474
537
|
};
|
|
475
538
|
sniffDump({ kind: 'pod', fields, realPod });
|
|
476
539
|
savePod(realPod);
|
|
477
|
-
|
|
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
|
|
609
|
-
const cached =
|
|
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
|
|
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
|
-
|
|
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',
|
|
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()
|
|
1468
|
-
const
|
|
1469
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
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
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
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
|
-
|
|
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)
|
|
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:
|
|
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:
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
|
|
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.
|
|
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",
|