create-openclaw-bot 5.10.1 → 5.11.1

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.
@@ -388,9 +388,9 @@ async function getAvailableDrives() {
388
388
  return drives.length ? drives : ['C:\\', 'D:\\'];
389
389
  }
390
390
 
391
- function recommendedMode(osChoice) {
392
- if (osChoice === 'win' || osChoice === 'macos') return 'docker';
393
- return 'native';
391
+ // Docker is the only supported deploy mode now (native was removed).
392
+ function recommendedMode() {
393
+ return 'docker';
394
394
  }
395
395
 
396
396
  function commandExists(cmd, args = ['--version']) {
@@ -438,27 +438,6 @@ function run(cmd, args, opts = {}) {
438
438
  });
439
439
  }
440
440
 
441
- function startDetached(cmd, args, opts = {}) {
442
- sendLog(`$ ${cmd} ${args.join(' ')} &`);
443
- const shell = process.platform === 'win32';
444
- const rawBin = resolveBinPath(cmd);
445
- const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
446
- const child = spawn(bin, args, {
447
- cwd: opts.cwd,
448
- shell,
449
- detached: true,
450
- stdio: 'ignore',
451
- windowsHide: opts.windowsHide ?? true,
452
- env: { ...process.env, ...(opts.env || {}) },
453
- });
454
- child.on('error', (err) => {
455
- sendLog(`[error] Failed to start background command "${cmd}": ${err.message}`);
456
- console.error(`Failed to start background command "${cmd}":`, err);
457
- });
458
- child.unref();
459
- return child.pid;
460
- }
461
-
462
441
  async function getCurrentRuntimeVersions() {
463
442
  const [openclaw, nineRouter, node] = await Promise.all([
464
443
  commandExists('openclaw', ['--version']),
@@ -605,135 +584,6 @@ Timed out after ${opts.timeout}ms`.trim() : stderr });
605
584
  });
606
585
  }
607
586
 
608
- // ── Native process supervision (auto-restart) ───────────────────────────────
609
- // Docker containers get `restart: always` for free. For native installs we register the gateway
610
- // and 9router as OS services so they survive crashes and reboots, mirroring that guarantee:
611
- // • macOS → per-user launchd LaunchAgent (KeepAlive + RunAtLoad)
612
- // • Linux → systemd unit (system unit when root, else --user) with Restart=always
613
- // • Windows / anything else → plain detached process (no supervision; unchanged behaviour)
614
- // Every step is best-effort: any failure falls back to startDetached, so a native install can
615
- // never end up worse off than before this feature existed.
616
- function escapeXml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
617
- function nativeServiceId(projectDir) { return slugify(basename(projectDir || 'openclaw')) || 'bot'; }
618
- function nativeServiceLabels(projectDir) {
619
- const id = nativeServiceId(projectDir);
620
- return {
621
- '9router': { launchd: `com.openclaw.9router.${id}`, systemd: `openclaw-9router-${id}.service` },
622
- sync: { launchd: `com.openclaw.sync.${id}`, systemd: `openclaw-sync-${id}.service` },
623
- gateway: { launchd: `com.openclaw.gateway.${id}`, systemd: `openclaw-gateway-${id}.service` },
624
- };
625
- }
626
- async function resolveAbsoluteBin(cmd) {
627
- try {
628
- const out = await runCapture(process.platform === 'win32' ? 'where' : 'which', [cmd], { shell: false, timeout: 5000 });
629
- const first = String(out.stdout || '').split(/\r?\n/).map((s) => s.trim()).find(Boolean);
630
- if (out.code === 0 && first) return first;
631
- } catch {}
632
- return resolveBinPath(cmd);
633
- }
634
- function systemdUserUnitDir() { return join(getRealHomedir(), '.config', 'systemd', 'user'); }
635
- function isRootUser() { return typeof process.getuid === 'function' && process.getuid() === 0; }
636
-
637
- /**
638
- * Start one native process under OS supervision (auto-restart). Falls back to a plain detached
639
- * process on Windows/unknown platforms or if the service tooling errors. Returns the method used:
640
- * 'launchd' | 'systemd' | 'detached'.
641
- */
642
- async function startNativeService({ projectDir, name, cmd, args, desc, env }) {
643
- const labels = nativeServiceLabels(projectDir)[name];
644
- const logDir = join(projectDir, '.openclaw', 'logs');
645
- await fsp.mkdir(logDir, { recursive: true }).catch(() => {});
646
- const serviceEnv = { PATH: process.env.PATH || '', HOME: getRealHomedir(), ...env };
647
- try {
648
- const absBin = await resolveAbsoluteBin(cmd);
649
- if (process.platform === 'darwin') {
650
- const agentsDir = join(getRealHomedir(), 'Library', 'LaunchAgents');
651
- await fsp.mkdir(agentsDir, { recursive: true });
652
- const progArgs = [absBin, ...args].map((a) => ` <string>${escapeXml(a)}</string>`).join('\n');
653
- const envDict = Object.entries(serviceEnv).map(([k, v]) => ` <key>${escapeXml(k)}</key><string>${escapeXml(String(v))}</string>`).join('\n');
654
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
655
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
656
- <plist version="1.0"><dict>
657
- <key>Label</key><string>${labels.launchd}</string>
658
- <key>ProgramArguments</key><array>
659
- ${progArgs}
660
- </array>
661
- <key>WorkingDirectory</key><string>${escapeXml(projectDir)}</string>
662
- <key>EnvironmentVariables</key><dict>
663
- ${envDict}
664
- </dict>
665
- <key>RunAtLoad</key><true/>
666
- <key>KeepAlive</key><true/>
667
- <key>StandardOutPath</key><string>${escapeXml(join(logDir, name + '.out.log'))}</string>
668
- <key>StandardErrorPath</key><string>${escapeXml(join(logDir, name + '.err.log'))}</string>
669
- </dict></plist>`;
670
- const plistPath = join(agentsDir, `${labels.launchd}.plist`);
671
- await fsp.writeFile(plistPath, plist, 'utf8');
672
- await run('launchctl', ['unload', plistPath], {}).catch(() => {});
673
- await run('launchctl', ['load', '-w', plistPath], {});
674
- sendLog(`[native] launchd service ${labels.launchd} loaded (auto-restart)`);
675
- return 'launchd';
676
- }
677
- if (process.platform === 'linux') {
678
- const root = isRootUser();
679
- const unitDir = root ? '/etc/systemd/system' : systemdUserUnitDir();
680
- await fsp.mkdir(unitDir, { recursive: true });
681
- const sc = (a) => run('systemctl', root ? a : ['--user', ...a], {});
682
- const envLines = Object.entries(serviceEnv).map(([k, v]) => `Environment="${k}=${String(v).replace(/\n/g, ' ')}"`).join('\n');
683
- const execStart = [absBin, ...args].map((a) => (/[\s"']/.test(a) ? JSON.stringify(a) : a)).join(' ');
684
- const unit = `[Unit]
685
- Description=${desc}
686
- After=network-online.target
687
- Wants=network-online.target
688
-
689
- [Service]
690
- Type=simple
691
- WorkingDirectory=${projectDir}
692
- ${envLines}
693
- ExecStart=${execStart}
694
- Restart=always
695
- RestartSec=3
696
-
697
- [Install]
698
- WantedBy=${root ? 'multi-user.target' : 'default.target'}
699
- `;
700
- await fsp.writeFile(join(unitDir, labels.systemd), unit, 'utf8');
701
- await sc(['daemon-reload']);
702
- await sc(['enable', '--now', labels.systemd]);
703
- sendLog(`[native] systemd ${root ? 'system' : 'user'} service ${labels.systemd} enabled (Restart=always)`);
704
- return 'systemd';
705
- }
706
- } catch (e) {
707
- sendLog(`[native] ${name} service setup failed (${e.message}); falling back to detached process`);
708
- }
709
- const pid = startDetached(cmd, args, { cwd: projectDir, env });
710
- if (name === 'gateway') state.botPid = pid;
711
- return 'detached';
712
- }
713
-
714
- /** Remove any launchd/systemd services registered for a native project (used on delete). */
715
- async function removeNativeAutostart(projectDir) {
716
- const labels = nativeServiceLabels(projectDir);
717
- try {
718
- if (process.platform === 'darwin') {
719
- const agentsDir = join(getRealHomedir(), 'Library', 'LaunchAgents');
720
- for (const k of ['9router', 'sync', 'gateway']) {
721
- const p = join(agentsDir, `${labels[k].launchd}.plist`);
722
- if (existsSync(p)) { await run('launchctl', ['unload', p], {}).catch(() => {}); await fsp.unlink(p).catch(() => {}); }
723
- }
724
- } else if (process.platform === 'linux') {
725
- const root = isRootUser();
726
- const unitDir = root ? '/etc/systemd/system' : systemdUserUnitDir();
727
- const sc = (a) => run('systemctl', root ? a : ['--user', ...a], {}).catch(() => {});
728
- let changed = false;
729
- for (const k of ['9router', 'sync', 'gateway']) {
730
- const u = join(unitDir, labels[k].systemd);
731
- if (existsSync(u)) { await sc(['disable', '--now', labels[k].systemd]); await fsp.unlink(u).catch(() => {}); changed = true; }
732
- }
733
- if (changed) await sc(['daemon-reload']);
734
- }
735
- } catch (e) { sendLog(`[native] autostart teardown skipped: ${e.message}`); }
736
- }
737
587
 
738
588
  function safeJoin(root, name) {
739
589
  const clean = normalize(String(name || '')).replace(/^([/\\])+/, '');
@@ -2079,104 +1929,7 @@ try{
2079
1929
  return { message: 'Generating Zalo QR. The image will appear automatically.' };
2080
1930
  }
2081
1931
 
2082
- // ── Native (non-Docker) path ──────────────────────────────────────────────
2083
- // Same flow as Docker but on the host: run `openclaw channels login` directly,
2084
- // read the QR PNG straight off the host filesystem, then restart the native
2085
- // gateway so the saved credentials take effect.
2086
- const gatewayPort = state.gatewayPort || 18789;
2087
- const runtimeEnv = {
2088
- ...process.env,
2089
- OPENCLAW_HOME: join(projectDir, '.openclaw'),
2090
- OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
2091
- OPENCLAW_GATEWAY_PORT: String(gatewayPort),
2092
- OPENCLAW_PORT: String(gatewayPort),
2093
- };
2094
-
2095
- // The runtime writes the QR to a uid-scoped temp dir; probe the likely locations.
2096
- const uid = (typeof process.getuid === 'function') ? process.getuid() : '';
2097
- const qrNames = profile === 'default'
2098
- ? ['openclaw-zalouser-qr.png', 'openclaw-zalouser-qr-default.png', `openclaw-zalouser-qr-${profile}.png`]
2099
- : [`openclaw-zalouser-qr-${profile}.png`];
2100
- const qrDirs = [join(os.tmpdir(), 'openclaw'), '/tmp/openclaw', `/tmp/openclaw-${uid}`, '/tmp/openclaw-1000', join(os.tmpdir(), `openclaw-${uid}`)];
2101
- const nativeQrPaths = [...new Set(qrDirs.flatMap((d) => qrNames.map((n) => join(d, n))))];
2102
-
2103
- // Clean stale credentials & QR files so we only ever surface a fresh code.
2104
- const credFile = profile === 'default' ? 'credentials.json' : `credentials-${profile}.json`;
2105
- const credPath = join(projectDir, '.openclaw', 'credentials', 'zalouser', credFile);
2106
- try { await fsp.rm(credPath, { force: true }); } catch {}
2107
- for (const p of nativeQrPaths) { try { await fsp.rm(p, { force: true }); } catch {} }
2108
-
2109
- sendLog('[zalouser] Generating Zalo QR (native). The image will appear automatically.');
2110
-
2111
- const MAX_LOGIN_ATTEMPTS = 4;
2112
- const RETRY_DELAYS = [0, 8000, 15000, 20000];
2113
- let loginAttempt = 0;
2114
- let sent = false;
2115
- let restartAfterLogin = false;
2116
-
2117
- // Poll the host filesystem for the QR PNG across all retry attempts.
2118
- let tries = 0;
2119
- const poll = setInterval(() => {
2120
- if (sent || tries++ > 120) {
2121
- clearInterval(poll);
2122
- if (!sent) sendLog('[zalouser] QR not found yet. Try closing/reopening login, or run `openclaw channels login --channel zalouser --verbose` on the host.');
2123
- return;
2124
- }
2125
- for (const p of nativeQrPaths) {
2126
- try {
2127
- if (existsSync(p) && fs.statSync(p).size > 100) {
2128
- const b64 = fs.readFileSync(p).toString('base64');
2129
- if (b64.length > 100) {
2130
- sent = true;
2131
- clearInterval(poll);
2132
- sendLog(`[zalouser:qr] data:image/png;base64,${b64}`);
2133
- sendLog('[zalouser] Scan this QR with the Zalo app.');
2134
- break;
2135
- }
2136
- }
2137
- } catch {}
2138
- }
2139
- }, 1000);
2140
-
2141
- const loginArgs = ['channels', 'login', '--channel', 'zalouser', '--account', profile, '--verbose'];
2142
- const runLoginAttempt = () => {
2143
- loginAttempt++;
2144
- if (loginAttempt > 1) sendLog(`[zalouser] Retry attempt ${loginAttempt}/${MAX_LOGIN_ATTEMPTS}...`);
2145
- const child = spawn('openclaw', loginArgs, { cwd: projectDir, env: runtimeEnv, shell: false, windowsHide: true });
2146
- const handleLoginLine = (line) => {
2147
- sendLog(line);
2148
- if (/login successful|saved auth/i.test(line)) restartAfterLogin = true;
2149
- };
2150
- child.stdout.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLoginLine));
2151
- child.stderr.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLoginLine));
2152
- child.on('error', (err) => sendLog(`[zalouser] Login process failed: ${err.message}. Is the 'openclaw' CLI installed on PATH?`));
2153
- child.on('close', async (code) => {
2154
- sendLog(`[zalouser] Login process exited ${code}`);
2155
- if (code === 0 || restartAfterLogin || sent) {
2156
- if (restartAfterLogin) {
2157
- sendLog('[zalouser] Login saved. Restarting native gateway so Zalo User can receive messages...');
2158
- try {
2159
- await run('openclaw', ['gateway', 'stop'], { cwd: projectDir, env: runtimeEnv });
2160
- state.botPid = startDetached('openclaw', ['gateway', 'run'], { cwd: projectDir, env: runtimeEnv });
2161
- sendLog(`[zalouser] Native gateway restarted (pid=${state.botPid || 'unknown'}). Try sending a Zalo message now.`);
2162
- } catch (err) {
2163
- sendLog(`[zalouser] Gateway restart failed: ${err.message}. Restart it manually: openclaw gateway run`);
2164
- }
2165
- }
2166
- zaloLoginInFlight = false;
2167
- } else if (loginAttempt < MAX_LOGIN_ATTEMPTS && !sent) {
2168
- const delay = RETRY_DELAYS[loginAttempt] || 10000;
2169
- sendLog(`[zalouser] QR not ready yet. Waiting ${delay / 1000}s before retry...`);
2170
- setTimeout(runLoginAttempt, delay);
2171
- } else {
2172
- sendLog('[zalouser] All login attempts exhausted. Try clicking "Đăng nhập Zalo" again.');
2173
- zaloLoginInFlight = false;
2174
- }
2175
- });
2176
- };
2177
-
2178
- runLoginAttempt();
2179
- return { message: 'Generating Zalo QR (native). The image will appear automatically.' };
1932
+ throw httpError(400, 'Zalo login cần project Docker đang chạy (không tìm thấy docker-compose.yml).');
2180
1933
  }
