openzoo 0.29.2 → 0.29.4
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/cursor-backend.js +33 -0
- package/bin/openzoo.js +2 -1
- package/lib/cursorbackend.js +63 -10
- package/lib/hosts.js +31 -0
- package/lib/setup.js +15 -7
- package/package.json +1 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone impersonation backend, meant to be spawned WITH PRIVILEGE so it can
|
|
4
|
+
* bind 127.0.0.1:443 directly — the port the editor dials for api2.cursor.sh.
|
|
5
|
+
*
|
|
6
|
+
* WHY A SEPARATE ROOT PROCESS. Binding 443 needs root on unix, but the paying
|
|
7
|
+
* proxy must NOT run as root (it holds the wallet). This process is static — it
|
|
8
|
+
* serves a model catalog and empty stubs, touches no keys, no money — so running
|
|
9
|
+
* only THIS as root is the safe split. The pfctl 443->8443 redirect that would
|
|
10
|
+
* have avoided root does not deliver loopback-to-loopback on macOS (measured:
|
|
11
|
+
* self-test FAIL), so a direct bind is the reliable path.
|
|
12
|
+
*
|
|
13
|
+
* argv: <port> <models.json> <log-file>
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { startCursorBackend } from '../lib/cursorbackend.js';
|
|
17
|
+
|
|
18
|
+
const port = Number(process.argv[2] || 443);
|
|
19
|
+
const modelsPath = process.argv[3];
|
|
20
|
+
const logPath = process.argv[4];
|
|
21
|
+
|
|
22
|
+
const log = (s) => {
|
|
23
|
+
const line = `${s}\n`;
|
|
24
|
+
try { if (logPath) fs.appendFileSync(logPath, line); } catch { /* ignore */ }
|
|
25
|
+
process.stdout.write(line);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
let models = [];
|
|
29
|
+
try { models = JSON.parse(fs.readFileSync(modelsPath, 'utf8')); }
|
|
30
|
+
catch (e) { log(`cursor-backend: could not read models (${e.message})`); process.exit(1); }
|
|
31
|
+
|
|
32
|
+
startCursorBackend({ port, models, log });
|
|
33
|
+
log(`cursor-backend: standalone up on :${port} (${models.length} models)`);
|
package/bin/openzoo.js
CHANGED
|
@@ -78,7 +78,8 @@ async function main() {
|
|
|
78
78
|
}
|
|
79
79
|
case 'unblock': {
|
|
80
80
|
const { unblockBackend, unredirect443, isBlocked } = await import('../lib/hosts.js');
|
|
81
|
-
try { unredirect443(); } catch { /* no redirect
|
|
81
|
+
try { unredirect443(); } catch { /* no redirect */ }
|
|
82
|
+
try { (await import('../lib/hosts.js')).unbindBackend443(); } catch { /* no backend */ }
|
|
82
83
|
const r = unblockBackend();
|
|
83
84
|
console.log(r.already ? 'not blocked — nothing to undo' : (isBlocked() ? 'still blocked (sudo declined?)' : 'restored: the editor can reach its own backend again'));
|
|
84
85
|
break;
|
package/lib/cursorbackend.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* content-type and answer in kind: bare message for connect unary proto, enveloped
|
|
27
27
|
* + trailers for grpc-web. Both are handled below.
|
|
28
28
|
*/
|
|
29
|
-
import
|
|
29
|
+
import http2 from 'node:http2';
|
|
30
30
|
import fs from 'node:fs';
|
|
31
31
|
import os from 'node:os';
|
|
32
32
|
import path from 'node:path';
|
|
@@ -80,14 +80,59 @@ function envelope(payload) {
|
|
|
80
80
|
return Buffer.concat([head, payload]);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/** CORS headers — the editor's renderer is a browser; without these the
|
|
84
|
+
* preflight fails and the REAL request (e.g. full_stripe_profile) never fires. */
|
|
85
|
+
const CORS = {
|
|
86
|
+
'access-control-allow-origin': '*',
|
|
87
|
+
'access-control-allow-methods': 'GET,POST,OPTIONS,PUT,DELETE',
|
|
88
|
+
'access-control-allow-headers': '*',
|
|
89
|
+
'access-control-expose-headers': '*',
|
|
90
|
+
'access-control-allow-credentials': 'true',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* An ENTITLED stripe profile. `#4 OPTIONS /auth/full_stripe_profile` in the log
|
|
95
|
+
* is THE plan gate — this is what tells the editor whether it may use models.
|
|
96
|
+
* Empty (my first stub) reads as free, hence "upgrade". These fields report an
|
|
97
|
+
* active, unlimited membership so nothing is gated.
|
|
98
|
+
*/
|
|
99
|
+
function stripeProfile() {
|
|
100
|
+
return JSON.stringify({
|
|
101
|
+
membershipType: 'pro',
|
|
102
|
+
subscriptionStatus: 'active',
|
|
103
|
+
verifiedStudent: false,
|
|
104
|
+
trialEligible: false,
|
|
105
|
+
daysRemainingOnTrial: 0,
|
|
106
|
+
isOnStudentPlan: false,
|
|
107
|
+
hardLimit: null,
|
|
108
|
+
hardLimitPerUser: null,
|
|
109
|
+
usageBasedPricingEnabled: true,
|
|
110
|
+
monthlyUsageBasedLimit: 1000000,
|
|
111
|
+
individualMembershipType: 'pro',
|
|
112
|
+
teamMembershipType: null,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
83
116
|
/**
|
|
84
117
|
* Answer one Connect/gRPC-web call. `method` is the trailing method name,
|
|
85
118
|
* `models` the catalog to publish. Non-catalog methods get an empty-OK body.
|
|
86
119
|
*/
|
|
87
120
|
function respond(req, res, method, models) {
|
|
121
|
+
// CORS preflight: answer 204 with the headers, so the real call proceeds.
|
|
122
|
+
if (req.method === 'OPTIONS') { res.writeHead(204, CORS); res.end(); return; }
|
|
123
|
+
|
|
124
|
+
const url = req.url || '';
|
|
88
125
|
const ct = String(req.headers['content-type'] || '');
|
|
89
126
|
const isGrpcWeb = ct.includes('grpc-web');
|
|
90
|
-
const isJson = ct.includes('json');
|
|
127
|
+
const isJson = ct.includes('json') || url.startsWith('/auth/');
|
|
128
|
+
|
|
129
|
+
// THE ENTITLEMENT ENDPOINT. /auth/full_stripe_profile (and any /auth/*stripe*)
|
|
130
|
+
// must report an active membership or the editor gates every model.
|
|
131
|
+
if (/full_stripe_profile|stripe|membership|subscription/i.test(url)) {
|
|
132
|
+
res.writeHead(200, { 'content-type': 'application/json', ...CORS });
|
|
133
|
+
res.end(stripeProfile());
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
91
136
|
|
|
92
137
|
const bodyProto = method === 'AvailableModels' ? encodeAvailableModels(models) : Buffer.alloc(0);
|
|
93
138
|
|
|
@@ -100,7 +145,7 @@ function respond(req, res, method, models) {
|
|
|
100
145
|
clientDisplayName: m.label ?? m.name, serverModelName: m.name, supportsPlanMode: true,
|
|
101
146
|
})) })
|
|
102
147
|
: '{}';
|
|
103
|
-
res.writeHead(200, { 'content-type': 'application/json' });
|
|
148
|
+
res.writeHead(200, { 'content-type': 'application/json', ...CORS });
|
|
104
149
|
res.end(json);
|
|
105
150
|
return;
|
|
106
151
|
}
|
|
@@ -108,14 +153,14 @@ function respond(req, res, method, models) {
|
|
|
108
153
|
if (isGrpcWeb) {
|
|
109
154
|
res.writeHead(200, {
|
|
110
155
|
'content-type': ct.includes('text') ? 'application/grpc-web-text+proto' : 'application/grpc-web+proto',
|
|
111
|
-
'grpc-status': '0',
|
|
156
|
+
'grpc-status': '0', ...CORS,
|
|
112
157
|
});
|
|
113
158
|
res.end(Buffer.concat([envelope(bodyProto), grpcWebTrailer()]));
|
|
114
159
|
return;
|
|
115
160
|
}
|
|
116
161
|
|
|
117
162
|
// Connect unary proto: bare message, status in headers.
|
|
118
|
-
res.writeHead(200, { 'content-type': 'application/proto' });
|
|
163
|
+
res.writeHead(200, { 'content-type': 'application/proto', ...CORS });
|
|
119
164
|
res.end(bodyProto);
|
|
120
165
|
}
|
|
121
166
|
|
|
@@ -127,13 +172,17 @@ function respond(req, res, method, models) {
|
|
|
127
172
|
export function startCursorBackend({ port = 8443, models, log = () => {} } = {}) {
|
|
128
173
|
const { cert, key } = ensureCert(log);
|
|
129
174
|
let conns = 0;
|
|
130
|
-
|
|
175
|
+
// HTTP/2 with ALPN, HTTP/1.1 fallback. The editor's Connect-RPC client speaks
|
|
176
|
+
// h2; a plain https (h1-only) server with no ALPN made most handshakes fail
|
|
177
|
+
// with ERR_SSL_NO_APPLICATION_PROTOCOL / ECONNRESET (measured in the log), so
|
|
178
|
+
// only a couple of h1 requests ever completed. allowHTTP1 keeps h1 working too;
|
|
179
|
+
// the (req,res) compatibility handler serves both.
|
|
180
|
+
const server = http2.createSecureServer(
|
|
131
181
|
{
|
|
132
182
|
cert: fs.readFileSync(cert),
|
|
133
183
|
key: fs.readFileSync(key),
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// reaching US instead of the real backend.
|
|
184
|
+
allowHTTP1: true,
|
|
185
|
+
ALPNProtocols: ['h2', 'http/1.1'],
|
|
137
186
|
SNICallback: (servername, cb) => { log(`cursor-tls: <- ClientHello SNI=${servername}`); cb(null); },
|
|
138
187
|
},
|
|
139
188
|
async (req, res) => {
|
|
@@ -145,7 +194,11 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
|
|
|
145
194
|
log(`cursor-backend: #${conns} ${req.method} ${full} ct=${ct} body=${body.length}b`);
|
|
146
195
|
try {
|
|
147
196
|
respond(req, res, method, models);
|
|
148
|
-
|
|
197
|
+
const what = /stripe|membership|subscription/i.test(full) ? 'ENTITLED stripe profile'
|
|
198
|
+
: method === 'AvailableModels' ? `AvailableModels (${models.length} models, gates open)`
|
|
199
|
+
: req.method === 'OPTIONS' ? 'CORS preflight 204'
|
|
200
|
+
: 'empty-ok';
|
|
201
|
+
log(`cursor-backend: -> ${what}`);
|
|
149
202
|
} catch (e) {
|
|
150
203
|
res.writeHead(200, { 'content-type': 'application/proto', 'grpc-status': '0' });
|
|
151
204
|
res.end(Buffer.alloc(0));
|
package/lib/hosts.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* `npx openzoo unblock` restores it. `--no-block` skips it entirely.
|
|
18
18
|
*/
|
|
19
19
|
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
20
22
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -137,3 +139,32 @@ export function unredirect443() {
|
|
|
137
139
|
if (process.platform === 'linux') return { ok: privileged('iptables -t nat -D OUTPUT -p tcp -o lo --dport 443 -j REDIRECT --to-ports 8443 2>/dev/null; true') };
|
|
138
140
|
return { ok: false, manual: true };
|
|
139
141
|
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Bind the impersonation backend on 127.0.0.1:443 as ROOT — the reliable path
|
|
145
|
+
* after pfctl loopback redirect proved not to deliver on macOS. Spawned detached
|
|
146
|
+
* inside the SAME sudo prompt used for the hosts entry; only this static server
|
|
147
|
+
* runs privileged, never the wallet-holding proxy. unbindBackend443 kills it.
|
|
148
|
+
*/
|
|
149
|
+
export function bindBackend443(modelsPath, logPath, log = console.log) {
|
|
150
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
151
|
+
const script = path.join(here, '..', 'bin', 'cursor-backend.js');
|
|
152
|
+
const node = process.execPath;
|
|
153
|
+
if (WIN) {
|
|
154
|
+
log(' windows: run in an ADMINISTRATOR shell:');
|
|
155
|
+
log(` "${node}" "${script}" 443 "${modelsPath}" "${logPath}"`);
|
|
156
|
+
return { ok: false, manual: true };
|
|
157
|
+
}
|
|
158
|
+
// nohup + & so sudo returns immediately; the root process keeps 443 bound.
|
|
159
|
+
const ok = privileged(
|
|
160
|
+
`pkill -f 'cursor-backend.js' 2>/dev/null; `
|
|
161
|
+
+ `nohup '${node}' '${script}' 443 '${modelsPath}' '${logPath}' >/dev/null 2>&1 & `
|
|
162
|
+
+ 'sleep 1; true',
|
|
163
|
+
);
|
|
164
|
+
return { ok };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function unbindBackend443() {
|
|
168
|
+
if (WIN) return { ok: false, manual: true };
|
|
169
|
+
return { ok: privileged("pkill -f 'cursor-backend.js' 2>/dev/null; true") };
|
|
170
|
+
}
|
package/lib/setup.js
CHANGED
|
@@ -454,14 +454,22 @@ export async function setupEditor(which, target) {
|
|
|
454
454
|
const doTakeover = target0 === 'cursor' && !process.argv.includes('--no-takeover');
|
|
455
455
|
if (doTakeover) {
|
|
456
456
|
try {
|
|
457
|
-
const {
|
|
458
|
-
const {
|
|
457
|
+
const { ensureCert } = await import('./cursorbackend.js');
|
|
458
|
+
const { bindBackend443 } = await import('./hosts.js');
|
|
459
459
|
const backModels = models.map((m) => ({ name: m, label: m }));
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
console.log
|
|
463
|
-
|
|
464
|
-
|
|
460
|
+
// Cert is minted as the USER (root can still read it); models handed to the
|
|
461
|
+
// privileged listener via a temp file.
|
|
462
|
+
ensureCert(console.log);
|
|
463
|
+
const modelsFile = path.join(os.tmpdir(), 'openzoo-cursor-models.json');
|
|
464
|
+
fs.writeFileSync(modelsFile, JSON.stringify(backModels));
|
|
465
|
+
const backendLog = path.join(os.homedir(), '.openzoo', 'cursor-backend.log');
|
|
466
|
+
try { fs.writeFileSync(backendLog, ''); } catch { /* ignore */ }
|
|
467
|
+
// DIRECT 443 BIND (root) — pfctl loopback redirect measured FAIL, this is
|
|
468
|
+
// the reliable path. Only this static server runs privileged.
|
|
469
|
+
const rr = bindBackend443(modelsFile, backendLog, console.log);
|
|
470
|
+
console.log(rr.ok ? `takeover: backend bound on 127.0.0.1:443 (root); log ${backendLog}`
|
|
471
|
+
: rr.manual ? 'takeover: run the printed command in an elevated shell, then relaunch'
|
|
472
|
+
: 'takeover: could NOT bind 443 — impersonation will not receive traffic');
|
|
465
473
|
|
|
466
474
|
// SELF-TEST — prove the whole path (hosts -> redirect -> our server) works
|
|
467
475
|
// NOW, at startup, so a failure is a loud line here instead of a silent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.4",
|
|
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",
|