openzoo 0.39.4 → 0.41.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 +45 -9
- package/lib/podagent.mjs +133 -68
- package/package.json +1 -1
package/lib/boxes.js
CHANGED
|
@@ -173,7 +173,39 @@ export async function reapExpired() {
|
|
|
173
173
|
return { reaped };
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
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))
|
|
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,74 +1,136 @@
|
|
|
1
|
-
// Pod agent — runs INSIDE our box
|
|
2
|
-
//
|
|
1
|
+
// Pod agent — runs INSIDE our box. It IS the Grok Bot sandbox brain, on the
|
|
2
|
+
// ports the app expects (1337 agent, 6080 vnc, 1340 local-exec), but the brain
|
|
3
|
+
// is openzoo (x402-paid) and the sandbox is ours.
|
|
3
4
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
5
|
+
// PROTOCOL, REVERSE-ENGINEERED FROM local-exec-daemon/main.cjs (2026-08-16):
|
|
6
|
+
// GET /local-exec/requests the daemon opens an SSE stream to RECEIVE frames
|
|
7
|
+
// POST /local-exec/responses the daemon SENDS frames: hello, ping, output,
|
|
8
|
+
// stdout, result, exit
|
|
9
|
+
// frames the daemon RECEIVES (we push down the SSE):
|
|
10
|
+
// {kind:"welcome", providerId}
|
|
11
|
+
// {kind:"exec", requestId, approvalId, serverMessage:{shellArgs:{command,
|
|
12
|
+
// workingDirectory, timeout}}} (serverMessage = agent.v1.ExecServerMessage,
|
|
13
|
+
// shellArgs = agent.v1.ShellArgs; the daemon assigns id itself)
|
|
14
|
+
// {kind:"upload"|"download"|"cancel", ...}
|
|
15
|
+
// exec is APPROVAL-GATED on the user's Mac (isLocalUseBlocked / supervised).
|
|
12
16
|
//
|
|
13
|
-
// So the
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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.
|
|
17
|
+
// So: the daemon runs commands on the USER's machine (variant "sand",
|
|
18
|
+
// localRoot=/Users/…), and WE — from the box — decide what to run, paying for
|
|
19
|
+
// the reasoning via openzoo. That is the whole product: a Grok-Bot-shaped agent
|
|
20
|
+
// whose brain is the zoo.
|
|
21
21
|
|
|
22
22
|
import http from 'node:http';
|
|
23
23
|
import { appendFileSync } from 'node:fs';
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
24
25
|
|
|
25
26
|
const PORTS = (process.env.OZ_AGENT_PORTS || '1337,6080,1340,6081')
|
|
26
27
|
.split(',').map((s) => Number(s.trim())).filter(Boolean);
|
|
27
28
|
const LOG = process.env.OZ_AGENT_LOG || '/var/log/openzoo/agent.jsonl';
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
29
|
+
const PROXY = process.env.OZ_PROXY || 'http://127.0.0.1:8402/v1';
|
|
30
|
+
const MODEL = process.env.OZ_BRAIN_MODEL || 'x-ai/grok-4.6';
|
|
31
|
+
const MAX_STEPS = Number(process.env.OZ_MAX_STEPS || 10);
|
|
31
32
|
|
|
32
33
|
function record(entry) {
|
|
33
34
|
try { appendFileSync(LOG, JSON.stringify(entry) + '\n'); } catch { /* best effort */ }
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
console.log(`[agent:${entry.port ?? '-'}] ${entry.method || entry.ev} ${entry.path || ''} ${entry.bodyBytes ?? ''}${entry.frames ? ' frames=' + entry.frames : ''}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ------------------------------------------------------------------ frames --
|
|
39
|
+
|
|
40
|
+
/** Send frames to the daemon down its SSE. The daemon reads the stream with a
|
|
41
|
+
* TextDecoder and parses per-line JSON, so one frame per `data:` line. We wrap
|
|
42
|
+
* as {frames:[…]} to mirror the response side; a bare frame is the fallback. */
|
|
43
|
+
function sseSend(res, frame) {
|
|
44
|
+
const line = `data: ${JSON.stringify({ frames: [frame] })}\n\n`;
|
|
45
|
+
try { res.write(line); return true; } catch { return false; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function execFrame(command, cwd = '/tmp') {
|
|
49
|
+
return {
|
|
50
|
+
kind: 'exec',
|
|
51
|
+
requestId: randomUUID(),
|
|
52
|
+
approvalId: randomUUID(),
|
|
53
|
+
// agent.v1.ExecServerMessage as protobuf-es JSON (camelCase). The daemon
|
|
54
|
+
// assigns `id`; we only supply the shell variant.
|
|
55
|
+
serverMessage: { shellArgs: { command, workingDirectory: cwd, timeout: 120 } },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ------------------------------------------------------------------- brain --
|
|
60
|
+
|
|
61
|
+
/** One openzoo chat turn. Paid per call by the box's own wallet via the local
|
|
62
|
+
* proxy — no key, no account. */
|
|
63
|
+
async function brain(messages) {
|
|
64
|
+
const r = await fetch(`${PROXY}/chat/completions`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer sk-openzoo' },
|
|
67
|
+
body: JSON.stringify({ model: MODEL, max_tokens: 900, messages }),
|
|
68
|
+
});
|
|
69
|
+
const j = await r.json().catch(() => ({}));
|
|
70
|
+
return j?.choices?.[0]?.message?.content ?? '';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SYSTEM = `You drive a Linux/macOS shell to accomplish a task. Reply with EXACTLY one line:
|
|
74
|
+
either RUN: <a single shell command>
|
|
75
|
+
or DONE: <one-line summary> when the task is complete.
|
|
76
|
+
No prose, no fences. You are given each command's output before your next line.`;
|
|
77
|
+
|
|
78
|
+
/** The agent loop for one task, executed through the connected daemon. Each
|
|
79
|
+
* RUN is pushed as an exec frame; the daemon's result frames (captured in
|
|
80
|
+
* `pendingResults`) feed the next turn. */
|
|
81
|
+
async function runTask(task, stream, ctx) {
|
|
82
|
+
const messages = [{ role: 'system', content: SYSTEM }, { role: 'user', content: task }];
|
|
83
|
+
for (let step = 0; step < MAX_STEPS; step++) {
|
|
84
|
+
const line = (await brain(messages)).trim();
|
|
85
|
+
record({ ev: 'brain', step, line });
|
|
86
|
+
const run = /^RUN:\s*([\s\S]+)/.exec(line);
|
|
87
|
+
if (!run) { record({ ev: 'task-done', step, line }); return; }
|
|
88
|
+
const cmd = run[1].trim();
|
|
89
|
+
const frame = execFrame(cmd);
|
|
90
|
+
ctx.awaiting = frame.requestId;
|
|
91
|
+
ctx.output = '';
|
|
92
|
+
sseSend(stream, frame);
|
|
93
|
+
record({ ev: 'exec-push', step, requestId: frame.requestId, cmd });
|
|
94
|
+
// wait for the daemon's result frames for this requestId (or timeout)
|
|
95
|
+
const out = await waitResult(ctx, 60_000);
|
|
96
|
+
messages.push({ role: 'assistant', content: line });
|
|
97
|
+
messages.push({ role: 'user', content: `output:\n${out || '(none)'}` });
|
|
98
|
+
}
|
|
36
99
|
}
|
|
37
100
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// Try the most likely shapes the daemon will accept; it will 400/echo the
|
|
48
|
-
// one it understands, which the capture records.
|
|
49
|
-
const frame = { frames: [{ kind: 'exec', id, command: cmd, cwd: '/tmp', supervised: true }] };
|
|
50
|
-
const payload = `data: ${JSON.stringify(frame)}\n\n`;
|
|
51
|
-
for (const res of execStreams) { try { res.write(payload); } catch { /* dead stream */ } }
|
|
52
|
-
record({ t: new Date().toISOString(), port: 1340, method: 'SSE-PUSH', path: '/local-exec/requests',
|
|
53
|
-
bodyBytes: payload.length, pushed: frame });
|
|
101
|
+
function waitResult(ctx, timeoutMs) {
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
const started = Date.now();
|
|
104
|
+
const iv = setInterval(() => {
|
|
105
|
+
if (ctx.done || Date.now() - started > timeoutMs) {
|
|
106
|
+
clearInterval(iv); ctx.done = false; resolve(ctx.output || '');
|
|
107
|
+
}
|
|
108
|
+
}, 250);
|
|
109
|
+
});
|
|
54
110
|
}
|
|
55
111
|
|
|
112
|
+
// ----------------------------------------------------------------- servers --
|
|
113
|
+
|
|
114
|
+
// per-connection context so a result frame can be matched to its exec
|
|
115
|
+
const ctxByToken = new Map();
|
|
116
|
+
|
|
56
117
|
for (const port of PORTS) {
|
|
57
118
|
const server = http.createServer((req, res) => {
|
|
58
|
-
//
|
|
119
|
+
// The daemon's receive-channel: hold the SSE open; when it connects, if a
|
|
120
|
+
// task is queued (OZ_TASK) run the agent loop against it.
|
|
59
121
|
if (req.method === 'GET' && req.url && req.url.startsWith('/local-exec/requests')) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
connection: 'keep-alive',
|
|
66
|
-
});
|
|
122
|
+
const token = (req.headers['x-anyrun-network-token'] || 'default');
|
|
123
|
+
const ctx = { awaiting: null, output: '', done: false };
|
|
124
|
+
ctxByToken.set(token, ctx);
|
|
125
|
+
record({ port, method: 'GET', path: req.url, note: 'SSE opened', bodyBytes: 0 });
|
|
126
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
67
127
|
res.write(':ok\n\n');
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
128
|
+
// announce ourselves, then drive the task
|
|
129
|
+
sseSend(res, { kind: 'welcome', providerId: 'openzoo' });
|
|
130
|
+
req.on('close', () => ctxByToken.delete(token));
|
|
131
|
+
if (process.env.OZ_TASK) {
|
|
132
|
+
setTimeout(() => runTask(process.env.OZ_TASK, res, ctx).catch((e) => record({ ev: 'task-error', err: String(e) })), 1200);
|
|
133
|
+
}
|
|
72
134
|
return;
|
|
73
135
|
}
|
|
74
136
|
|
|
@@ -76,32 +138,35 @@ for (const port of PORTS) {
|
|
|
76
138
|
req.on('data', (d) => chunks.push(d));
|
|
77
139
|
req.on('end', () => {
|
|
78
140
|
const body = Buffer.concat(chunks);
|
|
79
|
-
let
|
|
80
|
-
try {
|
|
81
|
-
record({
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
141
|
+
let parsed, kinds;
|
|
142
|
+
try { parsed = JSON.parse(body.toString('utf8')); kinds = parsed.frames?.map((f) => f.kind).join(','); } catch { /* not json */ }
|
|
143
|
+
record({ port, method: req.method, path: req.url, bodyBytes: body.length, frames: kinds,
|
|
144
|
+
bodyUtf8: body.slice(0, 1024).toString('utf8').replace(/[^\x20-\x7e]/g, '.') });
|
|
145
|
+
|
|
146
|
+
// the daemon posts result frames here — accumulate output for the loop
|
|
147
|
+
if (parsed?.frames && req.url?.includes('/local-exec/responses')) {
|
|
148
|
+
const token = (req.headers['x-anyrun-network-token'] || 'default');
|
|
149
|
+
const ctx = ctxByToken.get(token);
|
|
150
|
+
for (const f of parsed.frames) {
|
|
151
|
+
if (ctx && (f.kind === 'output' || f.kind === 'stdout')) ctx.output += (f.data || f.text || f.chunk || '');
|
|
152
|
+
if (ctx && (f.kind === 'result' || f.kind === 'exit')) ctx.done = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
88
156
|
if (req.url && req.url.includes('/vnc')) {
|
|
89
157
|
res.writeHead(200, { 'content-type': 'text/html' });
|
|
90
|
-
res.end('<!doctype html><title>openzoo box</title
|
|
158
|
+
res.end('<!doctype html><title>openzoo box</title>');
|
|
91
159
|
} else {
|
|
92
|
-
// /local-exec/responses and everything else: accept it so the daemon
|
|
93
|
-
// keeps its session and keeps sending frames.
|
|
94
160
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
95
161
|
res.end('{"ok":true}');
|
|
96
162
|
}
|
|
97
163
|
});
|
|
98
164
|
});
|
|
99
165
|
server.on('upgrade', (req, socket) => {
|
|
100
|
-
record({
|
|
101
|
-
upgrade: req.headers.upgrade, headers: req.headers, bodyBytes: 0 });
|
|
166
|
+
record({ port, method: 'UPGRADE', path: req.url, bodyBytes: 0 });
|
|
102
167
|
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n');
|
|
103
|
-
socket.on('data', (d) => record({ t: new Date().toISOString(), port, method: 'WS-DATA', path: req.url,
|
|
104
|
-
bodyBytes: d.length, bodyHex: d.slice(0, 256).toString('hex') }));
|
|
105
168
|
});
|
|
106
|
-
server.listen(port, '0.0.0.0', () => console.log(`[agent]
|
|
169
|
+
server.listen(port, '0.0.0.0', () => console.log(`[agent] on :${port}`));
|
|
107
170
|
}
|
|
171
|
+
|
|
172
|
+
console.log(`[agent] brain=${MODEL} via ${PROXY} task=${process.env.OZ_TASK ? JSON.stringify(process.env.OZ_TASK).slice(0, 60) : '(none — waiting)'}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.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",
|