2181
1934
 
2182
1935
  function getBotServiceName(projectDir) {
@@ -2392,16 +2145,7 @@ async function updateRuntime(target, projectDir) {
2392
2145
  probeCacheClear();
2393
2146
  return { ok: true, target, spec, mode: 'docker' };
2394
2147
  }
2395
- await run('npm', ['install', '-g', spec]);
2396
- if (isRouter) {
2397
- await run('openclaw', ['gateway', 'stop'], { cwd: projectDir }).catch(() => {});
2398
- await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
2399
- } else {
2400
- await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
2401
- }
2402
- await syncRuntimeState(projectDir, { full: true }).catch(() => {});
2403
- probeCacheClear();
2404
- return { ok: true, target, spec, mode: 'native' };
2148
+ throw httpError(400, 'Không có project Docker để cập nhật runtime.');
2405
2149
  }
2406
2150
 
2407
2151
  async function restartDockerBotContainer(projectDir = state.projectDir) {
@@ -2604,6 +2348,192 @@ async function writeCoreProject({ projectDir, osChoice, mode, gatewayPort = 1878
2604
2348
  }
2605
2349
  }
2606
2350
 
2351
+ // Locate a real Chrome/Chromium binary on the host (for the "grant Chrome to the bot" button).
2352
+ async function findChromeBinary() {
2353
+ if (process.platform === 'darwin') {
2354
+ for (const p of [
2355
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
2356
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
2357
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
2358
+ ]) if (existsSync(p)) return p;
2359
+ return '';
2360
+ }
2361
+ if (process.platform === 'win32') {
2362
+ for (const p of [
2363
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
2364
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
2365
+ join(process.env.LOCALAPPDATA || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
2366
+ ]) if (p && existsSync(p)) return p;
2367
+ return '';
2368
+ }
2369
+ for (const c of ['google-chrome', 'google-chrome-stable', 'chromium-browser', 'chromium']) {
2370
+ const r = await commandExists(c, ['--version']);
2371
+ if (r.ok) return c;
2372
+ }
2373
+ return '';
2374
+ }
2375
+
2376
+ // TCP relay for headless VPS: `ssh -R 9222:...` binds the VPS loopback only (sshd GatewayPorts
2377
+ // defaults to "no"), which the bot container cannot reach. This relay listens on the docker
2378
+ // bridge IP (host.docker.internal from inside the container) and pipes to the loopback tunnel.
2379
+ let _chromeRelayServer = null;
2380
+ async function getDockerBridgeIp() {
2381
+ try {
2382
+ const out = await runCapture('sh', ['-c', "ip -4 -o addr show docker0 | awk '{print $4}' | cut -d/ -f1"], { shell: false, timeout: 4000 });
2383
+ const ip = String(out.stdout || '').trim();
2384
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return ip;
2385
+ } catch {}
2386
+ return '172.17.0.1';
2387
+ }
2388
+ async function ensureChromeRelay() {
2389
+ if (_chromeRelayServer) return true;
2390
+ const bridgeIp = await getDockerBridgeIp();
2391
+ return new Promise((resolveP) => {
2392
+ const relay = net.createServer((client) => {
2393
+ const upstream = net.connect(9222, '127.0.0.1');
2394
+ client.pipe(upstream).pipe(client);
2395
+ client.on('error', () => upstream.destroy());
2396
+ upstream.on('error', () => client.destroy());
2397
+ });
2398
+ relay.once('error', () => resolveP(false)); // EADDRINUSE etc. → likely already relayed
2399
+ relay.listen(9222, bridgeIp, () => {
2400
+ _chromeRelayServer = relay;
2401
+ sendLog(`[chrome] Relay ${bridgeIp}:9222 → 127.0.0.1:9222 sẵn sàng (chờ SSH tunnel từ máy bạn).`);
2402
+ // Ubuntu VPSes usually run ufw with default-deny INPUT, which silently drops container→host
2403
+ // traffic to the relay. Open the port scoped to the PRIVATE bridge IP only (not reachable
2404
+ // from the internet). Best-effort; `ufw allow` skips duplicates on re-runs.
2405
+ run('sh', ['-c', `command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active" && ufw allow in to ${bridgeIp} port 9222 proto tcp comment "openclaw chrome-debug relay (docker bridge only)" || true`])
2406
+ .catch(() => sendLog('[chrome] Không thể tự mở firewall cho relay — nếu bot không thấy Chrome, chạy: ufw allow in to ' + bridgeIp + ' port 9222 proto tcp'));
2407
+ resolveP(true);
2408
+ });
2409
+ });
2410
+ }
2411
+
2412
+ // Launch real host Chrome in remote-debugging mode (port 9222) so the browser-automation plugin
2413
+ // can drive the user's actual Chrome (logged-in profile) instead of headless Chromium. The bot
2414
+ // reaches it via CDP (host.docker.internal:9222 from the container). Detached: keeps running after
2415
+ // this request. `--remote-allow-origins=*` is required by modern Chrome for cross-origin CDP.
2416
+ // On a headless VPS there is no Chrome to open here — instead we start the bridge relay and hand
2417
+ // back copy-paste commands so the user runs Chrome on THEIR machine + a reverse SSH tunnel.
2418
+ async function startChromeDebug() {
2419
+ if (isHeadlessServer()) {
2420
+ await ensureChromeRelay();
2421
+ const ip = (await getPublicIp().catch(() => '')) || '<IP-VPS>';
2422
+ const user = sshUserName();
2423
+ return {
2424
+ ok: true,
2425
+ headless: true,
2426
+ port: 9222,
2427
+ chromeCmdMac: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --user-data-dir="$HOME/.openclaw-chrome-debug" --remote-allow-origins='*'`,
2428
+ chromeCmdWin: `"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir=%TEMP%\\openclaw-chrome-debug --remote-allow-origins=*`,
2429
+ tunnelCmd: `ssh -N -R 9222:127.0.0.1:9222 ${user}@${ip}`,
2430
+ };
2431
+ }
2432
+ const bin = await findChromeBinary();
2433
+ if (!bin) {
2434
+ throw httpError(400, process.platform === 'linux'
2435
+ ? 'Không tìm thấy Chrome/Chromium trên máy. Cài google-chrome hoặc chromium rồi thử lại (không áp dụng cho VPS không có giao diện).'
2436
+ : 'Không tìm thấy Google Chrome. Hãy cài Chrome rồi thử lại.');
2437
+ }
2438
+ const port = 9222;
2439
+ const userDataDir = join(os.tmpdir(), 'openclaw-chrome-debug');
2440
+ const args = [
2441
+ `--remote-debugging-port=${port}`,
2442
+ `--user-data-dir=${userDataDir}`,
2443
+ '--remote-allow-origins=*',
2444
+ '--no-first-run',
2445
+ '--no-default-browser-check',
2446
+ ];
2447
+ const child = spawn(bin, args, { detached: true, stdio: 'ignore', windowsHide: false });
2448
+ child.on('error', (e) => sendLog(`[chrome] Không mở được Chrome debug: ${e.message}`));
2449
+ child.unref();
2450
+ sendLog(`[chrome] Đã mở Chrome debug ở cổng ${port} (${bin}). Bot sẽ ưu tiên dùng Chrome này.`);
2451
+ return { ok: true, port, browser: bin, userDataDir };
2452
+ }
2453
+
2454
+ // Poll until the Docker daemon responds (or timeout). Returns true if ready.
2455
+ async function waitForDockerDaemon(timeoutMs) {
2456
+ const deadline = Date.now() + timeoutMs;
2457
+ for (;;) {
2458
+ const ok = await commandExists('docker', ['version', '--format', '{{.Server.Version}}']);
2459
+ if (ok.ok) return true;
2460
+ if (Date.now() >= deadline) return false;
2461
+ await new Promise((r) => setTimeout(r, 3000));
2462
+ }
2463
+ }
2464
+
2465
+ // Ensure Docker (engine + compose) is available before a docker-mode install, auto-installing the
2466
+ // latest version appropriate for the host OS when it is missing:
2467
+ // • Linux → Docker's official convenience script (get.docker.com; auto-detects the distro) + start daemon
2468
+ // • macOS → Docker Desktop via Homebrew cask, then launch it
2469
+ // • Windows → Docker Desktop via winget (fallback Chocolatey), then launch it
2470
+ // macOS/Windows Docker Desktop needs a GUI/WSL startup (and sometimes a reboot), so we install +
2471
+ // launch + wait, and give a clear next-step if the daemon still isn't up when we time out.
2472
+ async function ensureDockerInstalled(osChoice) {
2473
+ if (await waitForDockerDaemon(0)) return; // already running
2474
+ const cliOk = await commandExists('docker', ['--version']);
2475
+
2476
+ if (process.platform === 'linux') {
2477
+ const root = typeof process.getuid === 'function' && process.getuid() === 0;
2478
+ const sudo = root ? '' : 'sudo ';
2479
+ if (!cliOk.ok) {
2480
+ sendLog('[docker] Chưa có Docker — đang tự cài Docker Engine mới nhất qua script chính thức get.docker.com (1–3 phút)...');
2481
+ await run('sh', ['-c', `curl -fsSL https://get.docker.com -o /tmp/get-docker.sh && ${sudo}sh /tmp/get-docker.sh`]);
2482
+ if (!root) await run('sh', ['-c', 'sudo usermod -aG docker "$USER" || true']).catch(() => {});
2483
+ }
2484
+ sendLog('[docker] Bật & khởi động Docker daemon...');
2485
+ await run('sh', ['-c', `${sudo}systemctl enable --now docker`]).catch(() => {});
2486
+ if (!(await waitForDockerDaemon(20000))) {
2487
+ throw httpError(500, 'Đã cài Docker nhưng daemon chưa chạy. Hãy chạy `systemctl start docker` (hoặc đăng nhập lại nếu vừa thêm vào nhóm docker) rồi cài lại.');
2488
+ }
2489
+ sendLog('[docker] Docker đã sẵn sàng.');
2490
+ return;
2491
+ }
2492
+
2493
+ if (process.platform === 'darwin') {
2494
+ if (!cliOk.ok) {
2495
+ const brew = await commandExists('brew', ['--version']);
2496
+ if (!brew.ok) {
2497
+ throw httpError(400, 'macOS: cần Homebrew để tự cài Docker Desktop. Cài Homebrew tại https://brew.sh (hoặc cài Docker Desktop thủ công) rồi cài lại.');
2498
+ }
2499
+ sendLog('[docker] macOS: đang cài Docker Desktop mới nhất qua Homebrew (brew install --cask docker)...');
2500
+ await run('brew', ['install', '--cask', 'docker']);
2501
+ }
2502
+ sendLog('[docker] Mở Docker Desktop và chờ daemon khởi động...');
2503
+ await run('open', ['-a', 'Docker']).catch(() => {});
2504
+ if (!(await waitForDockerDaemon(120000))) {
2505
+ throw httpError(500, 'Đã cài Docker Desktop — hãy mở Docker Desktop, hoàn tất cấp quyền lần đầu, đợi biểu tượng cá voi báo "running" rồi cài lại.');
2506
+ }
2507
+ sendLog('[docker] Docker đã sẵn sàng.');
2508
+ return;
2509
+ }
2510
+
2511
+ if (process.platform === 'win32') {
2512
+ if (!cliOk.ok) {
2513
+ const winget = await commandExists('winget', ['--version']);
2514
+ const choco = await commandExists('choco', ['--version']);
2515
+ if (winget.ok) {
2516
+ sendLog('[docker] Windows: đang cài Docker Desktop mới nhất qua winget...');
2517
+ await run('winget', ['install', '-e', '--id', 'Docker.DockerDesktop', '--accept-source-agreements', '--accept-package-agreements']);
2518
+ } else if (choco.ok) {
2519
+ sendLog('[docker] Windows: đang cài Docker Desktop qua Chocolatey...');
2520
+ await run('choco', ['install', 'docker-desktop', '-y']);
2521
+ } else {
2522
+ throw httpError(400, 'Windows: cần winget hoặc Chocolatey để tự cài Docker Desktop (hoặc cài thủ công tại https://www.docker.com). Cài xong rồi thử lại.');
2523
+ }
2524
+ }
2525
+ sendLog('[docker] Mở Docker Desktop và chờ daemon khởi động...');
2526
+ await run('cmd', ['/c', 'start', '', '%ProgramFiles%\\Docker\\Docker\\Docker Desktop.exe']).catch(() => {});
2527
+ if (!(await waitForDockerDaemon(120000))) {
2528
+ throw httpError(500, 'Đã cài Docker Desktop — Windows có thể cần bật WSL2 và khởi động lại máy. Hãy mở Docker Desktop, đợi "running" (hoặc reboot nếu được yêu cầu) rồi cài lại.');
2529
+ }
2530
+ sendLog('[docker] Docker đã sẵn sàng.');
2531
+ return;
2532
+ }
2533
+
2534
+ throw httpError(400, 'Hệ điều hành không được hỗ trợ tự cài Docker. Hãy cài Docker thủ công rồi thử lại.');
2535
+ }
2536
+
2607
2537
  async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, routerPort = 20128 }) {
2608
2538
  state.installing = true;
2609
2539
  state.installed = false;
@@ -2615,6 +2545,9 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
2615
2545
  try {
2616
2546
  sendLog('OpenClaw local installer started');
2617
2547
  sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
2548
+ // Make sure Docker is present (auto-install on Linux/VPS) before doing any work — fail fast
2549
+ // with a clear message rather than deep inside `docker compose up`.
2550
+ await ensureDockerInstalled(osChoice);
2618
2551
  await writeCoreProject({ projectDir, osChoice, mode, gatewayPort, routerPort });
2619
2552
  await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
2620
2553
  await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
@@ -2631,63 +2564,6 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
2631
2564
  await run('docker', ['compose', 'up', '-d', '--build'], { cwd: dockerDir });
2632
2565
  await applyResolved9RouterApiKey(projectDir).catch(() => {});
2633
2566
  await recreateDockerBot(projectDir).catch(() => {});
2634
- } else {
2635
- const nineRouterDataDir = join(projectDir, '.9router');
2636
- const nineRouterDbPath = join(nineRouterDataDir, 'db', 'data.sqlite');
2637
- const runtimeEnv = {
2638
- OPENCLAW_HOME: join(projectDir, '.openclaw'),
2639
- OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
2640
- DATA_DIR: nineRouterDataDir,
2641
- OPENCLAW_GATEWAY_PORT: String(gatewayPort),
2642
- OPENCLAW_PORT: String(gatewayPort),
2643
- // Parity with the docker runtime env (docker-gen.js) so the gateway and browser plugin
2644
- // behave the same natively: allow the private-network control-UI websocket and tell the
2645
- // browser plugin which host OS it is running on.
2646
- OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: '1',
2647
- OPENCLAW_SETUP_OS: osChoice || '',
2648
- OPENCLAW_BROWSER_HOST_OS: osChoice || '',
2649
- };
2650
- await run('openclaw', ['gateway', 'stop'], { cwd: projectDir, env: runtimeEnv }).catch(() => {});
2651
- // 9router binds loopback in native mode on purpose: openclaw runs on the SAME host and
2652
- // reaches it via localhost (see get9RouterBaseUrl), so there is no reason to expose the LLM
2653
- // proxy on 0.0.0.0 — even on a VPS that would be an open relay. Only the gateway is exposed.
2654
- // Start 9router first (auto-restart supervised), then sync, then the gateway — so the gateway
2655
- // boots after 9router is reachable and after the API key has been written to its config.
2656
- const routerMethod = await startNativeService({
2657
- projectDir, name: '9router', cmd: '9router', desc: `OpenClaw 9router (${nativeServiceId(projectDir)})`,
2658
- args: ['-n', '-l', '-H', '127.0.0.1', '-p', String(routerPort), '--skip-update'], env: runtimeEnv,
2659
- });
2660
- // Smart-route auto-sync: the docker sidecar runs a script that disables 9router's login
2661
- // requirement and builds the default `smart-route` combo from the active providers. Native
2662
- // mode skipped this, so the default model had no backing models. Reuse the exact docker
2663
- // script with the native DB path (passed via env to stay cross-platform / shell-safe).
2664
- try {
2665
- const artifacts = buildDockerArtifacts({ is9Router: true, osChoice, openClawNpmSpec: OPENCLAW_NPM_SPEC, gatewayPort, routerPort });
2666
- if (artifacts.syncScript) {
2667
- await fsp.mkdir(nineRouterDataDir, { recursive: true });
2668
- const syncPath = join(nineRouterDataDir, 'sync.js');
2669
- await fsp.writeFile(syncPath, artifacts.syncScript, 'utf8');
2670
- // Run the sync under supervision too (managed service) so it is torn down cleanly on
2671
- // delete instead of leaking an orphaned `node sync.js` process.
2672
- await startNativeService({
2673
- projectDir, name: 'sync', cmd: process.execPath, args: [syncPath],
2674
- desc: `OpenClaw 9router smart-route sync (${nativeServiceId(projectDir)})`,
2675
- env: { ...runtimeEnv, NINEROUTER_DB_PATH: nineRouterDbPath, PORT: String(routerPort) },
2676
- });
2677
- sendLog('[native] 9router smart-route sync started');
2678
- }
2679
- } catch (e) {
2680
- sendLog(`[native] smart-route sync setup skipped: ${e.message}`);
2681
- }
2682
- // Give 9router a moment to boot + sync to disable its login gate, then resolve its API key
2683
- // into openclaw.json BEFORE the gateway starts (so the gateway reads it on first boot).
2684
- await new Promise((r) => setTimeout(r, 8000));
2685
- await applyResolved9RouterApiKey(projectDir).catch(() => {});
2686
- const gatewayMethod = await startNativeService({
2687
- projectDir, name: 'gateway', cmd: 'openclaw', desc: `OpenClaw gateway (${nativeServiceId(projectDir)})`,
2688
- args: ['gateway', 'run'], env: runtimeEnv,
2689
- });
2690
- sendLog(`Native runtime started — 9router via ${routerMethod}, gateway via ${gatewayMethod}${state.botPid ? ` (pid=${state.botPid})` : ''}`);
2691
2567
  }
2692
2568
  state.installed = true;
2693
2569
  sendLog('✅ Install completed');
@@ -3092,10 +2968,6 @@ async function deleteProjectFolder(projectDir, rootProjectDir) {
3092
2968
  });
