openzoo 0.38.7 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/openzoo.js +9 -2
- package/lib/boxes.js +233 -0
- package/lib/cursorapi.js +49 -0
- package/lib/cursorbackend.js +15 -1
- package/lib/grokbot.js +91 -247
- package/lib/grokcli.js +264 -0
- package/lib/podagent.mjs +68 -0
- package/package.json +1 -1
package/bin/openzoo.js
CHANGED
|
@@ -23,7 +23,11 @@ usage:
|
|
|
23
23
|
by default, --terminal for the Claude Code CLI
|
|
24
24
|
npx openzoo launch <cmd> [args] launch a TERMINAL Messages API client
|
|
25
25
|
(claude, aider...) already pointed at the zoo
|
|
26
|
-
npx openzoo grokbot
|
|
26
|
+
npx openzoo grokbot KEEP Grok Bot's UI, serve YOUR RunPod box under it:
|
|
27
|
+
spawns a CPU box, MITMs api2.cursor.sh, and answers
|
|
28
|
+
EnsureSandBox with your box instead of a cursorvm pod.
|
|
29
|
+
Inference x402-paid; needs RUNPOD_API_KEY + sudo.
|
|
30
|
+
npx openzoo grok-cli point the grok CLI at the zoo — GROK MODELS ONLY,
|
|
27
31
|
paid per call by x402 instead of xAI first-party billing,
|
|
28
32
|
then TAKE OVER the app's backend: pins api2.cursor.sh in
|
|
29
33
|
/etc/hosts, serves it locally on :443 with byok_enabled,
|
|
@@ -76,11 +80,14 @@ async function main() {
|
|
|
76
80
|
await (await import('../lib/setup.js')).setupEditor(cmd === 'editor' ? undefined : cmd, process.argv[3]);
|
|
77
81
|
break;
|
|
78
82
|
case 'grokbot':
|
|
83
|
+
await (await import('../lib/grokbot.js')).runGrokBot(process.argv.slice(3));
|
|
84
|
+
break;
|
|
85
|
+
case 'grok-cli':
|
|
79
86
|
case 'grok':
|
|
80
87
|
// Grok Bot (com.anysphere.sand) fronts the `grok` CLI, and the CLI reads
|
|
81
88
|
// ~/.grok/config.toml — so pointing that model table at the local proxy
|
|
82
89
|
// is enough; no patching of the app bundle.
|
|
83
|
-
await (await import('../lib/
|
|
90
|
+
await (await import('../lib/grokcli.js')).setupGrokBot(process.argv.slice(3));
|
|
84
91
|
break;
|
|
85
92
|
case 'mcp':
|
|
86
93
|
await (await import('../lib/mcp.js')).startMcp();
|
package/lib/boxes.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// RunPod CPU boxes — the sandbox fleet `openzoo grokbot` runs its agents in.
|
|
2
|
+
//
|
|
3
|
+
// SAME SHAPE AS GROK BOT, DIFFERENT ECONOMICS. Grok Bot provisions a
|
|
4
|
+
// cursorvm.com pod per bot and runs the agent inside Cursor's cloud, billing
|
|
5
|
+
// through them (MEASURED: EnsureSandBox returns
|
|
6
|
+
// `<id>-pod-<id>-1337.us9.cursorvm.com` + a VNC port + /workspace/terminals).
|
|
7
|
+
// This does the same thing on RunPod, with inference paid per-call by x402
|
|
8
|
+
// from a wallet the box holds — no account, no provider key.
|
|
9
|
+
//
|
|
10
|
+
// CPU ONLY, ALWAYS. A GPU flavour here would be a silent 100x on the bill for
|
|
11
|
+
// a box that only ever runs a shell and a node process. `spawnBox` builds its
|
|
12
|
+
// request body without GPU fields AND asserts on the serialized JSON, because
|
|
13
|
+
// an `in` check against a literal you wrote three lines earlier can never fail
|
|
14
|
+
// — that guard is decoration, not enforcement.
|
|
15
|
+
|
|
16
|
+
import { execFile } from 'node:child_process';
|
|
17
|
+
import { promisify } from 'node:util';
|
|
18
|
+
|
|
19
|
+
const pexec = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
let _agentB64 = null;
|
|
25
|
+
function podAgentB64() {
|
|
26
|
+
if (_agentB64) return _agentB64;
|
|
27
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
_agentB64 = readFileSync(join(here, 'podagent.mjs')).toString('base64');
|
|
29
|
+
return _agentB64;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const REST = 'https://rest.runpod.io/v1';
|
|
33
|
+
export const BOX_PREFIX = 'openzoo-box-';
|
|
34
|
+
const CPU_FLAVORS = ['cpu3c', 'cpu3g', 'cpu5c'];
|
|
35
|
+
const BOX_IMAGE = process.env.OPENZOO_BOX_IMAGE || 'node:22-alpine';
|
|
36
|
+
const BOX_PORTS = ['8402/http', '1337/http', '6080/http', '1340/http', '6081/http', '22/tcp'];
|
|
37
|
+
const TTL_MAX_HOURS = 90 * 24;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Boot: write the wallet, start the x402 proxy, keep the container alive.
|
|
41
|
+
*
|
|
42
|
+
* The proxy is the point — everything the agent asks for is paid per request
|
|
43
|
+
* from this box's own wallet. `npx --yes openzoo` is fetched at boot rather
|
|
44
|
+
* than baked, so a box always runs the published shim; the tradeoff is a hard
|
|
45
|
+
* dependency on npm being reachable in the first ~30s, which readyBox() waits
|
|
46
|
+
* for explicitly instead of assuming.
|
|
47
|
+
*/
|
|
48
|
+
const ENTRYPOINT = [
|
|
49
|
+
'sh', '-c',
|
|
50
|
+
'set -e; mkdir -p /workspace /root/.openzoo /var/log/openzoo; '
|
|
51
|
+
+ 'if [ -n "$OPENZOO_WALLET_JSON" ]; then printf %s "$OPENZOO_WALLET_JSON" > /root/.openzoo/wallet.json; chmod 600 /root/.openzoo/wallet.json; fi; '
|
|
52
|
+
+ 'apk add --no-cache openssh bash git curl >/dev/null 2>&1 || true; '
|
|
53
|
+
+ 'npx --yes openzoo > /var/log/openzoo/proxy.log 2>&1 & '
|
|
54
|
+
// the capture agent answers the ports Grok Bot expects a Cursor sandbox on,
|
|
55
|
+
// logging the protocol we do not yet speak (see lib/podagent.mjs)
|
|
56
|
+
+ 'if [ -n "$OZ_PODAGENT_B64" ]; then printf %s "$OZ_PODAGENT_B64" | base64 -d > /opt/podagent.mjs; node /opt/podagent.mjs > /var/log/openzoo/agent.log 2>&1 & fi; '
|
|
57
|
+
+ 'wait',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function key() {
|
|
61
|
+
const k = process.env.RUNPOD_API_KEY || '';
|
|
62
|
+
return typeof k === 'string' ? k.trim() : '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function runpodConfigured() {
|
|
66
|
+
// guard the access, not just the prefix — an unset key must not throw here
|
|
67
|
+
return key().startsWith('rpa_');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function rp(path, init) {
|
|
71
|
+
const k = key();
|
|
72
|
+
if (!k) return { ok: false, status: 503, data: { error: 'RUNPOD_API_KEY not set' } };
|
|
73
|
+
const res = await fetch(`${REST}${path}`, {
|
|
74
|
+
...init,
|
|
75
|
+
headers: { authorization: `Bearer ${k}`, 'content-type': 'application/json', ...(init?.headers || {}) },
|
|
76
|
+
});
|
|
77
|
+
const data = await res.json().catch(() => ({}));
|
|
78
|
+
return { ok: res.ok, status: res.status, data };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function ttlHours(days = 30) {
|
|
82
|
+
return Math.min(Math.max(Number(days) || 30, 1) * 24, TTL_MAX_HOURS);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Expiry is encoded in the name so a box is reapable even if its env is lost.
|
|
87
|
+
*
|
|
88
|
+
* PARSED FROM A FIXED POSITION, NOT `.pop()`. The name ends
|
|
89
|
+
* `-<unix>-<rand4>`, so popping the last segment yields the RANDOM suffix,
|
|
90
|
+
* Number() gives NaN, and every box reports "no expiry" — which is exactly the
|
|
91
|
+
* bug the site's copy of this had.
|
|
92
|
+
*/
|
|
93
|
+
export function expiresFromName(name) {
|
|
94
|
+
if (!name?.startsWith(BOX_PREFIX)) return null;
|
|
95
|
+
const parts = name.split('-');
|
|
96
|
+
const n = Number(parts[parts.length - 2]);
|
|
97
|
+
return Number.isFinite(n) && n > 1_700_000_000 ? n : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function proxyUrl(id, port = 8402) {
|
|
101
|
+
return id ? `https://${id}-${port}.proxy.runpod.net` : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The URLs Grok Bot's EnsureSandBox response needs, all fronted by RunPod. */
|
|
105
|
+
export function podEndpoints(id) {
|
|
106
|
+
return id ? {
|
|
107
|
+
agent: proxyUrl(id, 1337),
|
|
108
|
+
vnc: proxyUrl(id, 6080),
|
|
109
|
+
p1340: proxyUrl(id, 1340),
|
|
110
|
+
p6081: proxyUrl(id, 6081),
|
|
111
|
+
proxy: proxyUrl(id, 8402),
|
|
112
|
+
} : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What a caller may see. NEVER the raw pod: `env` carries OPENZOO_WALLET_JSON,
|
|
116
|
+
* which is a funded private key. Raw pods must not escape this module. */
|
|
117
|
+
export function serializeBox(p) {
|
|
118
|
+
const exp = expiresFromName(p.name) || Number(p.env?.OPENZOO_EXPIRES_UNIX || 0) || null;
|
|
119
|
+
const sshPort = p.portMappings?.['22'];
|
|
120
|
+
return {
|
|
121
|
+
id: p.id,
|
|
122
|
+
name: p.name,
|
|
123
|
+
status: p.desiredStatus,
|
|
124
|
+
costPerHr: p.costPerHr,
|
|
125
|
+
proxy: proxyUrl(p.id),
|
|
126
|
+
ssh: p.publicIp && sshPort ? `ssh -o StrictHostKeyChecking=no root@${p.publicIp} -p ${sshPort}` : null,
|
|
127
|
+
host: p.publicIp || null,
|
|
128
|
+
sshPort: sshPort || null,
|
|
129
|
+
console: p.id ? `https://www.runpod.io/console/pods/${p.id}` : null,
|
|
130
|
+
expiresAt: exp ? new Date(exp * 1000).toISOString() : null,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Our boxes only, serialized. */
|
|
135
|
+
export async function listBoxes() {
|
|
136
|
+
const r = await rp('/pods');
|
|
137
|
+
if (!Array.isArray(r.data)) return { ok: false, error: r.data?.error || `pods ${r.status}`, boxes: [] };
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
boxes: r.data.filter((p) => !p.gpuCount && p.name?.startsWith(BOX_PREFIX)).map(serializeBox),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function killBox(id) {
|
|
145
|
+
return rp(`/pods/${id}`, { method: 'DELETE' });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Delete our boxes past their deadline. Name-prefix scoped AND gpu-guarded. */
|
|
149
|
+
export async function reapExpired() {
|
|
150
|
+
const r = await rp('/pods');
|
|
151
|
+
if (!Array.isArray(r.data)) return { reaped: [] };
|
|
152
|
+
const now = Math.floor(Date.now() / 1000);
|
|
153
|
+
const reaped = [];
|
|
154
|
+
for (const p of r.data) {
|
|
155
|
+
if (!p.id || p.gpuCount || !p.name?.startsWith(BOX_PREFIX)) continue;
|
|
156
|
+
const deadline = expiresFromName(p.name) || Number(p.env?.OPENZOO_EXPIRES_UNIX || 0);
|
|
157
|
+
if (deadline && now >= deadline) {
|
|
158
|
+
await killBox(p.id);
|
|
159
|
+
reaped.push(p.id);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { reaped };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function spawnBox({ name = 'bot', days = 1, vcpu = 2, walletJson, pubkey } = {}) {
|
|
166
|
+
const expiresAt = new Date(Date.now() + ttlHours(days) * 3600 * 1000);
|
|
167
|
+
const unix = Math.floor(expiresAt.getTime() / 1000);
|
|
168
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
169
|
+
const full = `${BOX_PREFIX}${name}-${unix}-${rand}`;
|
|
170
|
+
|
|
171
|
+
const body = {
|
|
172
|
+
name: full,
|
|
173
|
+
computeType: 'CPU',
|
|
174
|
+
cloudType: 'SECURE',
|
|
175
|
+
vcpuCount: Math.max(1, Math.min(Number(vcpu) || 2, 4)),
|
|
176
|
+
cpuFlavorIds: CPU_FLAVORS,
|
|
177
|
+
cpuFlavorPriority: 'availability',
|
|
178
|
+
imageName: BOX_IMAGE,
|
|
179
|
+
containerDiskInGb: 20,
|
|
180
|
+
volumeInGb: 10,
|
|
181
|
+
volumeMountPath: '/workspace',
|
|
182
|
+
ports: BOX_PORTS,
|
|
183
|
+
dockerStartCmd: ENTRYPOINT,
|
|
184
|
+
env: {
|
|
185
|
+
OPENZOO_BOX: '1',
|
|
186
|
+
OPENZOO_EXPIRES_UNIX: String(unix),
|
|
187
|
+
OPENZOO_NO_TUNNEL: '1', // the runpod proxy already fronts it
|
|
188
|
+
...(walletJson ? { OPENZOO_WALLET_JSON: walletJson } : {}),
|
|
189
|
+
...(pubkey ? { OPENZOO_WALLET_PUBKEY: pubkey } : {}),
|
|
190
|
+
OZ_PODAGENT_B64: podAgentB64(),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
// ENFORCE on the actual payload, not on a literal we just wrote.
|
|
194
|
+
const wire = JSON.stringify(body);
|
|
195
|
+
if (/"gpu(Count|TypeIds)"/.test(wire)) throw new Error('refusing to spawn: GPU fields present');
|
|
196
|
+
|
|
197
|
+
const created = await rp('/pods', { method: 'POST', body: wire });
|
|
198
|
+
if (!created.ok) return { ok: false, error: created.data?.error || `spawn ${created.status}` };
|
|
199
|
+
return { ok: true, box: serializeBox(created.data), expiresAt: expiresAt.toISOString() };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Poll until the box answers on its proxy — npx has to fetch the shim first. */
|
|
203
|
+
export async function readyBox(id, { timeoutMs = 240_000, log = () => {} } = {}) {
|
|
204
|
+
const url = `${proxyUrl(id)}/v1/models`;
|
|
205
|
+
const started = Date.now();
|
|
206
|
+
let last = '';
|
|
207
|
+
while (Date.now() - started < timeoutMs) {
|
|
208
|
+
try {
|
|
209
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
|
|
210
|
+
if (r.ok) return { ok: true, seconds: Math.round((Date.now() - started) / 1000) };
|
|
211
|
+
last = `http ${r.status}`;
|
|
212
|
+
} catch (e) { last = e.name === 'TimeoutError' ? 'timeout' : e.message; }
|
|
213
|
+
log(` waiting for box proxy… (${Math.round((Date.now() - started) / 1000)}s, ${last})`);
|
|
214
|
+
await new Promise((r) => setTimeout(r, 6000));
|
|
215
|
+
}
|
|
216
|
+
return { ok: false, error: `box proxy never came up (${last})` };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Run a command INSIDE the box. This is the sandbox half of the product:
|
|
220
|
+
* the model runs wherever openzoo routes it, the side effects land here. */
|
|
221
|
+
export async function execInBox(box, cmd, { timeoutMs = 120_000 } = {}) {
|
|
222
|
+
if (!box.host || !box.sshPort) return { ok: false, stdout: '', stderr: 'box has no ssh yet' };
|
|
223
|
+
try {
|
|
224
|
+
const { stdout, stderr } = await pexec('ssh', [
|
|
225
|
+
'-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null',
|
|
226
|
+
'-o', 'LogLevel=ERROR', '-o', 'ConnectTimeout=15',
|
|
227
|
+
'-p', String(box.sshPort), `root@${box.host}`, cmd,
|
|
228
|
+
], { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 });
|
|
229
|
+
return { ok: true, stdout, stderr };
|
|
230
|
+
} catch (e) {
|
|
231
|
+
return { ok: false, stdout: e.stdout || '', stderr: e.stderr || e.message };
|
|
232
|
+
}
|
|
233
|
+
}
|
package/lib/cursorapi.js
CHANGED
|
@@ -166,6 +166,17 @@ export function encodeForMethod(method, models) {
|
|
|
166
166
|
}
|
|
167
167
|
switch (method) {
|
|
168
168
|
case 'AvailableModels': return encodeAvailableModels(models);
|
|
169
|
+
case 'EnsureSandBox': {
|
|
170
|
+
// HIJACK: hand Grok Bot OUR box instead of a cursorvm.com pod. The
|
|
171
|
+
// orchestrator (openzoo grokbot) sets OZ_HIJACK_POD = {base,podId,token}
|
|
172
|
+
// once its RunPod box is ready. Absent -> fall through to empty-ok, i.e.
|
|
173
|
+
// the app gets no sandbox (harmless, and how sniff mode behaves).
|
|
174
|
+
if (!process.env.OZ_HIJACK_POD) return null;
|
|
175
|
+
try {
|
|
176
|
+
const pod = JSON.parse(process.env.OZ_HIJACK_POD);
|
|
177
|
+
return encodeEnsureSandBox(pod);
|
|
178
|
+
} catch { return null; }
|
|
179
|
+
}
|
|
169
180
|
case 'GetPlanInfo': return encodeGetPlanInfo();
|
|
170
181
|
case 'GetMe': return encodeGetMe();
|
|
171
182
|
case 'GetDefaultModel': return encodeGetDefaultModel(models);
|
|
@@ -175,6 +186,44 @@ export function encodeForMethod(method, models) {
|
|
|
175
186
|
}
|
|
176
187
|
|
|
177
188
|
/** Connect unary framing: 5-byte prefix (flags + big-endian length) + payload. */
|
|
189
|
+
/**
|
|
190
|
+
* EnsureSandBox response — OUR pod, in Cursor's exact shape.
|
|
191
|
+
*
|
|
192
|
+
* Field map decoded from a live cursorvm.com response (2026-08-16):
|
|
193
|
+
* 1 region · 2 accountId · 3 podId · 4 network_token · 5 "local"
|
|
194
|
+
* 6 AGENT url (port 1337) · 7 VNC url (6080/vnc.html?...) · 8 /workspace/terminals
|
|
195
|
+
* 9 ready=1 · 10 url (1340) · 11 token · 12 url (6081)
|
|
196
|
+
*
|
|
197
|
+
* We keep 1-5,8,9,11 plausible and repoint 6/7/10/12 at a box WE control, so
|
|
198
|
+
* Grok Bot's UI connects to our sandbox instead of Cursor's. The RunPod proxy
|
|
199
|
+
* fronts one port; we map the agent port there and the app's own path suffixes
|
|
200
|
+
* (`/vnc.html`, the websockify query) ride along unchanged.
|
|
201
|
+
*
|
|
202
|
+
* HONEST LIMIT: the app will then speak Cursor's in-pod agent protocol to
|
|
203
|
+
* field-6 and expect a VNC desktop at field-7. Our box must actually serve
|
|
204
|
+
* those. This encoder is the redirect; whether the box satisfies the protocol
|
|
205
|
+
* is a separate, unfinished problem — point it at a logging box first.
|
|
206
|
+
*/
|
|
207
|
+
export function encodeEnsureSandBox({ region = 'us1', accountId, podId, token, agent, vnc, p1340, p6081 }) {
|
|
208
|
+
// per-port URLs — field 6 is the agent (1337), field 7 the VNC desktop
|
|
209
|
+
// (6080), matching the live cursorvm response. RunPod fronts each port at
|
|
210
|
+
// https://<id>-<port>.proxy.runpod.net, so agent !== vnc.
|
|
211
|
+
return new Buf()
|
|
212
|
+
.str(1, region)
|
|
213
|
+
.str(2, accountId)
|
|
214
|
+
.str(3, podId)
|
|
215
|
+
.str(4, token)
|
|
216
|
+
.str(5, 'local')
|
|
217
|
+
.str(6, agent)
|
|
218
|
+
.str(7, `${vnc}/vnc.html?network_token=${token}&resume_lower_s=900&resume_upper_s=18000&path=websockify%3Fnetwork_token%3D${token}`)
|
|
219
|
+
.str(8, '/workspace/terminals')
|
|
220
|
+
.int(9, 1)
|
|
221
|
+
.str(10, p1340 || agent)
|
|
222
|
+
.str(11, token)
|
|
223
|
+
.str(12, p6081 || vnc)
|
|
224
|
+
.done();
|
|
225
|
+
}
|
|
226
|
+
|
|
178
227
|
export function connectFrame(payload) {
|
|
179
228
|
const head = Buffer.alloc(5);
|
|
180
229
|
head.writeUInt8(0, 0);
|
package/lib/cursorbackend.js
CHANGED
|
@@ -299,7 +299,10 @@ async function passthroughToRealAnthropic(req, res, body, host, full, log) {
|
|
|
299
299
|
r.on('error', reject);
|
|
300
300
|
});
|
|
301
301
|
up.on('error', reject);
|
|
302
|
-
|
|
302
|
+
// WatchSandBoxMigration is a LONG POLL — a 20s cap aborts it every time and
|
|
303
|
+
// the app never learns its sandbox is ready. Streams get no deadline.
|
|
304
|
+
const streaming = /Watch|Stream|Subscribe/i.test(full);
|
|
305
|
+
if (!streaming) up.setTimeout(20000, () => up.destroy(new Error('upstream timeout')));
|
|
303
306
|
if (req.method !== 'GET' && req.method !== 'HEAD' && body && body.length) up.write(body);
|
|
304
307
|
up.end();
|
|
305
308
|
});
|
|
@@ -311,6 +314,17 @@ async function passthroughToRealAnthropic(req, res, body, host, full, log) {
|
|
|
311
314
|
}
|
|
312
315
|
res.writeHead(status, h);
|
|
313
316
|
res.end(buf);
|
|
317
|
+
// DUMP THE INTERESTING ONES. EnsureSandBox's response is what names the
|
|
318
|
+
// sandbox, and the sandbox is the candidate inference path — you cannot
|
|
319
|
+
// read that from a byte count. Written raw for offline decoding.
|
|
320
|
+
if (process.env.OPENZOO_DUMP === '1' && buf.length) {
|
|
321
|
+
try {
|
|
322
|
+
const dir = '/tmp/openzoo-sniff';
|
|
323
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
324
|
+
const safe = full.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 80);
|
|
325
|
+
fs.writeFileSync(`${dir}/${Date.now()}_${safe}.bin`, buf);
|
|
326
|
+
} catch { /* dumping must never break the proxy */ }
|
|
327
|
+
}
|
|
314
328
|
log(`cursor-backend: ${req.method} ${full} -> REAL anthropic (${status}, ${buf.length}b)`);
|
|
315
329
|
} catch (e) {
|
|
316
330
|
res.writeHead(502, { 'content-type': 'application/json' });
|
package/lib/grokbot.js
CHANGED
|
@@ -1,263 +1,107 @@
|
|
|
1
|
-
// `npx openzoo grokbot` — Grok Bot
|
|
1
|
+
// `npx openzoo grokbot` — keep Grok Bot's UI, serve OUR box underneath.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Grok Bot is a thin client over Cursor's aiserver.v1 API. Each bot gets a
|
|
4
|
+
// pod from `GrokBotService/EnsureSandBox` — a cursorvm.com VM the agent runs
|
|
5
|
+
// in. We MITM api2.cursor.sh (proven: /etc/hosts pin + Node TLS override lets
|
|
6
|
+
// us answer 88 methods and passthrough the rest with real bodies), and for
|
|
7
|
+
// that ONE method we return OUR RunPod box in Cursor's exact 12-field wire
|
|
8
|
+
// shape. The polished UI is theirs; the sandbox is ours.
|
|
7
9
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// payment. Pointing base_url at the gateway makes every request fail
|
|
16
|
-
// "payment required".
|
|
10
|
+
// THE WALL, STATED HONESTLY: once the app has our pod, it speaks Cursor's
|
|
11
|
+
// in-pod agent protocol to port 1337 and expects a VNC/websockify desktop on
|
|
12
|
+
// 6080. We do not yet speak either. The box therefore runs lib/podagent.mjs,
|
|
13
|
+
// which LOGS every request on those ports to /var/log/openzoo/agent.jsonl. So
|
|
14
|
+
// this ships as a CAPTURE hijack: the UI connects to our box, and we read what
|
|
15
|
+
// it sends to learn the protocol we then have to implement. That is the honest
|
|
16
|
+
// state — a working hijack needs that protocol, and this is how we get it.
|
|
17
17
|
|
|
18
18
|
import { spawn, execSync } from 'node:child_process';
|
|
19
|
-
import { existsSync
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
|
|
23
23
|
import { config } from './config.js';
|
|
24
|
+
import { PayClient } from './pay.js';
|
|
25
|
+
import {
|
|
26
|
+
podEndpoints, readyBox, runpodConfigured, spawnBox, listBoxes,
|
|
27
|
+
} from './boxes.js';
|
|
24
28
|
|
|
25
|
-
const GROK_HOME = process.env.GROK_HOME || join(homedir(), '.grok');
|
|
26
|
-
const CONFIG_PATH = join(GROK_HOME, 'config.toml');
|
|
27
29
|
const APP = '/Applications/Grok Bot.app';
|
|
30
|
+
const GROK_HOME = process.env.GROK_HOME || join(homedir(), '.grok');
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
{ key: 'openzoo-grok-45', id: 'x-ai/grok-4.5', name: 'Grok 4.5 (openzoo)' },
|
|
34
|
-
];
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* TOML table keys cannot contain a bare dot — `[model.openzoo-grok-4.6]` parses
|
|
38
|
-
* as model -> "openzoo-grok-4" -> "6", silently producing a malformed entry
|
|
39
|
-
* with no `model` field. Slugify to keep the key flat.
|
|
40
|
-
*/
|
|
41
|
-
function slug(id) {
|
|
42
|
-
return 'openzoo-' + id.replace(/^x-ai\//, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/-+$/, '');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function grokModels(base) {
|
|
46
|
-
try {
|
|
47
|
-
const r = await fetch(`${base}/models`, { signal: AbortSignal.timeout(8000) });
|
|
48
|
-
if (!r.ok) throw new Error(`models ${r.status}`);
|
|
49
|
-
const j = await r.json();
|
|
50
|
-
const rows = (j.data || [])
|
|
51
|
-
// VENDOR-PREFIXED IDS ONLY. The local proxy augments /v1/models with
|
|
52
|
-
// harness aliases ("grok-4", "openzoo-grok-4.6") so editors that validate
|
|
53
|
-
// a configured id upfront do not refuse to start. Those are routing
|
|
54
|
-
// conveniences, not catalog rows — writing them here would produce
|
|
55
|
-
// duplicate picker entries that all resolve to the same model.
|
|
56
|
-
.filter((m) => typeof m.id === 'string' && m.id.startsWith('x-ai/') && !m.kind)
|
|
57
|
-
.map((m) => ({ key: slug(m.id), id: m.id, name: `${m.id.replace(/^x-ai\//, 'Grok ')} (openzoo)` }));
|
|
58
|
-
return rows.length ? rows : FALLBACK_MODELS;
|
|
59
|
-
} catch {
|
|
60
|
-
// never write an empty picker because the catalog blipped
|
|
61
|
-
return FALLBACK_MODELS;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Strip the blocks we own so a re-run is idempotent, keeping everything else
|
|
66
|
-
* (mcp_servers, plugins, marketplace, privacy, ui) exactly as the user had it. */
|
|
67
|
-
function stripOurs(toml) {
|
|
68
|
-
const lines = toml.split('\n');
|
|
69
|
-
const out = [];
|
|
70
|
-
let skipping = false;
|
|
71
|
-
for (const line of lines) {
|
|
72
|
-
const header = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
73
|
-
if (header) {
|
|
74
|
-
const name = header[1];
|
|
75
|
-
skipping = name === 'models' || name.startsWith('model.');
|
|
76
|
-
}
|
|
77
|
-
if (!skipping) out.push(line);
|
|
78
|
-
}
|
|
79
|
-
return out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function renderModels(models, base) {
|
|
83
|
-
const def = models[0].key;
|
|
84
|
-
const body = models.map((m) => `[model.${m.key}]
|
|
85
|
-
model = "${m.id}"
|
|
86
|
-
base_url = "${base}"
|
|
87
|
-
api_key = "openzoo"
|
|
88
|
-
name = "${m.name}"`).join('\n\n');
|
|
89
|
-
return `
|
|
90
|
-
# --- openzoo: Grok-only, paid per call by x402 -------------------------------
|
|
91
|
-
# Written by \`npx openzoo grokbot\`. Re-running rewrites ONLY this block.
|
|
92
|
-
# base_url is the LOCAL proxy: the public gateway 402s every call by design,
|
|
93
|
-
# and the proxy is what signs the payment.
|
|
94
|
-
[models]
|
|
95
|
-
default = "${def}"
|
|
96
|
-
|
|
97
|
-
${body}
|
|
98
|
-
`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export async function setupGrokBot(argv = []) {
|
|
102
|
-
const base = `http://localhost:${config.port}/v1`;
|
|
103
|
-
const launch = !argv.includes('--no-launch');
|
|
104
|
-
|
|
105
|
-
// 1. proxy up, or nothing can pay
|
|
106
|
-
let up = false;
|
|
107
|
-
try { up = (await fetch(`${base}/models`, { signal: AbortSignal.timeout(3000) })).ok; } catch { up = false; }
|
|
108
|
-
if (!up) {
|
|
109
|
-
console.error('openzoo: starting the proxy...');
|
|
110
|
-
const { startProxy } = await import('./proxy.js');
|
|
111
|
-
await startProxy({ silent: true, autoTunnel: true });
|
|
112
|
-
for (let i = 0; i < 25 && !up; i++) {
|
|
113
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
114
|
-
try { up = (await fetch(`${base}/models`, { signal: AbortSignal.timeout(2000) })).ok; } catch { /* wait */ }
|
|
115
|
-
}
|
|
116
|
-
if (!up) { console.error(`openzoo: proxy did not come up on ${base}`); process.exit(1); }
|
|
32
|
+
export async function runGrokBot(argv = []) {
|
|
33
|
+
if (process.platform !== 'darwin') {
|
|
34
|
+
console.error('openzoo grokbot: the app hijack is macOS-only (needs Grok Bot.app + /etc/hosts).');
|
|
35
|
+
process.exit(1);
|
|
117
36
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
// 2. rewrite ONLY our block, after a timestamped backup
|
|
122
|
-
if (!existsSync(GROK_HOME)) mkdirSync(GROK_HOME, { recursive: true });
|
|
123
|
-
let existing = '';
|
|
124
|
-
if (existsSync(CONFIG_PATH)) {
|
|
125
|
-
const backup = `${CONFIG_PATH}.bak-${Date.now()}`;
|
|
126
|
-
copyFileSync(CONFIG_PATH, backup);
|
|
127
|
-
existing = readFileSync(CONFIG_PATH, 'utf8');
|
|
128
|
-
console.error(`openzoo: backed up ${CONFIG_PATH} -> ${backup}`);
|
|
37
|
+
if (!existsSync(APP)) {
|
|
38
|
+
console.error('openzoo grokbot: /Applications/Grok Bot.app not found.');
|
|
39
|
+
process.exit(1);
|
|
129
40
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
41
|
+
if (!runpodConfigured()) {
|
|
42
|
+
console.error('openzoo grokbot: RUNPOD_API_KEY not set (needs an rpa_… key).');
|
|
43
|
+
console.error(' export RUNPOD_API_KEY=rpa_… then re-run. Boxes bill to that account;');
|
|
44
|
+
console.error(' inference bills per-call to the box\'s own openzoo wallet.');
|
|
45
|
+
process.exit(1);
|
|
135
46
|
}
|
|
136
|
-
writeFileSync(CONFIG_PATH, `${next}\n${renderModels(models, base)}`, 'utf8');
|
|
137
|
-
|
|
138
|
-
console.error(`openzoo: wrote ${models.length} Grok model(s) to ${CONFIG_PATH}`);
|
|
139
|
-
for (const m of models) console.error(` ${m.key} -> ${m.id}`);
|
|
140
|
-
console.error(`openzoo: default = ${models[0].key} · all calls paid by x402 from your burner wallet`);
|
|
141
|
-
|
|
142
|
-
if (!launch) return;
|
|
143
|
-
|
|
144
|
-
// 3. launch the app if it exists, else the CLI
|
|
145
|
-
if (existsSync(APP)) {
|
|
146
|
-
// BYOK IS THE DEFAULT. Without it this command only rewrites the CLI's
|
|
147
|
-
// model table, and the APP — the thing you actually look at — keeps talking
|
|
148
|
-
// to Cursor's cloud, which is the whole problem it was meant to solve.
|
|
149
|
-
// --no-byok drops back to plain launch.
|
|
150
|
-
// TAKEOVER IS THE DEFAULT. The --host-resolver-rules probe below is kept
|
|
151
|
-
// only for reference: it was PROVED not to intercept this app (flags present,
|
|
152
|
-
// backend listening, zero connections). Nothing routes without the hosts pin.
|
|
153
|
-
if (!argv.includes('--no-takeover')) {
|
|
154
|
-
// TAKEOVER — the Cursor recipe, because it is the only one that works.
|
|
155
|
-
//
|
|
156
|
-
// WHY NOT --host-resolver-rules: PROVED not to work here. The flag was
|
|
157
|
-
// verified present in the running process args for all four cursor hosts,
|
|
158
|
-
// the backend was listening, and ZERO connections arrived — the app kept
|
|
159
|
-
// talking to the real api2.cursor.sh (cert CN confirmed on its live
|
|
160
|
-
// sockets). That flag only steers CHROMIUM's stack; Grok Bot's Connect/gRPC
|
|
161
|
-
// transport runs in the Electron MAIN process on Node's DNS + Node's TLS,
|
|
162
|
-
// which ignore it. Same reason setup.js sets NODE_TLS_REJECT_UNAUTHORIZED
|
|
163
|
-
// for Cursor takeover.
|
|
164
|
-
//
|
|
165
|
-
// So: /etc/hosts (Node reads the OS resolver) + Node-side TLS override,
|
|
166
|
-
// and the app is spawned DIRECTLY rather than via `open`, because `open`
|
|
167
|
-
// does not pass an environment to the app.
|
|
168
|
-
const { blockBackend, unblockBackend, isBlocked } = await import('./hosts.js');
|
|
169
|
-
const { startCursorBackend } = await import('./cursorbackend.js');
|
|
170
|
-
const port = 443; // Node's DNS gives no port control — we must own :443
|
|
171
47
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
// api2.cursor.sh. MEASURED: the backend logged "listening" and then zero
|
|
232
|
-
// requests, while the app carried on answering normally.
|
|
233
|
-
try {
|
|
234
|
-
execSync('osascript -e \'tell application "Grok Bot" to quit\'', { stdio: 'ignore' });
|
|
235
|
-
} catch { /* not running is fine */ }
|
|
236
|
-
// give it a moment to actually exit before relaunching
|
|
237
|
-
await new Promise((r) => setTimeout(r, 2500));
|
|
238
|
-
// MAP EVERY CURSOR HOST, NOT JUST api2.
|
|
239
|
-
// MEASURED: mapping api2 alone applied cleanly (verified in the running
|
|
240
|
-
// process args) and still produced ZERO requests, because Grok Bot's own
|
|
241
|
-
// surface is api3 — the bundle carries `https://api3.cursor.sh/tev1/v1`.
|
|
242
|
-
// The self-signed cert already covers all four names, so map them all.
|
|
243
|
-
const hosts = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'repo42.cursor.sh'];
|
|
244
|
-
const rules = hosts.map((h) => `MAP ${h} 127.0.0.1:${port}`).join(',');
|
|
245
|
-
spawn('open', ['-n', '-a', APP, '--args',
|
|
246
|
-
'--ignore-certificate-errors',
|
|
247
|
-
`--host-resolver-rules=${rules}`,
|
|
248
|
-
], { stdio: 'ignore', detached: true }).unref();
|
|
249
|
-
// keep this process alive so the backend keeps answering
|
|
250
|
-
console.error('openzoo: leave this running while Grok Bot is open. ctrl-c stops the');
|
|
251
|
-
console.error(' backend; the app then reconnects to xAI normally on next launch.');
|
|
252
|
-
console.error(' (skip all of this with --no-byok)');
|
|
253
|
-
await new Promise(() => {});
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
console.error('openzoo: launching Grok Bot...');
|
|
257
|
-
spawn('open', ['-a', APP], { stdio: 'ignore', detached: true }).unref();
|
|
258
|
-
} else {
|
|
259
|
-
console.error('openzoo: Grok Bot.app not found — starting the `grok` CLI instead');
|
|
260
|
-
const p = spawn('grok', argv.filter((a) => a !== '--no-launch'), { stdio: 'inherit' });
|
|
261
|
-
p.on('exit', (c) => process.exit(c ?? 0));
|
|
262
|
-
}
|
|
48
|
+
const days = Number((argv.find((a) => /^--days=/.test(a)) || '').split('=')[1]) || 1;
|
|
49
|
+
const shareWallet = argv.includes('--share-wallet');
|
|
50
|
+
const client = new PayClient();
|
|
51
|
+
|
|
52
|
+
// 1) a box, ready, BEFORE we touch the app — so the first EnsureSandBox we
|
|
53
|
+
// hijack already has a live pod to point at.
|
|
54
|
+
console.error('openzoo grokbot: spawning your box (CPU, x402-funded)…');
|
|
55
|
+
const spawned = await spawnBox({
|
|
56
|
+
name: 'grokbot', days,
|
|
57
|
+
...(shareWallet ? { walletJson: JSON.stringify([...client.keypair.secretKey]) } : {}),
|
|
58
|
+
});
|
|
59
|
+
if (!spawned.ok) { console.error(` spawn failed: ${spawned.error}`); process.exit(1); }
|
|
60
|
+
const box = spawned.box;
|
|
61
|
+
console.error(` ${box.name} ${box.id} expires ${spawned.expiresAt.slice(0, 16)}`);
|
|
62
|
+
const ready = await readyBox(box.id, { log: (m) => console.error(m) });
|
|
63
|
+
if (!ready.ok) console.error(` ${ready.error} — continuing; the capture agent may still bind`);
|
|
64
|
+
else console.error(` box ready in ${ready.seconds}s`);
|
|
65
|
+
|
|
66
|
+
const ep = podEndpoints(box.id);
|
|
67
|
+
process.env.OZ_HIJACK_POD = JSON.stringify({
|
|
68
|
+
region: 'us1',
|
|
69
|
+
accountId: box.id.replace(/[^a-z0-9]/gi, '').slice(0, 20),
|
|
70
|
+
podId: `pod-${box.id}`,
|
|
71
|
+
token: `nto-${Math.random().toString(36).slice(2, 12)}`,
|
|
72
|
+
agent: ep.agent, vnc: ep.vnc, p1340: ep.p1340, p6081: ep.p6081,
|
|
73
|
+
});
|
|
74
|
+
// other methods must still reach the REAL backend, or the app can't load
|
|
75
|
+
process.env.OPENZOO_PASSTHRU = '1';
|
|
76
|
+
process.env.OPENZOO_FORCE_BLOCK = '1';
|
|
77
|
+
|
|
78
|
+
// 2) pin + serve api2.cursor.sh locally (sudo). Chromium flags don't reach
|
|
79
|
+
// the Electron main process; only /etc/hosts + Node TLS override do.
|
|
80
|
+
const { blockBackend, unblockBackend, isBlocked } = await import('./hosts.js');
|
|
81
|
+
const { startCursorBackend } = await import('./cursorbackend.js');
|
|
82
|
+
console.error('openzoo grokbot: pinning api2.cursor.sh (sudo)…');
|
|
83
|
+
if (!isBlocked()) blockBackend();
|
|
84
|
+
startCursorBackend({ port: 443, log: (m) => console.error(` backend: ${m}`) });
|
|
85
|
+
|
|
86
|
+
const restore = () => { try { unblockBackend(); console.error('openzoo: /etc/hosts restored.'); } catch { /* */ } };
|
|
87
|
+
process.on('SIGINT', () => { restore(); process.exit(0); });
|
|
88
|
+
process.on('exit', restore);
|
|
89
|
+
|
|
90
|
+
// 3) launch the app directly (open -a can't pass env) with Node TLS override
|
|
91
|
+
console.error('openzoo grokbot: launching Grok Bot pointed at your box…');
|
|
92
|
+
console.error(` box proxy : ${ep.proxy}`);
|
|
93
|
+
console.error(` agent (1337): ${ep.agent}`);
|
|
94
|
+
console.error(` ssh : ${box.ssh || '(pending)'}`);
|
|
95
|
+
console.error(' WATCH the box agent log for what the app sends:');
|
|
96
|
+
console.error(` ${box.ssh ? box.ssh + " 'tail -f /var/log/openzoo/agent.log'" : '(ssh not ready yet)'}`);
|
|
97
|
+
try { execSync('osascript -e \'tell application "Grok Bot" to quit\'', { stdio: 'ignore' }); } catch { /* */ }
|
|
98
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
99
|
+
spawn(`${APP}/Contents/MacOS/Grok Bot`, ['--ignore-certificate-errors'], {
|
|
100
|
+
stdio: 'ignore', detached: true,
|
|
101
|
+
env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED: '0' },
|
|
102
|
+
}).unref();
|
|
103
|
+
|
|
104
|
+
console.error('openzoo grokbot: ctrl-c to stop, restore /etc/hosts, and leave the box running.');
|
|
105
|
+
console.error(` the box keeps running until its TTL — manage with: RUNPOD_API_KEY=… openzoo grokbot (again) or the runpod console`);
|
|
106
|
+
await new Promise(() => {});
|
|
263
107
|
}
|
package/lib/grokcli.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// `npx openzoo grokbot` — Grok Bot (xAI's agent app) on the zoo, Grok-only.
|
|
2
|
+
//
|
|
3
|
+
// WHAT GROK BOT IS: an Electron app, bundle id `com.anysphere.sand` (Anysphere,
|
|
4
|
+
// the Cursor company) shipped as "Grok Bot". It fronts the `grok` CLI, and the
|
|
5
|
+
// CLI is what reads ~/.grok/config.toml — which is why pointing the CLI's model
|
|
6
|
+
// table at the local proxy is enough, with no patching of the app bundle.
|
|
7
|
+
//
|
|
8
|
+
// WHY GROK-ONLY: the point of running it on the zoo is x402 per-call billing
|
|
9
|
+
// instead of first-party xAI billing, while keeping the product it is — a Grok
|
|
10
|
+
// agent. So every model written here is an x-ai/* id and nothing else is
|
|
11
|
+
// reachable from the picker.
|
|
12
|
+
//
|
|
13
|
+
// WHY THE LOCAL PROXY AND NOT x402-tokens.fly.dev: the gateway answers 402 on
|
|
14
|
+
// every call by design. The proxy is the thing that builds and signs the
|
|
15
|
+
// payment. Pointing base_url at the gateway makes every request fail
|
|
16
|
+
// "payment required".
|
|
17
|
+
|
|
18
|
+
import { spawn, execSync } from 'node:child_process';
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { config } from './config.js';
|
|
24
|
+
|
|
25
|
+
const GROK_HOME = process.env.GROK_HOME || join(homedir(), '.grok');
|
|
26
|
+
const CONFIG_PATH = join(GROK_HOME, 'config.toml');
|
|
27
|
+
const APP = '/Applications/Grok Bot.app';
|
|
28
|
+
|
|
29
|
+
/** Grok ids as the zoo serves them. Verified against GET /v1/models at write
|
|
30
|
+
* time so a renamed/retired model never lands in the picker as a dead row. */
|
|
31
|
+
const FALLBACK_MODELS = [
|
|
32
|
+
{ key: 'openzoo-grok-46', id: 'x-ai/grok-4.6', name: 'Grok 4.6 (openzoo)' },
|
|
33
|
+
{ key: 'openzoo-grok-45', id: 'x-ai/grok-4.5', name: 'Grok 4.5 (openzoo)' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* TOML table keys cannot contain a bare dot — `[model.openzoo-grok-4.6]` parses
|
|
38
|
+
* as model -> "openzoo-grok-4" -> "6", silently producing a malformed entry
|
|
39
|
+
* with no `model` field. Slugify to keep the key flat.
|
|
40
|
+
*/
|
|
41
|
+
function slug(id) {
|
|
42
|
+
return 'openzoo-' + id.replace(/^x-ai\//, '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/-+$/, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function grokModels(base) {
|
|
46
|
+
try {
|
|
47
|
+
const r = await fetch(`${base}/models`, { signal: AbortSignal.timeout(8000) });
|
|
48
|
+
if (!r.ok) throw new Error(`models ${r.status}`);
|
|
49
|
+
const j = await r.json();
|
|
50
|
+
const rows = (j.data || [])
|
|
51
|
+
// VENDOR-PREFIXED IDS ONLY. The local proxy augments /v1/models with
|
|
52
|
+
// harness aliases ("grok-4", "openzoo-grok-4.6") so editors that validate
|
|
53
|
+
// a configured id upfront do not refuse to start. Those are routing
|
|
54
|
+
// conveniences, not catalog rows — writing them here would produce
|
|
55
|
+
// duplicate picker entries that all resolve to the same model.
|
|
56
|
+
.filter((m) => typeof m.id === 'string' && m.id.startsWith('x-ai/') && !m.kind)
|
|
57
|
+
.map((m) => ({ key: slug(m.id), id: m.id, name: `${m.id.replace(/^x-ai\//, 'Grok ')} (openzoo)` }));
|
|
58
|
+
return rows.length ? rows : FALLBACK_MODELS;
|
|
59
|
+
} catch {
|
|
60
|
+
// never write an empty picker because the catalog blipped
|
|
61
|
+
return FALLBACK_MODELS;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Strip the blocks we own so a re-run is idempotent, keeping everything else
|
|
66
|
+
* (mcp_servers, plugins, marketplace, privacy, ui) exactly as the user had it. */
|
|
67
|
+
function stripOurs(toml) {
|
|
68
|
+
const lines = toml.split('\n');
|
|
69
|
+
const out = [];
|
|
70
|
+
let skipping = false;
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const header = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
73
|
+
if (header) {
|
|
74
|
+
const name = header[1];
|
|
75
|
+
skipping = name === 'models' || name.startsWith('model.');
|
|
76
|
+
}
|
|
77
|
+
if (!skipping) out.push(line);
|
|
78
|
+
}
|
|
79
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderModels(models, base) {
|
|
83
|
+
const def = models[0].key;
|
|
84
|
+
const body = models.map((m) => `[model.${m.key}]
|
|
85
|
+
model = "${m.id}"
|
|
86
|
+
base_url = "${base}"
|
|
87
|
+
api_key = "openzoo"
|
|
88
|
+
name = "${m.name}"`).join('\n\n');
|
|
89
|
+
return `
|
|
90
|
+
# --- openzoo: Grok-only, paid per call by x402 -------------------------------
|
|
91
|
+
# Written by \`npx openzoo grokbot\`. Re-running rewrites ONLY this block.
|
|
92
|
+
# base_url is the LOCAL proxy: the public gateway 402s every call by design,
|
|
93
|
+
# and the proxy is what signs the payment.
|
|
94
|
+
[models]
|
|
95
|
+
default = "${def}"
|
|
96
|
+
|
|
97
|
+
${body}
|
|
98
|
+
`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function setupGrokBot(argv = []) {
|
|
102
|
+
const base = `http://localhost:${config.port}/v1`;
|
|
103
|
+
const launch = !argv.includes('--no-launch');
|
|
104
|
+
|
|
105
|
+
// 1. proxy up, or nothing can pay
|
|
106
|
+
let up = false;
|
|
107
|
+
try { up = (await fetch(`${base}/models`, { signal: AbortSignal.timeout(3000) })).ok; } catch { up = false; }
|
|
108
|
+
if (!up) {
|
|
109
|
+
console.error('openzoo: starting the proxy...');
|
|
110
|
+
const { startProxy } = await import('./proxy.js');
|
|
111
|
+
await startProxy({ silent: true, autoTunnel: true });
|
|
112
|
+
for (let i = 0; i < 25 && !up; i++) {
|
|
113
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
114
|
+
try { up = (await fetch(`${base}/models`, { signal: AbortSignal.timeout(2000) })).ok; } catch { /* wait */ }
|
|
115
|
+
}
|
|
116
|
+
if (!up) { console.error(`openzoo: proxy did not come up on ${base}`); process.exit(1); }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const models = await grokModels(base);
|
|
120
|
+
|
|
121
|
+
// 2. rewrite ONLY our block, after a timestamped backup
|
|
122
|
+
if (!existsSync(GROK_HOME)) mkdirSync(GROK_HOME, { recursive: true });
|
|
123
|
+
let existing = '';
|
|
124
|
+
if (existsSync(CONFIG_PATH)) {
|
|
125
|
+
const backup = `${CONFIG_PATH}.bak-${Date.now()}`;
|
|
126
|
+
copyFileSync(CONFIG_PATH, backup);
|
|
127
|
+
existing = readFileSync(CONFIG_PATH, 'utf8');
|
|
128
|
+
console.error(`openzoo: backed up ${CONFIG_PATH} -> ${backup}`);
|
|
129
|
+
}
|
|
130
|
+
let next = stripOurs(existing);
|
|
131
|
+
// the fork/secondary model must ride the zoo too, or forks quietly bill xAI
|
|
132
|
+
// first-party while the main model is on openzoo
|
|
133
|
+
if (/^\s*fork_secondary_model\s*=/m.test(next)) {
|
|
134
|
+
next = next.replace(/^\s*fork_secondary_model\s*=.*$/m, `fork_secondary_model = "${models[0].key}"`);
|
|
135
|
+
}
|
|
136
|
+
writeFileSync(CONFIG_PATH, `${next}\n${renderModels(models, base)}`, 'utf8');
|
|
137
|
+
|
|
138
|
+
console.error(`openzoo: wrote ${models.length} Grok model(s) to ${CONFIG_PATH}`);
|
|
139
|
+
for (const m of models) console.error(` ${m.key} -> ${m.id}`);
|
|
140
|
+
console.error(`openzoo: default = ${models[0].key} · all calls paid by x402 from your burner wallet`);
|
|
141
|
+
|
|
142
|
+
if (!launch) return;
|
|
143
|
+
|
|
144
|
+
// 3. launch the app if it exists, else the CLI
|
|
145
|
+
if (existsSync(APP)) {
|
|
146
|
+
// BYOK IS THE DEFAULT. Without it this command only rewrites the CLI's
|
|
147
|
+
// model table, and the APP — the thing you actually look at — keeps talking
|
|
148
|
+
// to Cursor's cloud, which is the whole problem it was meant to solve.
|
|
149
|
+
// --no-byok drops back to plain launch.
|
|
150
|
+
// TAKEOVER IS THE DEFAULT. The --host-resolver-rules probe below is kept
|
|
151
|
+
// only for reference: it was PROVED not to intercept this app (flags present,
|
|
152
|
+
// backend listening, zero connections). Nothing routes without the hosts pin.
|
|
153
|
+
if (!argv.includes('--no-takeover')) {
|
|
154
|
+
// TAKEOVER — the Cursor recipe, because it is the only one that works.
|
|
155
|
+
//
|
|
156
|
+
// WHY NOT --host-resolver-rules: PROVED not to work here. The flag was
|
|
157
|
+
// verified present in the running process args for all four cursor hosts,
|
|
158
|
+
// the backend was listening, and ZERO connections arrived — the app kept
|
|
159
|
+
// talking to the real api2.cursor.sh (cert CN confirmed on its live
|
|
160
|
+
// sockets). That flag only steers CHROMIUM's stack; Grok Bot's Connect/gRPC
|
|
161
|
+
// transport runs in the Electron MAIN process on Node's DNS + Node's TLS,
|
|
162
|
+
// which ignore it. Same reason setup.js sets NODE_TLS_REJECT_UNAUTHORIZED
|
|
163
|
+
// for Cursor takeover.
|
|
164
|
+
//
|
|
165
|
+
// So: /etc/hosts (Node reads the OS resolver) + Node-side TLS override,
|
|
166
|
+
// and the app is spawned DIRECTLY rather than via `open`, because `open`
|
|
167
|
+
// does not pass an environment to the app.
|
|
168
|
+
const { blockBackend, unblockBackend, isBlocked } = await import('./hosts.js');
|
|
169
|
+
const { startCursorBackend } = await import('./cursorbackend.js');
|
|
170
|
+
const port = 443; // Node's DNS gives no port control — we must own :443
|
|
171
|
+
|
|
172
|
+
// --sniff: intercept but PASS EVERYTHING THROUGH to the real backend, so
|
|
173
|
+
// the app works normally and every method/size/status is logged. This is
|
|
174
|
+
// the only way to see the chat path: stubbing breaks EnsureSandBox and the
|
|
175
|
+
// app never reaches inference at all.
|
|
176
|
+
if (argv.includes('--sniff')) { process.env.OPENZOO_PASSTHRU = '1'; process.env.OPENZOO_DUMP = '1'; }
|
|
177
|
+
else process.env.OPENZOO_BYOK = '1';
|
|
178
|
+
process.env.OPENZOO_FORCE_BLOCK = '1'; // our own guard refuses otherwise
|
|
179
|
+
console.error(argv.includes('--sniff')
|
|
180
|
+
? 'openzoo: SNIFF — pinning api2.cursor.sh, proxying it to the REAL backend, logging everything.'
|
|
181
|
+
: 'openzoo: TAKEOVER — pinning api2.cursor.sh and serving it locally.');
|
|
182
|
+
if (argv.includes('--sniff')) console.error(' response bodies -> /tmp/openzoo-sniff/');
|
|
183
|
+
console.error(' sudo will ask for your password (hosts file + :443).');
|
|
184
|
+
if (!isBlocked()) blockBackend();
|
|
185
|
+
|
|
186
|
+
startCursorBackend({ port, log: (m) => console.error(` backend: ${m}`) });
|
|
187
|
+
|
|
188
|
+
const restore = () => {
|
|
189
|
+
try { unblockBackend(); console.error('openzoo: /etc/hosts restored.'); } catch { /* best effort */ }
|
|
190
|
+
};
|
|
191
|
+
process.on('SIGINT', () => { restore(); process.exit(0); });
|
|
192
|
+
process.on('exit', restore);
|
|
193
|
+
|
|
194
|
+
console.error('openzoo: launching Grok Bot with Node TLS override...');
|
|
195
|
+
spawn(`${APP}/Contents/MacOS/Grok Bot`, ['--ignore-certificate-errors'], {
|
|
196
|
+
stdio: 'ignore',
|
|
197
|
+
detached: true,
|
|
198
|
+
env: { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED: '0' },
|
|
199
|
+
}).unref();
|
|
200
|
+
|
|
201
|
+
console.error('openzoo: ctrl-c to stop and RESTORE /etc/hosts (do not just close the terminal).');
|
|
202
|
+
await new Promise(() => {});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!argv.includes('--no-byok')) {
|
|
207
|
+
// BYOK PROBE — NO /etc/hosts, NO sudo.
|
|
208
|
+
//
|
|
209
|
+
// Grok Bot asks aiserver.v1 whether BYOK is allowed (`byok_enabled`,
|
|
210
|
+
// field 14). Answering that ourselves needs us to BE api2.cursor.sh, and
|
|
211
|
+
// the obvious way — pinning it in /etc/hosts — is exactly what took the
|
|
212
|
+
// app offline for an hour with "Reconnecting to your computer", because
|
|
213
|
+
// the pin is machine-wide and outlives the app.
|
|
214
|
+
//
|
|
215
|
+
// Chromium's own --host-resolver-rules does the same redirect scoped to
|
|
216
|
+
// THIS LAUNCH ONLY. Quit and reopen normally and it is gone; nothing
|
|
217
|
+
// persists, nothing else on the machine is affected, no password needed.
|
|
218
|
+
const { startCursorBackend } = await import('./cursorbackend.js');
|
|
219
|
+
const port = 8443;
|
|
220
|
+
process.env.OPENZOO_BYOK = '1';
|
|
221
|
+
// LOUD BY DEFAULT. The previous run failed silently — "listening" and then
|
|
222
|
+
// nothing — and silence looked identical to success. Every arriving method
|
|
223
|
+
// is printed so "did it connect at all" is answerable at a glance.
|
|
224
|
+
startCursorBackend({ port, log: (m) => console.error(` backend: ${m}`) });
|
|
225
|
+
console.error('openzoo: BYOK probe — impersonating api2/api3/api4/repo42.cursor.sh on :' + port);
|
|
226
|
+
console.error(' scoped to this launch only (no /etc/hosts, no sudo)');
|
|
227
|
+
console.error(' WATCH: if StreamChat starts arriving at the proxy, inference moved.');
|
|
228
|
+
console.error(' If only a BYOK settings pane appears, it is xAI-key-only and buys nothing.');
|
|
229
|
+
// QUIT FIRST — `open -a X --args` is a NO-OP ON A RUNNING APP.
|
|
230
|
+
// macOS just activates the existing instance and drops the args, so the
|
|
231
|
+
// resolver rule never applies and the app keeps talking to the real
|
|
232
|
+
// api2.cursor.sh. MEASURED: the backend logged "listening" and then zero
|
|
233
|
+
// requests, while the app carried on answering normally.
|
|
234
|
+
try {
|
|
235
|
+
execSync('osascript -e \'tell application "Grok Bot" to quit\'', { stdio: 'ignore' });
|
|
236
|
+
} catch { /* not running is fine */ }
|
|
237
|
+
// give it a moment to actually exit before relaunching
|
|
238
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
239
|
+
// MAP EVERY CURSOR HOST, NOT JUST api2.
|
|
240
|
+
// MEASURED: mapping api2 alone applied cleanly (verified in the running
|
|
241
|
+
// process args) and still produced ZERO requests, because Grok Bot's own
|
|
242
|
+
// surface is api3 — the bundle carries `https://api3.cursor.sh/tev1/v1`.
|
|
243
|
+
// The self-signed cert already covers all four names, so map them all.
|
|
244
|
+
const hosts = ['api2.cursor.sh', 'api3.cursor.sh', 'api4.cursor.sh', 'repo42.cursor.sh'];
|
|
245
|
+
const rules = hosts.map((h) => `MAP ${h} 127.0.0.1:${port}`).join(',');
|
|
246
|
+
spawn('open', ['-n', '-a', APP, '--args',
|
|
247
|
+
'--ignore-certificate-errors',
|
|
248
|
+
`--host-resolver-rules=${rules}`,
|
|
249
|
+
], { stdio: 'ignore', detached: true }).unref();
|
|
250
|
+
// keep this process alive so the backend keeps answering
|
|
251
|
+
console.error('openzoo: leave this running while Grok Bot is open. ctrl-c stops the');
|
|
252
|
+
console.error(' backend; the app then reconnects to xAI normally on next launch.');
|
|
253
|
+
console.error(' (skip all of this with --no-byok)');
|
|
254
|
+
await new Promise(() => {});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
console.error('openzoo: launching Grok Bot...');
|
|
258
|
+
spawn('open', ['-a', APP], { stdio: 'ignore', detached: true }).unref();
|
|
259
|
+
} else {
|
|
260
|
+
console.error('openzoo: Grok Bot.app not found — starting the `grok` CLI instead');
|
|
261
|
+
const p = spawn('grok', argv.filter((a) => a !== '--no-launch'), { stdio: 'inherit' });
|
|
262
|
+
p.on('exit', (c) => process.exit(c ?? 0));
|
|
263
|
+
}
|
|
264
|
+
}
|
package/lib/podagent.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Pod capture agent — runs INSIDE our box, on the ports Grok Bot expects a
|
|
2
|
+
// Cursor sandbox to answer (1337 agent, 6080 vnc, 1340, 6081).
|
|
3
|
+
//
|
|
4
|
+
// THIS IS THE PROBE THAT ANSWERS THE WALL. Once EnsureSandBox is rewritten to
|
|
5
|
+
// point Grok Bot at our box, the app speaks Cursor's in-pod agent protocol to
|
|
6
|
+
// port 1337 and expects a VNC/websockify desktop on 6080. We do not know that
|
|
7
|
+
// protocol. This server logs EVERY request — method, path, headers, body
|
|
8
|
+
// (hex + utf8) — to /var/log/openzoo/agent.jsonl and stdout, and answers a
|
|
9
|
+
// benign 200/101 so the app keeps talking and reveals more. Read the log and
|
|
10
|
+
// you have the spec; until then, "serve our VMs into their UI" is a capture,
|
|
11
|
+
// not a working hijack, and this file is honest about which.
|
|
12
|
+
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import { appendFileSync } from 'node:fs';
|
|
15
|
+
|
|
16
|
+
const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
|
|
17
|
+
.split(',').map((s) => Number(s.trim())).filter(Boolean);
|
|
18
|
+
const LOG = process.env.OZ_AGENT_LOG || '/var/log/openzoo/agent.jsonl';
|
|
19
|
+
|
|
20
|
+
function record(entry) {
|
|
21
|
+
const line = JSON.stringify(entry);
|
|
22
|
+
try { appendFileSync(LOG, line + '\n'); } catch { /* best effort */ }
|
|
23
|
+
console.log(`[agent:${entry.port}] ${entry.method} ${entry.path} ${entry.bodyBytes}b` +
|
|
24
|
+
(entry.upgrade ? ` (UPGRADE ${entry.upgrade})` : ''));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const port of PORTS) {
|
|
28
|
+
const server = http.createServer((req, res) => {
|
|
29
|
+
const chunks = [];
|
|
30
|
+
req.on('data', (d) => chunks.push(d));
|
|
31
|
+
req.on('end', () => {
|
|
32
|
+
const body = Buffer.concat(chunks);
|
|
33
|
+
record({
|
|
34
|
+
t: new Date().toISOString(), port,
|
|
35
|
+
method: req.method, path: req.url,
|
|
36
|
+
headers: req.headers,
|
|
37
|
+
bodyBytes: body.length,
|
|
38
|
+
bodyHex: body.slice(0, 512).toString('hex'),
|
|
39
|
+
bodyUtf8: body.slice(0, 512).toString('utf8').replace(/[^\x20-\x7e]/g, '.'),
|
|
40
|
+
});
|
|
41
|
+
// Benign answers so the client does not give up immediately.
|
|
42
|
+
if (req.url && req.url.includes('/vnc')) {
|
|
43
|
+
res.writeHead(200, { 'content-type': 'text/html' });
|
|
44
|
+
res.end('<!doctype html><title>openzoo box</title><body>capture</body>');
|
|
45
|
+
} else {
|
|
46
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
47
|
+
res.end('{}');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
// Capture websocket upgrades too — VNC/websockify is a WS, and the agent
|
|
52
|
+
// channel may be one; log the handshake even though we don't speak it yet.
|
|
53
|
+
server.on('upgrade', (req, socket) => {
|
|
54
|
+
record({
|
|
55
|
+
t: new Date().toISOString(), port,
|
|
56
|
+
method: req.method, path: req.url, upgrade: req.headers.upgrade,
|
|
57
|
+
headers: req.headers, bodyBytes: 0,
|
|
58
|
+
});
|
|
59
|
+
// 101 with no real protocol; the client will close, which is fine — we
|
|
60
|
+
// only needed the handshake headers.
|
|
61
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n');
|
|
62
|
+
socket.on('data', (d) => record({
|
|
63
|
+
t: new Date().toISOString(), port, method: 'WS-DATA', path: req.url,
|
|
64
|
+
bodyBytes: d.length, bodyHex: d.slice(0, 256).toString('hex'),
|
|
65
|
+
}));
|
|
66
|
+
});
|
|
67
|
+
server.listen(port, '0.0.0.0', () => console.log(`[agent] capturing on :${port}`));
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
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",
|