openzoo 0.50.97 → 0.50.99

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/grokcli.js CHANGED
@@ -16,13 +16,26 @@
16
16
  // "payment required".
17
17
 
18
18
  import { spawn, execSync } from 'node:child_process';
19
- import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, readdirSync, chmodSync } from 'node:fs';
19
+ import { createHash } from 'node:crypto';
20
+ import {
21
+ closeSync,
22
+ existsSync,
23
+ openSync,
24
+ readFileSync,
25
+ writeFileSync,
26
+ copyFileSync,
27
+ mkdirSync,
28
+ readdirSync,
29
+ chmodSync,
30
+ unlinkSync,
31
+ } from 'node:fs';
20
32
  import { homedir } from 'node:os';
21
- import { dirname, join } from 'node:path';
33
+ import { delimiter, dirname, join } from 'node:path';
22
34
  import { fileURLToPath } from 'node:url';
23
35
 
24
36
  import { config } from './config.js';
25
37
  import { killListen } from './proxy.js';
38
+ import { fakeCursorTokens } from './cursorapi.js';
26
39
  import {
27
40
  GROKBOT_CDP_PORT,
28
41
  grokBotChromiumArgs,
@@ -31,7 +44,6 @@ import {
31
44
 
32
45
  const GROK_HOME = process.env.GROK_HOME || join(homedir(), '.grok');
33
46
  const CONFIG_PATH = join(GROK_HOME, 'config.toml');
34
- const APP = process.env.GROK_APP || '/Applications/Grok Bot.app';
35
47
 
36
48
  /* LINUX/WIN DISCOVERY — the multiarch release ships Grok_Bot_<v>_<plat>-<arch>.AppImage
37
49
  * / .exe. GROKBOT_BIN env always wins. */
@@ -72,24 +84,44 @@ function windowsExe() {
72
84
  return join(home, 'AppData', 'Local', 'Programs', 'Grok Bot', 'Grok Bot.exe');
73
85
  }
74
86
 
87
+ function darwinAppDirs() {
88
+ const apps = [];
89
+ if (process.env.GROK_APP) apps.push(process.env.GROK_APP);
90
+ apps.push('/Applications/Grok Bot.app');
91
+ apps.push(join(homedir(), 'Applications', 'Grok Bot.app'));
92
+ return apps;
93
+ }
94
+
95
+ function darwinBinary() {
96
+ for (const app of darwinAppDirs()) {
97
+ const bin = join(app, 'Contents', 'MacOS', 'Grok Bot');
98
+ if (existsSync(bin)) return bin;
99
+ }
100
+ return join(darwinAppDirs()[0], 'Contents', 'MacOS', 'Grok Bot');
101
+ }
102
+
75
103
  /** Cross-platform Grok Bot binary. GROKBOT_BIN env always wins. */
76
104
  export function grokBotBinary() {
77
105
  if (process.env.GROKBOT_BIN) return process.env.GROKBOT_BIN;
78
- if (process.platform === 'darwin') return `${APP}/Contents/MacOS/Grok Bot`;
106
+ if (process.platform === 'darwin') return darwinBinary();
79
107
  if (process.platform === 'linux') return linuxAppImage();
80
108
  if (process.platform === 'win32') return windowsExe();
81
- return `${APP}/Contents/MacOS/Grok Bot`;
109
+ return darwinBinary();
82
110
  }
83
111
 
84
112
  /** Cross-platform quit: osascript on mac, SIGTERM pids elsewhere. */
85
113
  function quitGrokBot() {
86
114
  try {
87
115
  if (process.platform === 'darwin') {
88
- quitGrokBot();
89
- } else {
90
- for (const pid of grokBotPids()) {
91
- try { process.kill(Number(pid), 'SIGTERM'); } catch { /* gone */ }
92
- }
116
+ try {
117
+ execSync('osascript -e \'tell application "Grok Bot" to quit\'', {
118
+ timeout: 8000,
119
+ stdio: 'ignore',
120
+ });
121
+ } catch { /* not running */ }
122
+ }
123
+ for (const pid of grokBotPids()) {
124
+ try { process.kill(Number(pid), 'SIGTERM'); } catch { /* gone */ }
93
125
  }
94
126
  } catch { /* ok */ }
95
127
  }
@@ -166,29 +198,56 @@ ${body}
166
198
  `;
167
199
  }
168
200
 
169
- const GROK_BOT_BIN = grokBotBinary();
201
+ function grokBotImageNames(bin = grokBotBinary()) {
202
+ const names = new Set(['Grok Bot.exe', 'Grok_Bot.exe', 'grokbot.exe']);
203
+ const base = String(bin || '').split(/[/\\]/).pop();
204
+ if (base) names.add(base);
205
+ return [...names];
206
+ }
207
+
208
+ function parseTasklistPids(out) {
209
+ const pids = [];
210
+ for (const line of String(out || '').split(/\r?\n/)) {
211
+ const cols = line.split('","');
212
+ if (cols.length < 2) continue;
213
+ const pid = cols[1].replace(/"/g, '').trim();
214
+ if (/^\d+$/.test(pid)) pids.push(pid);
215
+ }
216
+ return pids;
217
+ }
170
218
 
171
219
  /** pids whose command is the Grok Bot main binary (not grep itself). */
172
- export function grokBotPids(run = execSync) {
220
+ export function grokBotPids(run = execSync, platform = process.platform) {
221
+ const quiet = { encoding: 'utf8', shell: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] };
173
222
  try {
174
- return run(`pgrep -f ${JSON.stringify(GROK_BOT_BIN)}`, { encoding: 'utf8', shell: true })
223
+ if (platform === 'win32') {
224
+ const pids = [];
225
+ for (const name of grokBotImageNames()) {
226
+ try {
227
+ const out = run(`tasklist /FI "IMAGENAME eq ${name}" /FO CSV /NH`, quiet);
228
+ pids.push(...parseTasklistPids(out));
229
+ } catch { /* image not running */ }
230
+ }
231
+ return [...new Set(pids)];
232
+ }
233
+ return run(`pgrep -f ${JSON.stringify(grokBotBinary())}`, quiet)
175
234
  .trim().split('\n').map((s) => s.trim()).filter(Boolean);
176
235
  } catch {
177
236
  return [];
178
237
  }
179
238
  }
180
239
 
181
- function pidHasNeedle(pid, needle, run = execSync) {
240
+ function pidHasNeedle(pid, needle, run = execSync, platform = process.platform) {
182
241
  const n = Number(pid);
183
242
  if (!Number.isInteger(n) || n <= 0) return false;
184
- if (process.platform === 'linux') {
243
+ if (platform === 'linux') {
185
244
  try {
186
245
  // /proc/<pid>/environ is NUL-separated; flatten and test.
187
246
  const env = readFileSync(`/proc/${n}/environ`, 'utf8');
188
247
  if (env.replace(/\0/g, '\n').includes(needle)) return true;
189
248
  } catch { /* gone */ }
190
249
  }
191
- if (process.platform === 'win32') return false;
250
+ if (platform === 'win32') return false;
192
251
  for (const cmd of [`ps -Eww -p ${n} -o command=`, `ps eww -p ${n}`]) {
193
252
  try {
194
253
  if (run(cmd, { encoding: 'utf8', timeout: 2000 }).includes(needle)) return true;
@@ -197,13 +256,13 @@ function pidHasNeedle(pid, needle, run = execSync) {
197
256
  return false;
198
257
  }
199
258
 
200
- function somethingTalksToPort(port, run = execSync) {
259
+ function somethingTalksToPort(port, run = execSync, platform = process.platform) {
201
260
  try {
202
- if (process.platform === 'linux') {
261
+ if (platform === 'linux') {
203
262
  const out = run('ss -tnp', { encoding: 'utf8', timeout: 2000 });
204
263
  return out.includes(':' + Number(port)) && /grok|electron|bot/i.test(out);
205
264
  }
206
- if (process.platform === 'win32') {
265
+ if (platform === 'win32') {
207
266
  const out = run('netstat -ano', { encoding: 'utf8', timeout: 5000 });
208
267
  return new RegExp(':' + Number(port) + '[^0-9].*ESTABLISHED', 'i').test(out);
209
268
  }
@@ -217,13 +276,13 @@ function somethingTalksToPort(port, run = execSync) {
217
276
  }
218
277
 
219
278
  /** Is the running Grok Bot already pointed at our aiserver? */
220
- export function inspectGrokBotHijack(url, port = 8443, run = execSync, cdpPort = GROKBOT_CDP_PORT) {
221
- const pids = grokBotPids(run);
279
+ export function inspectGrokBotHijack(url, port = 8443, run = execSync, cdpPort = GROKBOT_CDP_PORT, platform = process.platform) {
280
+ const pids = grokBotPids(run, platform);
222
281
  const needle = `CURSOR_API_BASE_URL=${url}`;
223
- const envHit = pids.some((pid) => pidHasNeedle(pid, needle, run));
224
- const tcpHit = somethingTalksToPort(port, run);
282
+ const envHit = pids.some((pid) => pidHasNeedle(pid, needle, run, platform));
283
+ const tcpHit = somethingTalksToPort(port, run, platform);
225
284
  const cdpNeedle = `--remote-debugging-port=${Number(cdpPort)}`;
226
- const chipDebug = pids.some((pid) => pidHasNeedle(pid, cdpNeedle, run));
285
+ const chipDebug = pids.some((pid) => pidHasNeedle(pid, cdpNeedle, run, platform));
227
286
  return { running: pids.length > 0, pids, hijacked: envHit || tcpHit, chipDebug };
228
287
  }
229
288
 
@@ -265,6 +324,308 @@ export function grokBotQuitPlan({
265
324
  };
266
325
  }
267
326
 
327
+ export function botPidPath(home = homedir()) {
328
+ return join(home, '.openzoo', 'bot.pid');
329
+ }
330
+
331
+ export function isBotDaemonChild(env = process.env) {
332
+ return env.OPENZOO_BOT_DAEMON_CHILD === '1';
333
+ }
334
+
335
+ export function wantsBotDaemon(argv = [], env = process.env) {
336
+ if (isBotDaemonChild(env)) return false;
337
+ if (argv.includes('--once') || argv.includes('--web')) return false;
338
+ return argv.includes('--daemon') || argv.includes('-d') || env.OPENZOO_BOT_DAEMON === '1';
339
+ }
340
+
341
+ /**
342
+ * Env for the VENDOR Grok Bot Electron binary. Must NOT inherit
343
+ * ELECTRON_RUN_AS_NODE — that is how OpenZoo Bot runs `openzoo.js`, and
344
+ * passing it through makes Grok Bot start as Node with no window (measured
345
+ * on the Linux AppImage: pay banner prints, then nothing). Nested AppImage
346
+ * vars (APPIMAGE/APPDIR/LD_LIBRARY_PATH) make the child reuse our squashfs
347
+ * and die silently.
348
+ */
349
+ export function grokBotLaunchEnv(url, baseEnv = process.env) {
350
+ const env = { ...baseEnv };
351
+ for (const k of [
352
+ 'ELECTRON_RUN_AS_NODE',
353
+ 'ELECTRON_NO_ASAR',
354
+ 'APPIMAGE',
355
+ 'APPDIR',
356
+ 'ARGV0',
357
+ 'OWD',
358
+ 'APPIMAGE_EXTRACT_AND_RUN',
359
+ 'LD_LIBRARY_PATH',
360
+ 'LD_PRELOAD',
361
+ ]) delete env[k];
362
+ env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
363
+ env.CURSOR_API_BASE_URL = url;
364
+ env.SAND_BACKEND_URL = url;
365
+ env.SAND_HOST_GATEWAY_URL = url;
366
+ env.SAND_HOST_GATEWAY_TOKEN = 'openzoo';
367
+ env.SAND_HOST_GATEWAY_NETWORK_TOKEN = 'openzoo';
368
+ // Login page is websiteUrl/loginDeepControl (cursor.com by default). Point
369
+ // it at the hijack so "Log in with Cursor" never opens a real Cursor tab.
370
+ env.SAND_CURSOR_WEBSITE_URL = url;
371
+ env.CURSOR_WEBSITE_URL = url;
372
+ // SAND_HOST_GATEWAY_URL turns on .env-descriptor-account-bindings.json:
373
+ // the first Cursor account that used this machine claims it, and a fake
374
+ // openzoo JWT then gets refused-credentials-retained (i18n DKMHuY).
375
+ // asar createDesktopAccountAuthorizer honors this rebind flag.
376
+ env.SAND_DEV_ALLOW_ACCOUNT_REBIND = '1';
377
+ // asar waitForBrowserLogin always shell.openExternal(loginUrl) THEN polls.
378
+ // Fake poll already returns tokens — swallow the tab so 127.0.0.1:8443/loginDeep
379
+ // never pops. Linux Electron uses xdg-open from PATH (mac NSWorkspace ignores
380
+ // this; the loginDeep HTML still window.close()s).
381
+ const realPath = env.PATH || '/usr/bin:/bin:/usr/local/bin';
382
+ env.OPENZOO_REAL_PATH = realPath;
383
+ try {
384
+ const shim = ensureGrokBotOpenShim({
385
+ home: env.HOME || homedir(),
386
+ platform: process.platform,
387
+ });
388
+ env.PATH = `${shim.dir}${delimiter}${realPath}`;
389
+ env.BROWSER = shim.browser;
390
+ } catch { /* read-only home */ }
391
+ return env;
392
+ }
393
+
394
+ export function grokBotOpenShimDir(home = homedir()) {
395
+ return join(home, '.openzoo', 'bin');
396
+ }
397
+
398
+ /** POSIX shim: exit 0 on loginDeepControl, else exec the real command. */
399
+ export const GROKBOT_OPEN_SHIM_SH = `#!/bin/sh
400
+ url=""
401
+ for a in "$@"; do
402
+ case "$a" in
403
+ http://*|https://*|cursor://*) url="$a" ;;
404
+ esac
405
+ done
406
+ case "$url" in
407
+ *loginDeepControl*|*/loginDeep*) exit 0 ;;
408
+ esac
409
+ cmd=\`basename "$0"\`
410
+ rest_path=\${OPENZOO_REAL_PATH:-/usr/bin:/bin:/usr/local/bin}
411
+ old_ifs=$IFS
412
+ IFS=:
413
+ for d in $rest_path; do
414
+ if [ -n "$d" ] && [ -x "$d/$cmd" ]; then
415
+ [ "$d/$cmd" = "$0" ] && continue
416
+ IFS=$old_ifs
417
+ exec "$d/$cmd" "$@"
418
+ fi
419
+ done
420
+ IFS=$old_ifs
421
+ exit 0
422
+ `;
423
+
424
+ /**
425
+ * PATH shims so Grok Bot's shell.openExternal does not pop a browser tab
426
+ * for the fake login URL. Writes xdg-open + gio (Linux Electron) and open
427
+ * (in case macOS shells out; NSWorkspace still bypasses PATH).
428
+ */
429
+ export function ensureGrokBotOpenShim({
430
+ home = homedir(),
431
+ platform = process.platform,
432
+ } = {}) {
433
+ const dir = grokBotOpenShimDir(home);
434
+ mkdirSync(dir, { recursive: true });
435
+ const names = platform === 'win32' ? [] : platform === 'darwin' ? ['open'] : ['xdg-open', 'gio'];
436
+ for (const name of names) {
437
+ const p = join(dir, name);
438
+ writeFileSync(p, GROKBOT_OPEN_SHIM_SH, { encoding: 'utf8', mode: 0o755 });
439
+ try { chmodSync(p, 0o755); } catch { /* windows */ }
440
+ }
441
+ return {
442
+ dir,
443
+ browser: join(dir, names[0] || 'xdg-open'),
444
+ names,
445
+ };
446
+ }
447
+
448
+ /** asar kkn: sha256(namespace || 0x00 || value) hex. */
449
+ export function sandBindingKey(namespace, value) {
450
+ return createHash('sha256').update(String(namespace)).update('\0').update(String(value)).digest('hex');
451
+ }
452
+
453
+ /** asar descriptorKey — `new URL(url).toString()` adds the trailing slash. */
454
+ export function sandDescriptorKey(url) {
455
+ return sandBindingKey('sand-env-descriptor', new URL(url).toString());
456
+ }
457
+
458
+ /** asar accountKey. JWT sub / cursorAccountSlot is `openzoo-user`. */
459
+ export function sandAccountKey(slot = 'openzoo-user') {
460
+ return sandBindingKey('sand-account-slot', slot);
461
+ }
462
+
463
+ /**
464
+ * Electron userData candidates for vendor Grok Bot (productName "Grok Bot",
465
+ * package name "sand"). Bindings live at
466
+ * `<userData>/.env-descriptor-account-bindings.json`.
467
+ */
468
+ export function grokBotUserDataDirs(home = homedir(), env = process.env, platform = process.platform) {
469
+ const names = ['Grok Bot', 'grok-bot', 'GrokBot', 'sand'];
470
+ let root;
471
+ if (platform === 'darwin') root = join(home, 'Library', 'Application Support');
472
+ else if (platform === 'win32') root = env.APPDATA || join(home, 'AppData', 'Roaming');
473
+ else root = env.XDG_CONFIG_HOME || join(home, '.config');
474
+ return names.map((n) => join(root, n));
475
+ }
476
+
477
+ /**
478
+ * Pre-seed the env-descriptor binding for our hijack URL.
479
+ *
480
+ * asar authorize (packaged AppImage) is:
481
+ * existing === thisAccount → ok
482
+ * existing != null && !REBIND → refuse
483
+ * existing == null && !firstStartupClaim && hasExistingDurableData() → refuse
484
+ * else write binding
485
+ *
486
+ * Packaged builds force hasExistingDurableData() true (dev-box control plane
487
+ * is off). Mid-session "Log in with Cursor" is not firstStartupClaim. A new
488
+ * hijack URL therefore has existing==null and REBIND never runs — 1.3.10.
489
+ * Writing this file first makes existing==thisAccount (or existing!=null so
490
+ * REBIND can rewrite if the slot is not openzoo-user).
491
+ */
492
+ export function claimGrokBotMachine(url, {
493
+ home = homedir(),
494
+ env = process.env,
495
+ platform = process.platform,
496
+ accountSlot = 'openzoo-user',
497
+ } = {}) {
498
+ const descriptorKey = sandDescriptorKey(url);
499
+ const accountKey = sandAccountKey(accountSlot);
500
+ const written = [];
501
+ for (const dir of grokBotUserDataDirs(home, env, platform)) {
502
+ mkdirSync(dir, { recursive: true });
503
+ const p = join(dir, '.env-descriptor-account-bindings.json');
504
+ let bindings = {};
505
+ try {
506
+ const prev = JSON.parse(readFileSync(p, 'utf8'));
507
+ if (prev && typeof prev.bindings === 'object' && prev.bindings) bindings = { ...prev.bindings };
508
+ } catch { /* missing or junk */ }
509
+ bindings[descriptorKey] = accountKey;
510
+ writeFileSync(p, JSON.stringify({ version: 1, bindings }), { encoding: 'utf8', mode: 0o600 });
511
+ written.push(p);
512
+ }
513
+ return { descriptorKey, accountKey, written };
514
+ }
515
+
516
+ function plaintextSecret(value) {
517
+ return `plaintext:v1:${Buffer.from(String(value), 'utf8').toString('base64')}`;
518
+ }
519
+
520
+ /**
521
+ * Write fake JWTs into Grok Bot's sand-secrets.json so asar getStatus is
522
+ * already logged-in and waitForBrowserLogin never runs.
523
+ * Windows shell.openExternal uses ShellExecute (not PATH), so the xdg-open
524
+ * shim cannot swallow loginDeepControl — this is the Windows fix.
525
+ */
526
+ export function seedGrokBotFakeLogin({
527
+ home = homedir(),
528
+ env = process.env,
529
+ platform = process.platform,
530
+ } = {}) {
531
+ const tok = fakeCursorTokens();
532
+ const patch = {
533
+ 'cursor-access-token': plaintextSecret(tok.accessToken),
534
+ 'cursor-refresh-token': plaintextSecret(tok.refreshToken),
535
+ };
536
+ const written = [];
537
+ for (const dir of grokBotUserDataDirs(home, env, platform)) {
538
+ mkdirSync(dir, { recursive: true });
539
+ const p = join(dir, 'sand-secrets.json');
540
+ let prev = {};
541
+ try {
542
+ const j = JSON.parse(readFileSync(p, 'utf8'));
543
+ if (j && typeof j === 'object') prev = j;
544
+ } catch { /* missing */ }
545
+ writeFileSync(p, JSON.stringify({ ...prev, ...patch }, null, 2), { encoding: 'utf8', mode: 0o600 });
546
+ written.push(p);
547
+ }
548
+ return { written, authId: tok.authId };
549
+ }
550
+
551
+ /**
552
+ * Turn OFF the local-tool approval prompt so exec/list_dir actually run.
553
+ *
554
+ * `localUseRefusal` in dist/local-exec-daemon/main.cjs only honours the
555
+ * frame's `authorizedByStanding` when the daemon is `serverAuthoritative`;
556
+ * ours is not, so it falls through to the user's own setting and answers
557
+ * "That action was not approved on the user's computer, so nothing ran."
558
+ * The setting is `localToolPermission` in `<userData>/settings.json`,
559
+ * default "ask", and `localToolPermissionCeiling` CAPS it
560
+ * (resolveSandLocalToolPermission takes the lower of {never:0,ask:1,always:2})
561
+ * — so raising the choice without also clearing a "never"/"ask" ceiling
562
+ * changes nothing.
563
+ */
564
+ export function seedGrokBotLocalToolPermission({
565
+ home = homedir(),
566
+ env = process.env,
567
+ platform = process.platform,
568
+ permission = 'always',
569
+ } = {}) {
570
+ const written = [];
571
+ for (const dir of grokBotUserDataDirs(home, env, platform)) {
572
+ mkdirSync(dir, { recursive: true });
573
+ const p = join(dir, 'settings.json');
574
+ let prev = {};
575
+ try {
576
+ const j = JSON.parse(readFileSync(p, 'utf8'));
577
+ if (j && typeof j === 'object') prev = j;
578
+ } catch { /* missing or junk — the app writes it on first settings change */ }
579
+ const next = { ...prev, localToolPermission: permission };
580
+ // A ceiling of "ask"/"never" would silently clamp us straight back.
581
+ if (permission === 'always') delete next.localToolPermissionCeiling;
582
+ // The app writes this file with JSON.stringify(e, null, 2).
583
+ writeFileSync(p, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
584
+ written.push(p);
585
+ }
586
+ return { written, permission };
587
+ }
588
+
589
+ export function daemonizeBot(argv, {
590
+ execPath = process.execPath,
591
+ script = process.argv[1],
592
+ env = process.env,
593
+ logFile = join(homedir(), '.openzoo', 'bot.log'),
594
+ } = {}) {
595
+ const rest = argv.filter((a) => a !== '--daemon' && a !== '-d');
596
+ const entry = script || fileURLToPath(new URL('../bin/openzoo.js', import.meta.url));
597
+ mkdirSync(dirname(logFile), { recursive: true });
598
+ const fd = openSync(logFile, 'a');
599
+ const childEnv = { ...env, OPENZOO_BOT_DAEMON_CHILD: '1' };
600
+ if (process.versions.electron) childEnv.ELECTRON_RUN_AS_NODE = '1';
601
+ const child = spawn(execPath, [entry, 'bot', ...rest], {
602
+ detached: true,
603
+ stdio: ['ignore', fd, fd],
604
+ env: childEnv,
605
+ windowsHide: true,
606
+ });
607
+ child.unref();
608
+ try { closeSync(fd); } catch { /* fd is inherited */ }
609
+ const pidPath = botPidPath();
610
+ mkdirSync(dirname(pidPath), { recursive: true });
611
+ writeFileSync(pidPath, String(child.pid));
612
+ return { pid: child.pid, logFile, pidPath };
613
+ }
614
+
615
+ export function stopBotDaemon({ log = (m) => console.error(m) } = {}) {
616
+ const pidPath = botPidPath();
617
+ let pid = 0;
618
+ try { pid = Number(readFileSync(pidPath, 'utf8').trim()); } catch { /* none */ }
619
+ if (!Number.isInteger(pid) || pid <= 0) {
620
+ log('openzoo: no bot daemon pidfile');
621
+ return false;
622
+ }
623
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
624
+ try { unlinkSync(pidPath); } catch { /* ok */ }
625
+ log(`openzoo: stopped daemon pid ${pid}`);
626
+ return true;
627
+ }
628
+
268
629
  /**
269
630
  * `openzoo bot` — Grok Bot.app on the zoo, no sudo, no /etc/hosts.
270
631
  *
@@ -283,13 +644,36 @@ export async function runBot(argv = []) {
283
644
  const { runGrokBotWeb } = await import('./grokbotweb.js');
284
645
  return runGrokBotWeb(argv);
285
646
  }
647
+ if (argv.includes('--stop')) {
648
+ stopBotDaemon();
649
+ return;
650
+ }
651
+ if (wantsBotDaemon(argv)) {
652
+ const d = daemonizeBot(argv);
653
+ console.error(`openzoo: daemon pid ${d.pid} — log ${d.logFile}`);
654
+ return;
655
+ }
286
656
  const port = 8443;
287
657
  const url = `https://127.0.0.1:${port}`;
288
- const bin = GROK_BOT_BIN;
658
+ let bin = grokBotBinary();
659
+ if (!existsSync(bin) && !argv.includes('--no-fetch') && process.env.GROKBOT_NO_FETCH !== '1') {
660
+ console.error('openzoo: Grok Bot not installed — fetching the multiarch build');
661
+ try {
662
+ const { ensureGrokBot } = await import('./grokbotFetch.js');
663
+ bin = await ensureGrokBot({
664
+ existing: bin,
665
+ log: (m) => console.error(m),
666
+ });
667
+ if (bin) process.env.GROKBOT_BIN = bin;
668
+ } catch (e) {
669
+ console.error(`openzoo: fetch failed (${e.message})`);
670
+ }
671
+ }
289
672
  if (!existsSync(bin)) {
290
673
  console.error(`openzoo: Grok Bot binary not found — looked for ${bin}`);
291
674
  console.error(' set GROKBOT_BIN=/path/to/Grok_Bot_0.36.0_linux-x64.AppImage');
292
675
  console.error(' or drop the AppImage in ~/Applications, ~/.local/bin, /opt, ~/Downloads');
676
+ console.error(' or run again on a network so openzoo can fetch grokbot-v* from GitHub');
293
677
  process.exit(1);
294
678
  }
295
679
 
@@ -336,7 +720,7 @@ export async function runBot(argv = []) {
336
720
  const stale = killListen(port);
337
721
  if (stale.length) console.error(`openzoo: killed stale hijack on :${port} pids=${stale.join(',')}`);
338
722
  try {
339
- startCursorBackend({ port, models, log });
723
+ await startCursorBackend({ port, models, log });
340
724
  } catch (e) {
341
725
  console.error('openzoo: 8443 bind failed after kill (', e.message, ')');
342
726
  throw e;
@@ -377,30 +761,56 @@ export async function runBot(argv = []) {
377
761
  await new Promise((r) => setTimeout(r, 1500));
378
762
  }
379
763
 
764
+ try {
765
+ const claim = claimGrokBotMachine(url);
766
+ console.error(`openzoo: machine-claim openzoo-user → ${claim.written.length} Grok Bot userData dir(s)`);
767
+ } catch (e) {
768
+ console.error(`openzoo: machine-claim failed (${e.message}) — login may still hit DKMHuY`);
769
+ }
770
+ try {
771
+ const seed = seedGrokBotFakeLogin();
772
+ console.error(`openzoo: fake login seeded → ${seed.written.length} sand-secrets.json`);
773
+ } catch (e) {
774
+ console.error(`openzoo: fake login seed failed (${e.message})`);
775
+ }
776
+ try {
777
+ const perm = seedGrokBotLocalToolPermission();
778
+ console.error(`openzoo: local-tool permission=always → ${perm.written.length} settings.json`);
779
+ } catch (e) {
780
+ console.error(`openzoo: local-tool permission seed failed (${e.message}) — exec will say "not approved"`);
781
+ }
782
+
380
783
  const launchGrokBot = () => {
381
784
  console.error('openzoo: launching Grok Bot');
382
785
  console.error(` CURSOR_API_BASE_URL=${url}`);
383
- if (process.platform === 'linux' && /\.appimage$/i.test(bin)) {
786
+ if (process.platform === 'linux' && /\.appimage$/i.test(bin)) {
384
787
  try { chmodSync(bin, 0o755); } catch { /* read-only */ }
385
788
  }