3093
2969
  // Sleep 2.5 seconds to let Windows file system release overlays/locks
3094
2970
  await new Promise((resolve) => setTimeout(resolve, 2500));
3095
- } else {
3096
- // Native project: stop & remove any launchd/systemd auto-restart services so they don't keep
3097
- // respawning a deleted bot. No-op when no services were registered.
3098
- await removeNativeAutostart(resolved);
3099
2971
  }
3100
2972
 
3101
2973
  try {
@@ -3984,6 +3856,9 @@ async function handler(req, res, rootProjectDir) {
3984
3856
  const projectDir = await resolveProjectDir(rootProjectDir, body);
3985
3857
  return json(res, await addBotMount(projectDir, body.hostPath, body.mountName));
3986
3858
  }
3859
+ if (url.pathname === '/api/browser/start-chrome-debug' && req.method === 'POST') {
3860
+ return json(res, await startChromeDebug());
3861
+ }
3987
3862
  if (url.pathname === '/api/setup/update' && req.method === 'POST') {
3988
3863
  const installerDir = resolve(__dirname, '../..');
3989
3864
  const isGit = existsSync(resolve(installerDir, '.git'));
@@ -4100,11 +3975,19 @@ async function handler(req, res, rootProjectDir) {
4100
3975
  return json(res, { name, content: await fsp.readFile(file, 'utf8') });
4101
3976
  }
4102
3977
  if (req.method === 'PUT') {
4103
- if (!name.endsWith('.md')) throw httpError(400, 'Only markdown files (.md) can be modified');
3978
+ // Allow the same text types the file tree marks editable (it exposes .json/.js/.yml/…,
3979
+ // not just .md — the old .md-only guard made "Save" silently fail on those files).
3980
+ const writableExt = new Set(['.md', '.txt', '.json', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.yml', '.yaml', '.env', '.sh', '.bat', '.ps1', '.html', '.css']);
3981
+ if (!writableExt.has(extname(name).toLowerCase())) throw httpError(400, `Loại file này không hỗ trợ sửa từ UI (${extname(name) || 'không có đuôi'})`);
4104
3982
  const body = await readJson(req);
4105
3983
  const projectDir = await resolveProjectDir(rootProjectDir, body);
4106
3984
  const file = safeJoin(projectDir, name);
4107
- await fsp.writeFile(file, String(body.content || ''), 'utf8');
3985
+ const content = String(body.content || '');
3986
+ // Don't let a typo brick openclaw.json & friends — reject invalid JSON with a clear error.
3987
+ if (extname(name).toLowerCase() === '.json') {
3988
+ try { JSON.parse(content); } catch (e) { throw httpError(400, `JSON không hợp lệ: ${e.message}`); }
3989
+ }
3990
+ await fsp.writeFile(file, content, 'utf8');
4108
3991
  return json(res, { ok: true });
4109
3992
  }
4110
3993
  }
@@ -4318,7 +4201,7 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
4318
4201
  printRemoteAccessHint(port).catch(() => {});
4319
4202
  }
4320
4203
 
4321
- export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloUserLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, removeNativeAutostart };
4204
+ export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloUserLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder };
4322
4205
 
4323
4206
 
4324
4207