openzoo 0.38.8 → 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 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 point Grok Bot / the grok CLI at the zoo — GROK MODELS ONLY,
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/grokbot.js')).setupGrokBot(process.argv.slice(3));
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/grokbot.js CHANGED
@@ -1,264 +1,107 @@
1
- // `npx openzoo grokbot` — Grok Bot (xAI's agent app) on the zoo, Grok-only.
1
+ // `npx openzoo grokbot` — keep Grok Bot's UI, serve OUR box underneath.
2
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.
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
- // 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".
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, readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
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
- /** 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); }
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
- 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}`);
37
+ if (!existsSync(APP)) {
38
+ console.error('openzoo grokbot: /Applications/Grok Bot.app not found.');
39
+ process.exit(1);
129
40
  }
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}"`);
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
- // --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
- }
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(() => {});
264
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
+ }
@@ -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.38.8",
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",