386
- spawn(bin, grokBotChromiumArgs(), {
789
+ const env = grokBotLaunchEnv(url);
790
+ if (process.platform === 'linux' && /\.appimage$/i.test(bin)) {
791
+ env.APPIMAGE_EXTRACT_AND_RUN = '1';
792
+ }
793
+ console.error(` SAND_DEV_ALLOW_ACCOUNT_REBIND=${env.SAND_DEV_ALLOW_ACCOUNT_REBIND}`);
794
+ if (env.BROWSER && process.platform !== 'win32') {
795
+ console.error(' loginDeep tab swallowed (xdg-open/open shim)');
796
+ }
797
+ const child = spawn(bin, grokBotChromiumArgs(), {
387
798
  stdio: 'ignore',
388
799
  detached: true,
389
- env: {
390
- ...process.env,
391
- NODE_TLS_REJECT_UNAUTHORIZED: '0',
392
- CURSOR_API_BASE_URL: url,
393
- SAND_BACKEND_URL: url,
394
- // Helper daemon (local-exec-daemon/main.cjs) uses Node fetch, not Chromium.
395
- // Without these it dials the cached cursorvm 1337 URL / dies on our
396
- // self-signed cert and GET /local-exec/requests never arrives.
397
- SAND_HOST_GATEWAY_URL: url,
398
- SAND_HOST_GATEWAY_TOKEN: 'openzoo',
399
- SAND_HOST_GATEWAY_NETWORK_TOKEN: 'openzoo',
400
- },
401
- }).unref();
800
+ env,
801
+ });
802
+ child.on('error', (e) => console.error(`openzoo: Grok Bot spawn failed: ${e.message}`));
803
+ child.unref();
402
804
  };
