openzoo 0.39.3 → 0.40.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/lib/boxes.js CHANGED
@@ -173,7 +173,39 @@ export async function reapExpired() {
173
173
  return { reaped };
174
174
  }
175
175
 
176
- export async function spawnBox({ name = 'bot', days = 1, vcpu = 2, walletJson, pubkey } = {}) {
176
+ /**
177
+ * MACHINE TIERS — a menu, not one size.
178
+ *
179
+ * A box that only runs a shell is fine on 2 vCPU, but a grokbot agent that
180
+ * builds a site, runs a dev server, or compiles wants real muscle — and some
181
+ * tasks want a GPU. So the orchestrator quotes SEVERAL tiers per timeframe and
182
+ * the user picks. GPU is a DELIBERATE tier here, never an accident: the guard
183
+ * below refuses GPU fields on a CPU tier, so a cpu tier can't silently 100x the
184
+ * bill, while a gpu tier carries them on purpose.
185
+ *
186
+ * $/hr are approximate RunPod secure-cloud rates for sizing the quote; the real
187
+ * charge is whatever RunPod bills. estUsdHr is labelled approximate for that.
188
+ */
189
+ export const BOX_TIERS = {
190
+ 'cpu-sm': { label: 'CPU · 2 vCPU', compute: 'CPU', vcpu: 2, disk: 20, estUsdHr: 0.06 },
191
+ 'cpu-md': { label: 'CPU · 4 vCPU', compute: 'CPU', vcpu: 4, disk: 40, estUsdHr: 0.12 },
192
+ 'cpu-lg': { label: 'CPU · 8 vCPU', compute: 'CPU', vcpu: 8, disk: 60, estUsdHr: 0.24 },
193
+ 'gpu-4090':{ label: 'GPU · 1× RTX 4090', compute: 'GPU', gpuTypeIds: ['NVIDIA GeForce RTX 4090'], gpuCount: 1, disk: 60, estUsdHr: 0.44 },
194
+ 'gpu-a100':{ label: 'GPU · 1× A100 80GB', compute: 'GPU', gpuTypeIds: ['NVIDIA A100 80GB PCIe'], gpuCount: 1, disk: 80, estUsdHr: 1.64 },
195
+ };
196
+ export const DEFAULT_TIER = 'cpu-sm';
197
+
198
+ /** The quote menu the orchestrator shows: every tier priced for `days`. */
199
+ export function quoteTiers(days = 1) {
200
+ const hrs = ttlHours(days);
201
+ return Object.entries(BOX_TIERS).map(([key, t]) => ({
202
+ tier: key, label: t.label, days: Math.round(hrs / 24 * 10) / 10,
203
+ estUsd: Math.round(t.estUsdHr * hrs * 100) / 100, estUsdHr: t.estUsdHr, gpu: t.compute === 'GPU',
204
+ }));
205
+ }
206
+
207
+ export async function spawnBox({ name = 'bot', days = 1, tier = DEFAULT_TIER, walletJson, pubkey } = {}) {
208
+ const spec = BOX_TIERS[tier] || BOX_TIERS[DEFAULT_TIER];
177
209
  const expiresAt = new Date(Date.now() + ttlHours(days) * 3600 * 1000);
178
210
  const unix = Math.floor(expiresAt.getTime() / 1000);
179
211
  const rand = Math.random().toString(36).slice(2, 6);
@@ -181,19 +213,20 @@ export async function spawnBox({ name = 'bot', days = 1, vcpu = 2, walletJson, p
181
213
 
182
214
  const body = {
183
215
  name: full,
184
- computeType: 'CPU',
216
+ computeType: spec.compute,
185
217
  cloudType: 'SECURE',
186
- vcpuCount: Math.max(1, Math.min(Number(vcpu) || 2, 4)),
187
- cpuFlavorIds: CPU_FLAVORS,
188
- cpuFlavorPriority: 'availability',
189
218
  imageName: BOX_IMAGE,
190
- containerDiskInGb: 20,
219
+ containerDiskInGb: spec.disk,
191
220
  volumeInGb: 10,
192
221
  volumeMountPath: '/workspace',
193
222
  ports: BOX_PORTS,
194
223
  dockerStartCmd: ENTRYPOINT,
224
+ ...(spec.compute === 'CPU'
225
+ ? { vcpuCount: spec.vcpu, cpuFlavorIds: CPU_FLAVORS, cpuFlavorPriority: 'availability' }
226
+ : { gpuCount: spec.gpuCount, gpuTypeIds: spec.gpuTypeIds }),
195
227
  env: {
196
228
  OPENZOO_BOX: '1',
229
+ OPENZOO_TIER: tier,
197
230
  OPENZOO_EXPIRES_UNIX: String(unix),
198
231
  OPENZOO_NO_TUNNEL: '1', // the runpod proxy already fronts it
199
232
  OPENZOO_BIND: '0.0.0.0', // else the runpod proxy can't reach :8402
@@ -203,13 +236,16 @@ export async function spawnBox({ name = 'bot', days = 1, vcpu = 2, walletJson, p
203
236
  OZ_PODAGENT_B64: podAgentB64(),
204
237
  },
205
238
  };
206
- // ENFORCE on the actual payload, not on a literal we just wrote.
239
+ // GPU ONLY ON A GPU TIER. A cpu tier must never carry GPU fields — that would
240
+ // be a silent 100x. Enforced on the serialized payload, not a literal.
207
241
  const wire = JSON.stringify(body);
208
- if (/"gpu(Count|TypeIds)"/.test(wire)) throw new Error('refusing to spawn: GPU fields present');
242
+ if (spec.compute === 'CPU' && /"gpu(Count|TypeIds)"/.test(wire)) {
243
+ throw new Error('refusing to spawn: GPU fields on a CPU tier');
244
+ }
209
245
 
210
246
  const created = await rp('/pods', { method: 'POST', body: wire });
211
247
  if (!created.ok) return { ok: false, error: created.data?.error || `spawn ${created.status}` };
212
- return { ok: true, box: serializeBox(created.data), expiresAt: expiresAt.toISOString() };
248
+ return { ok: true, tier, box: serializeBox(created.data), expiresAt: expiresAt.toISOString() };
213
249
  }
214
250
 
215
251
  /** Poll until the box answers on its proxy — npx has to fetch the shim first. */
package/lib/podagent.mjs CHANGED
@@ -1,14 +1,23 @@
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).
1
+ // Pod agent — runs INSIDE our box, on the ports Grok Bot expects a Cursor
2
+ // sandbox to answer (1337 agent, 6080 vnc, 1340 local-exec, 6081).
3
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.
4
+ // THE PROTOCOL, DECODED FROM A LIVE CAPTURE (2026-08-16). Grok Bot's sandbox
5
+ // speaks plain HTTP JSON, not a proprietary binary:
6
+ // GET /local-exec/requests <- the local-exec-daemon opens an SSE stream to
7
+ // RECEIVE exec requests from the pod.
8
+ // POST /local-exec/responses <- the daemon SENDS frames back:
9
+ // {"frames":[{"kind":"hello","localRoot":"/Users/…","variant":"sand",…}]}
10
+ // {"frames":[{"kind":"ping","supervised":true}]}
11
+ // auth: Bearer <network_token> / x-anyrun-network-token: nto-…
12
+ //
13
+ // So the box is the ORCHESTRATOR: it holds the SSE open and pushes exec frames
14
+ // down it; the daemon runs them and posts results to /local-exec/responses.
15
+ // This file (a) answers the handshake so the daemon stays connected, (b) can
16
+ // PUSH a probe command down the SSE to capture what an exec frame + its output
17
+ // frame look like (OZ_PROBE_CMD), and (c) logs every frame to agent.jsonl.
18
+ //
19
+ // STILL A CAPTURE until we've seen one real exec/output frame — then the
20
+ // openzoo agent brain can drive this for real. Honest about which.
12
21
 
13
22
  import http from 'node:http';
14
23
  import { appendFileSync } from 'node:fs';
@@ -16,53 +25,86 @@ import { appendFileSync } from 'node:fs';
16
25
  const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
17
26
  .split(',').map((s) => Number(s.trim())).filter(Boolean);
18
27
  const LOG = process.env.OZ_AGENT_LOG || '/var/log/openzoo/agent.jsonl';
28
+ // A shell command to push down the SSE the first time a daemon connects, to
29
+ // capture the exec-request AND its result frame. Default probes harmlessly.
30
+ const PROBE = process.env.OZ_PROBE_CMD || 'echo openzoo-probe && uname -a';
19
31
 
20
32
  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})` : ''));
33
+ try { appendFileSync(LOG, JSON.stringify(entry) + '\n'); } catch { /* best effort */ }
34
+ const tag = entry.frames ? ` frames=${entry.frames}` : '';
35
+ console.log(`[agent:${entry.port}] ${entry.method} ${entry.path} ${entry.bodyBytes}b${tag}`);
36
+ }
37
+
38
+ // SSE clients currently holding /local-exec/requests open, so we can push.
39
+ const execStreams = new Set();
40
+ let probedOnce = false;
41
+
42
+ /** Push one exec frame down every open request stream. Frame shape is our best
43
+ * guess from the response frames; the daemon's reply (accept or error) TEACHES
44
+ * us the real schema, which is the whole point. */
45
+ function pushExec(cmd) {
46
+ const id = `oz-${Date.now().toString(36)}`;
47
+ // THE COMMAND FRAME IS kind:"shell", NOT "exec". Decoded from the app bundle:
48
+ // the daemon's receive-union has kind "shell" (run), "sequence", "abort"; the
49
+ // proto is {command: scalar, args, cwd, env}. Our first probe used "exec"
50
+ // (which appears once in the bundle) and the daemon silently ignored it,
51
+ // sending only pings. "shell" is what it actually runs.
52
+ const frame = { frames: [{ kind: 'shell', id, command: cmd, cwd: '/tmp', supervised: true }] };
53
+ const payload = `data: ${JSON.stringify(frame)}\n\n`;
54
+ for (const res of execStreams) { try { res.write(payload); } catch { /* dead stream */ } }
55
+ record({ t: new Date().toISOString(), port: 1340, method: 'SSE-PUSH', path: '/local-exec/requests',
56
+ bodyBytes: payload.length, pushed: frame });
25
57
  }
26
58
 
27
59
  for (const port of PORTS) {
28
60
  const server = http.createServer((req, res) => {
61
+ // SSE receive-channel: hold it open, let us push exec frames.
62
+ if (req.method === 'GET' && req.url && req.url.startsWith('/local-exec/requests')) {
63
+ record({ t: new Date().toISOString(), port, method: 'GET', path: req.url,
64
+ headers: req.headers, bodyBytes: 0, note: 'SSE stream opened' });
65
+ res.writeHead(200, {
66
+ 'content-type': 'text/event-stream',
67
+ 'cache-control': 'no-cache',
68
+ connection: 'keep-alive',
69
+ });
70
+ res.write(':ok\n\n');
71
+ execStreams.add(res);
72
+ req.on('close', () => execStreams.delete(res));
73
+ // once a stream is open, push the probe command to capture the exec loop
74
+ if (!probedOnce) { probedOnce = true; setTimeout(() => pushExec(PROBE), 1500); }
75
+ return;
76
+ }
77
+
29
78
  const chunks = [];
30
79
  req.on('data', (d) => chunks.push(d));
31
80
  req.on('end', () => {
32
81
  const body = Buffer.concat(chunks);
82
+ let frames;
83
+ try { frames = JSON.parse(body.toString('utf8')).frames?.map((f) => f.kind).join(','); } catch { /* not json */ }
33
84
  record({
34
85
  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, '.'),
86
+ method: req.method, path: req.url, headers: req.headers,
87
+ bodyBytes: body.length, frames,
88
+ bodyHex: body.slice(0, 256).toString('hex'),
89
+ bodyUtf8: body.slice(0, 1024).toString('utf8').replace(/[^\x20-\x7e]/g, '.'),
40
90
  });
41
- // Benign answers so the client does not give up immediately.
42
91
  if (req.url && req.url.includes('/vnc')) {
43
92
  res.writeHead(200, { 'content-type': 'text/html' });
44
93
  res.end('<!doctype html><title>openzoo box</title><body>capture</body>');
45
94
  } else {
95
+ // /local-exec/responses and everything else: accept it so the daemon
96
+ // keeps its session and keeps sending frames.
46
97
  res.writeHead(200, { 'content-type': 'application/json' });
47
- res.end('{}');
98
+ res.end('{"ok":true}');
48
99
  }
49
100
  });
50
101
  });
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
102
  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.
103
+ record({ t: new Date().toISOString(), port, method: 'UPGRADE', path: req.url,
104
+ upgrade: req.headers.upgrade, headers: req.headers, bodyBytes: 0 });
61
105
  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
- }));
106
+ socket.on('data', (d) => record({ t: new Date().toISOString(), port, method: 'WS-DATA', path: req.url,
107
+ bodyBytes: d.length, bodyHex: d.slice(0, 256).toString('hex') }));
66
108
  });
67
109
  server.listen(port, '0.0.0.0', () => console.log(`[agent] capturing on :${port}`));
68
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.39.3",
3
+ "version": "0.40.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",