403
- if (plan.spawn) launchGrokBot();
805
+ let needSpawn = plan.spawn;
806
+ if (process.platform === 'win32' && state.running && !plan.quit) {
807
+ // ShellExecute already opened Edge; restart so sand-secrets are read and
808
+ // loginDeepControl is not opened again.
809
+ try { quitGrokBot(); } catch { /* ok */ }
810
+ await new Promise((r) => setTimeout(r, 1500));
811
+ needSpawn = true;
812
+ }
813
+ if (needSpawn) launchGrokBot();
404
814
  else console.error('openzoo: Grok Bot already hijacked — not spawning another copy');
405
815
 
406
816
  injectSpendChipInBackground({
@@ -513,7 +923,7 @@ export async function setupGrokBot(argv = []) {
513
923
  console.error(' sudo will ask for your password (hosts file + :443).');
514
924
  if (!isBlocked()) blockBackend();
515
925
 
516
- startCursorBackend({ port, log: (m) => console.error(` backend: ${m}`) });
926
+ await startCursorBackend({ port, log: (m) => console.error(` backend: ${m}`) });
517
927
 
518
928
  const restore = () => {
519
929
  try { unblockBackend(); console.error('openzoo: /etc/hosts restored.'); } catch { /* best effort */ }
@@ -552,7 +962,7 @@ export async function setupGrokBot(argv = []) {
552
962
  // LOUD BY DEFAULT. The previous run failed silently — "listening" and then
553
963
  // nothing — and silence looked identical to success. Every arriving method
554
964
  // is printed so "did it connect at all" is answerable at a glance.
555
- startCursorBackend({ port, log: (m) => console.error(` backend: ${m}`) });
965
+ await startCursorBackend({ port, log: (m) => console.error(` backend: ${m}`) });
556
966
  console.error('openzoo: BYOK probe — impersonating api2/api3/api4/repo42.cursor.sh on :' + port);
557
967
  console.error(' scoped to this launch only (no /etc/hosts, no sudo)');
558
968
  console.error(' WATCH: if StreamChat starts arriving at the proxy, inference moved.');