create-openclaw-bot 5.16.9 → 5.17.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/README.md CHANGED
@@ -4,10 +4,10 @@
4
4
 
5
5
  ### One **Web UI** to scaffold, deploy & run free multi-bot AI assistants — zero terminal required
6
6
 
7
- *Run one command → open the dashboard → your bot is live. Windows · macOS · Linux · VPS — Docker-powered, auto-installed for you.*
7
+ *Run one command → open the dashboard → your bot is live. Windows, macOS, Linux, VPS — Docker-powered, auto-installed for you.*
8
8
 
9
9
  <p align="center">
10
- <a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.16.9-0EA5E9?style=for-the-badge" alt="Version 5.16.9" /></a>
10
+ <a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.17.0-0EA5E9?style=for-the-badge" alt="Version 5.17.0" /></a>
11
11
  <a href="https://github.com/tuanminhhole/openclaw-setup?tab=MIT-1-ov-file"><img src="https://img.shields.io/badge/LICENSE-MIT-success?style=for-the-badge" alt="MIT License" /></a>
12
12
  <a href="https://www.npmjs.com/package/create-openclaw-bot"><img src="https://img.shields.io/npm/v/create-openclaw-bot?style=for-the-badge&label=CLI&color=2563EB&logo=npm&logoColor=white" alt="NPM Version" /></a>
13
13
  <a href="https://github.com/tuanminhhole/openclaw-setup/stargazers"><img src="https://img.shields.io/github/stars/tuanminhhole/openclaw-setup?style=for-the-badge&color=eab308&logo=github&logoColor=white" alt="GitHub Stars" /></a>
@@ -37,7 +37,7 @@
37
37
 
38
38
  ---
39
39
 
40
- ## 🆕 What's New in v5.16.9
40
+ ## 🆕 What's New in v5.17.0
41
41
 
42
42
  - 🩹 **Native installs on OpenClaw 2026.8 are smooth from the first run** - this wave patches the remaining chain of issues measured on fresh customer machines: generated configs pass the new strict checks 100%, install steps run in the right order, and restarts/plugin installs no longer get blocked midway.
43
43
  - 🔑 **9Router login works on Docker**: the new 9Router blocks the default password for "remote" access - and inside Docker every request counts as remote, so 123456 could never log in. It works now (still change the password once you're in).
package/README.vi.md CHANGED
@@ -4,10 +4,10 @@
4
4
 
5
5
  ### Một **giao diện Web** để tạo, triển khai & vận hành trợ lý AI đa bot miễn phí — không cần gõ terminal
6
6
 
7
- *Chạy một lệnh mở dashboard bot lên sóng. Windows · macOS · Linux · VPS chạy trên Docker, tự cài giúp bạn.*
7
+ *Chạy một lệnh, mở dashboard, bot lên sóng. Windows, macOS, Linux, VPS. Bot chạy thẳng trên máy, không cần Docker.*
8
8
 
9
9
  <p align="center">
10
- <a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.16.9-0EA5E9?style=for-the-badge" alt="Version 5.16.9" /></a>
10
+ <a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.17.0-0EA5E9?style=for-the-badge" alt="Version 5.17.0" /></a>
11
11
  <a href="https://github.com/tuanminhhole/openclaw-setup?tab=MIT-1-ov-file"><img src="https://img.shields.io/badge/LICENSE-MIT-success?style=for-the-badge" alt="MIT License" /></a>
12
12
  <a href="https://www.npmjs.com/package/create-openclaw-bot"><img src="https://img.shields.io/npm/v/create-openclaw-bot?style=for-the-badge&label=CLI&color=2563EB&logo=npm&logoColor=white" alt="NPM Version" /></a>
13
13
  <a href="https://github.com/tuanminhhole/openclaw-setup/stargazers"><img src="https://img.shields.io/github/stars/tuanminhhole/openclaw-setup?style=for-the-badge&color=eab308&logo=github&logoColor=white" alt="GitHub Stars" /></a>
@@ -37,7 +37,7 @@
37
37
 
38
38
  ---
39
39
 
40
- ## 🆕 Có gì mới trong v5.16.9
40
+ ## 🆕 Có gì mới trong v5.17.0
41
41
 
42
42
  - 🩹 **Cài native trên OpenClaw 2026.8 mượt từ phát đầu** - đợt này vá nốt chuỗi lỗi đo được khi dựng máy khách mới: cấu hình sinh ra sạch 100% với bộ soát khắt khe của OpenClaw mới, các bước cài tự chạy đúng thứ tự, và mọi thao tác khởi động lại/cài plugin không còn bị chặn giữa chừng.
43
43
  - 🔑 **Đăng nhập 9Router hoạt động trên Docker**: bản 9Router mới chặn mật khẩu mặc định với truy cập "từ xa" - mà trong Docker thì mọi truy cập đều bị tính là từ xa, nên trước đây không cách nào vào được bằng 123456. Giờ đăng nhập bình thường (vào rồi vẫn nên đổi mật khẩu).
@@ -246,9 +246,10 @@ Bấm nút **Cập nhật** ở **góc trên bên phải giao diện Setup**. H
246
246
 
247
247
  ## 📋 Yêu cầu hệ thống
248
248
 
249
- - **Node.js 24 LTS** (bắt buộc) wizard Setup chạy bằng Node, nên cần cho **cả** chế độ Docker lẫn Native. [Tải Node.js](https://nodejs.org/).
249
+ - **Node.js 24 LTS** (bắt buộc). Cả trình cài đặt lẫn bot đều chạy bằng Node. [Tải Node.js](https://nodejs.org/).
250
+ Máy chưa có Node thì trình cài đặt tự cài giúp bạn.
250
251
  - **Git**: Đã cài đặt và có trong biến môi trường PATH.
251
- - **Docker Desktop** (khuyên dùng, để chạy bot): hỗ trợ Docker Compose V2. [Tải Docker](https://www.docker.com/products/docker-desktop/).
252
+ - **Không cần Docker nữa.** Từ bản 5.17.0 bot chạy thẳng trên máy.
252
253
 
253
254
  ---
254
255
 
@@ -258,9 +259,8 @@ Bấm nút **Cập nhật** ở **góc trên bên phải giao diện Setup**. H
258
259
 
259
260
  **1. Mở giao diện Setup** — chạy lệnh cài ở trên, dashboard sẽ mở trên trình duyệt.
260
261
 
261
- **2. Chọn Hệ điều hành & Chế độ chạy** cài [Node.js 24 LTS](https://nodejs.org/) trước (wizard chạy bằng Node cần cho **cả 2** chế độ). Rồi vào tab **Cài đặt**, chọn hệ điều hành chế độ chạy:
262
- - **Docker** (khuyên dùng) chạy cách ly **tạo được nhiều project/bot**. Cài thêm [Docker Desktop](https://www.docker.com/products/docker-desktop/).
263
- - **Native** — nhẹ hơn, chạy bot thẳng trên máy (không cần Docker).
262
+ **2. Chọn Hệ điều hành** rồi bấm tiếp. Chế độ chạy giờ chỉ còn **Native**, tức bot chạy thẳng trên máy bạn.
263
+ Ô Docker vẫn hiện nhưng đã khoá, bấm không được. do ghi ngay trên màn hình.
264
264
 
265
265
  **3. Nhập đường dẫn & tên project** — nhập đường dẫn thư mục và tên project (ví dụ tên: `bot`), rồi bấm **Cài đặt**. Ví dụ đường dẫn:
266
266
  - Windows: `D:\bot`
@@ -17,6 +17,7 @@ const { buildWorkspaceFileMap, buildCronjobSkillMd, buildInfographicGeneratorSki
17
17
  const { buildOpenclawJson, buildEnvFileContent, buildExecApprovalsJson, buildZaloConnectChannelConfig } = loadSharedModule('../setup/shared/bot-config-gen.js', '__openclawBotConfig');
18
18
  const { buildDockerArtifacts, contextDefaultsScript } = loadSharedModule('../setup/shared/docker-gen.js', '__openclawDockerGen');
19
19
  const { HOST_UI_PS1, HOST_UI_PS1_VERSION } = loadSharedModule('../setup/shared/host-ui-ps1.js', '__openclawHostUiPs1');
20
+ const { buildWindowsLaunchers, WINDOWS_DESKTOP_LAUNCHERS } = loadSharedModule('../setup/shared/windows-launcher-gen.js', '__openclawWindowsLaunchers');
20
21
  const { OPENCLAW_NPM_SPEC, NINE_ROUTER_NPM_SPEC, ZALO_CHANNEL_ID, ZALO_PLUGIN_ID, ZALO_CONNECT_VERSION, ZALO_CONNECT_PLUGIN_SPEC, build9RouterProviderConfig, get9RouterBaseUrl } = loadSharedModule('../setup/shared/common-gen.js', '__openclawCommon');
21
22
  const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
22
23
 
@@ -1213,7 +1214,12 @@ async function detectRuntime(projectDir) {
1213
1214
  routerPort,
1214
1215
  gatewayUrl: `http://127.0.0.1:${gatewayPort}`,
1215
1216
  routerUrl: `http://127.0.0.1:${routerPort}`,
1216
- mode: existsSync(join(projectDir || '', 'docker', 'openclaw', 'docker-compose.yml')) ? 'docker' : 'native',
1217
+ // Ask projectDeployMode, don't re-derive from the compose file: a project migrated to native
1218
+ // KEEPS its docker/ folder on purpose (that is the way back), so "compose file exists" stops
1219
+ // meaning "runs on docker" the moment migration lands. Getting this wrong made the dashboard
1220
+ // report OpenClaw and 9Router OFFLINE on a machine whose native gateway was answering 200 —
1221
+ // it was probing the stopped containers. Seen on win_kha, 10/09/2026.
1222
+ mode: projectDeployMode(projectDir),
1217
1223
  cliGatewayStatus,
1218
1224
  syncSource,
1219
1225
  };
@@ -2770,15 +2776,34 @@ async function computeZaloHealth(projectDir) {
2770
2776
  textStatus = `${r.stdout || ''}\n${r.stderr || ''}`;
2771
2777
  } catch {}
2772
2778
  }
2773
- // zalo-connect writes credentials under the project's .openclaw (OPENCLAW_HOME); fall
2774
- // back to the real home dir in case the plugin used os.homedir() instead.
2779
+ // Where the Zalo session file lives depends on the plugin version, and getting this wrong
2780
+ // makes a perfectly logged-in bot show as "chưa đăng nhập":
2781
+ // <=3.1.4 <home>/.openclaw/zalo-connect-credentials[-<account>].json
2782
+ // >=3.1.5 <state>/credentials/zalo-connect/credentials[-<account>].json
2783
+ // 3.1.5 MOVES the old file on first run and leaves a *.migrated stub behind, so a scan that
2784
+ // only knows the old shape finds nothing at all. Look in the new layout first, then fall
2785
+ // back, and normalise the new names to the old ones so the caller's lookup keeps working.
2786
+ // Measured on win_kha after the zalo-connect upgrade, 10/09/2026.
2775
2787
  const credRe = /^zalo-connect-credentials(?:-[^.]+)?\.json$/i;
2776
- for (const dir of [join(projectDir, '.openclaw'), join(os.homedir(), '.openclaw')]) {
2788
+ const newCredRe = /^credentials(?:-[^.]+)?\.json$/i;
2789
+ const toLegacyName = (n) => n.replace(/^credentials/, 'zalo-connect-credentials');
2790
+ for (const dir of [
2791
+ join(projectDir, '.openclaw', 'credentials', 'zalo-connect'),
2792
+ join(os.homedir(), '.openclaw', 'credentials', 'zalo-connect'),
2793
+ ]) {
2777
2794
  try {
2778
- const names = (await fsp.readdir(dir)).filter((n) => credRe.test(n));
2779
- if (names.length) { credentialNames = names; break; }
2795
+ const names = (await fsp.readdir(dir)).filter((n) => newCredRe.test(n));
2796
+ if (names.length) { credentialNames = names.map(toLegacyName); break; }
2780
2797
  } catch {}
2781
2798
  }
2799
+ if (!credentialNames || !credentialNames.length) {
2800
+ for (const dir of [join(projectDir, '.openclaw'), join(os.homedir(), '.openclaw')]) {
2801
+ try {
2802
+ const names = (await fsp.readdir(dir)).filter((n) => credRe.test(n));
2803
+ if (names.length) { credentialNames = names; break; }
2804
+ } catch {}
2805
+ }
2806
+ }
2782
2807
  }
2783
2808
  } else {
2784
2809
  try {
@@ -3545,6 +3570,286 @@ async function prepareNativeStateHome(projectDir) {
3545
3570
  return installEnv;
3546
3571
  }
3547
3572
 
3573
+ /**
3574
+ * Turn OpenClaw's real computer-use on or off for a project.
3575
+ *
3576
+ * Three pieces must line up, and a bot with only two of them reports "I don't have permission"
3577
+ * without saying which one is missing — that ambiguity cost a whole debugging session:
3578
+ * 1. `computer` (+ `screen`) allowed in tools.alsoAllow — otherwise the agent never receives
3579
+ * the tool at all, no matter what else is configured;
3580
+ * 2. the `cua-computer` plugin, which is MANDATORY on Windows and ships disabled. It also has
3581
+ * to be on plugins.allow, or enabling is refused with "blocked by allowlist";
3582
+ * 3. a node host process advertising `computer.act` + `screen.snapshot`. The tool is only the
3583
+ * model-facing surface; the node is what actually touches the screen, which is why a
3584
+ * correctly configured tool still does nothing on its own.
3585
+ *
3586
+ * The node host must run inside a real interactive desktop session — started from a service or
3587
+ * an SSH session it either dies with the session or cannot see a desktop at all. Launching it
3588
+ * from here works because the operator pressing the button is sitting at that desktop.
3589
+ */
3590
+ async function setComputerUse(projectDir, enable) {
3591
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
3592
+ if (!existsSync(cfgPath)) return { ok: false, error: 'openclaw.json not found' };
3593
+ const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
3594
+
3595
+ cfg.tools = (cfg.tools && typeof cfg.tools === 'object') ? cfg.tools : {};
3596
+ const allow = new Set(Array.isArray(cfg.tools.alsoAllow) ? cfg.tools.alsoAllow : []);
3597
+ cfg.plugins = (cfg.plugins && typeof cfg.plugins === 'object') ? cfg.plugins : {};
3598
+ cfg.plugins.allow = Array.isArray(cfg.plugins.allow) ? cfg.plugins.allow : [];
3599
+ cfg.plugins.entries = (cfg.plugins.entries && typeof cfg.plugins.entries === 'object') ? cfg.plugins.entries : {};
3600
+
3601
+ if (enable) {
3602
+ allow.add('computer');
3603
+ allow.add('screen');
3604
+ if (!cfg.plugins.allow.includes('cua-computer')) cfg.plugins.allow.push('cua-computer');
3605
+ cfg.plugins.entries['cua-computer'] = { ...(cfg.plugins.entries['cua-computer'] || {}), enabled: true };
3606
+ } else {
3607
+ allow.delete('computer');
3608
+ allow.delete('screen');
3609
+ if (cfg.plugins.entries['cua-computer']) cfg.plugins.entries['cua-computer'].enabled = false;
3610
+ }
3611
+ cfg.tools.alsoAllow = [...allow];
3612
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
3613
+
3614
+ if (!enable) {
3615
+ await stopNodeHost().catch(() => {});
3616
+ sendLog('[computer-use] Đã tắt: gỡ tool computer/screen và dừng node điều khiển.');
3617
+ return { ok: true, enabled: false };
3618
+ }
3619
+
3620
+ sendLog('[computer-use] Đã bật tool computer + plugin cua-computer. Đang khởi động node điều khiển...');
3621
+ const meta = readNativeMeta(projectDir) || {};
3622
+ const port = meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT;
3623
+ await startNodeHost(projectDir, port);
3624
+ return { ok: true, enabled: true, gatewayPort: port };
3625
+ }
3626
+
3627
+ /** Run `openclaw node run` detached so it outlives this request but stays in this desktop session. */
3628
+ async function startNodeHost(projectDir, gatewayPort) {
3629
+ if (await nodeHostRunning()) { sendLog('[computer-use] Node điều khiển đã chạy sẵn.'); return; }
3630
+ const a = ocArgv(projectDir, ['node', 'run', '--host', '127.0.0.1', '--port', String(gatewayPort)]);
3631
+ const child = spawn(a.cmd, a.args, { ...a.opts, detached: true, stdio: 'ignore', windowsHide: true });
3632
+ child.on('error', (err) => sendLog(`[computer-use] không chạy được node host: ${err.message}`));
3633
+ child.unref();
3634
+ // Confirm instead of assuming: a node host that failed to start looks exactly like one that
3635
+ // started, until the bot says it has no permission.
3636
+ for (let i = 0; i < 15; i++) {
3637
+ await new Promise((r) => setTimeout(r, 2000));
3638
+ if (await nodeHostRunning()) { sendLog('[computer-use] Node điều khiển đã kết nối.'); return; }
3639
+ }
3640
+ sendLog('[computer-use] Node điều khiển chưa lên sau 30s — kiểm tra lại bằng `openclaw node status`.');
3641
+ }
3642
+
3643
+ async function nodeHostRunning() {
3644
+ if (process.platform === 'win32') {
3645
+ const r = await runCapture('powershell', ['-NoProfile', '-Command',
3646
+ "(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -like '*node*run*--host*' } | Measure-Object).Count"], { shell: false, timeout: 8000 });
3647
+ return Number(String(r.stdout || '0').trim()) > 0;
3648
+ }
3649
+ const r = await runCapture('sh', ['-c', "ps ax | grep -c '[n]ode run --host'"], { shell: false, timeout: 8000 });
3650
+ return Number(String(r.stdout || '0').trim()) > 0;
3651
+ }
3652
+
3653
+ async function stopNodeHost() {
3654
+ if (process.platform === 'win32') {
3655
+ await runCapture('powershell', ['-NoProfile', '-Command',
3656
+ "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -like '*node*run*--host*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -EA SilentlyContinue }"], { shell: false, timeout: 10000 });
3657
+ return;
3658
+ }
3659
+ await runCapture('sh', ['-c', "pkill -f 'node run --host' || true"], { shell: false, timeout: 10000 });
3660
+ }
3661
+
3662
+ /**
3663
+ * Write the double-click launchers into the project and copy the operator-facing ones to the
3664
+ * Desktop. Safe to re-run: it overwrites, so a fixed launcher reaches machines on the next
3665
+ * install or update instead of leaving the customer pressing a stale copy.
3666
+ */
3667
+ async function writeWindowsLaunchers(projectDir, gatewayPort, routerPort) {
3668
+ const files = buildWindowsLaunchers({ projectDir, gatewayPort, routerPort, setupPort: activeUiPort || 51789 });
3669
+ for (const [name, content] of Object.entries(files)) {
3670
+ await fsp.writeFile(join(projectDir, name), content, 'utf8');
3671
+ }
3672
+ const desktop = join(os.homedir(), 'Desktop');
3673
+ if (existsSync(desktop)) {
3674
+ for (const name of WINDOWS_DESKTOP_LAUNCHERS) {
3675
+ await fsp.writeFile(join(desktop, name), files[name], 'utf8').catch(() => {});
3676
+ }
3677
+ sendLog(`[native] Đã đặt ${WINDOWS_DESKTOP_LAUNCHERS.length} tệp bấm-đúp lên Desktop.`);
3678
+ }
3679
+ }
3680
+
3681
+ /**
3682
+ * Move a running docker project onto native, keeping every bot and every workspace file.
3683
+ *
3684
+ * Why this exists: openclaw >=2026.9 writes files through @openclaw/fs-safe, which fstat()s the
3685
+ * file after an atomic rename. That check cannot pass through a Docker Desktop bind mount on
3686
+ * Windows, so the moment a docker image is rebuilt onto 2026.9.x the gateway can no longer write
3687
+ * its own config: every boot is unclean, the restart-loop breaker trips, and the bot is down with
3688
+ * a misleading "ENOENT: fstat". Measured end to end on win_kha, 09/09/2026 — native on the very
3689
+ * same NTFS path works, so the fault is the bind-mount layer, not the disk.
3690
+ *
3691
+ * The data lives in two different places and only one of them is visible on the host:
3692
+ * bind-mounted paths are already on disk, but named volumes are not — state (the sqlite DB with
3693
+ * every session), the plugin tree, and 9router's login live inside volumes. Copy those out FIRST;
3694
+ * everything after this point is reversible, the containers and image are left untouched.
3695
+ */
3696
+ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {}) {
3697
+ const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
3698
+ if (!existsSync(composeFile)) return false;
3699
+ sendLog('[migrate] Chuyển project từ Docker sang native...');
3700
+
3701
+ // 1. Stop the containers so nothing writes while we copy. Never `down -v` — that deletes the
3702
+ // very volumes holding the customer's sessions.
3703
+ await run('docker', ['compose', '-f', composeFile, 'stop']).catch(() => {});
3704
+ // Stopping is not enough: the containers carry a restart policy, so the next reboot brings them
3705
+ // back and they grab 18789/18790 before the native gateway can bind — the native process then
3706
+ // dies with EADDRINUSE seconds after reporting "ready", which reads like a random crash.
3707
+ // Measured on win_kha after its first reboot, 10/09/2026. Clear the policy, keep the containers.
3708
+ const psAll = await runCapture('docker', ['compose', '-f', composeFile, 'ps', '-a', '--format', '{{.Name}}'], { shell: false });
3709
+ const names = String(psAll.stdout || '').split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
3710
+ if (names.length) await run('docker', ['update', '--restart=no', ...names]).catch(() => {});
3711
+
3712
+ // 2. Copy every named volume out to the host path it maps to. Read the mapping from the
3713
+ // containers themselves rather than guessing volume names: compose prefixes them with the
3714
+ // project name, which differs per machine.
3715
+ const psOut = await runCapture('docker', ['compose', '-f', composeFile, 'ps', '-a', '--format', '{{.Name}}'], { shell: false });
3716
+ const containers = String(psOut.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
3717
+ const CONTAINER_HOME = '/home/node/project';
3718
+ let copied = 0;
3719
+ for (const name of containers) {
3720
+ const insp = await runCapture('docker', ['inspect', name, '--format', '{{range .Mounts}}{{.Type}}|{{.Name}}|{{.Destination}}\n{{end}}'], { shell: false });
3721
+ for (const line of String(insp.stdout || '').split(/\r?\n/)) {
3722
+ const [type, volName, dest] = line.split('|').map((s) => (s || '').trim());
3723
+ if (type !== 'volume' || !volName || !dest) continue;
3724
+ // Map the in-container path back onto the project on disk.
3725
+ let hostDir = null;
3726
+ if (dest.startsWith(`${CONTAINER_HOME}/`)) hostDir = join(projectDir, dest.slice(CONTAINER_HOME.length + 1));
3727
+ else if (dest === '/root/.9router' || dest.endsWith('/.9router')) hostDir = join(projectDir, '.9router');
3728
+ if (!hostDir) { sendLog(`[migrate] bỏ qua volume ${volName} (${dest}) — không map được về project`); continue; }
3729
+ await fsp.mkdir(hostDir, { recursive: true });
3730
+ // Copy with a throwaway container: the volume has no host path we can read directly.
3731
+ const r = await runCapture('docker', [
3732
+ 'run', '--rm', '-v', `${volName}:/__src:ro`, '-v', `${hostDir}:/__dst`,
3733
+ 'node:22-slim', 'sh', '-c', 'cp -a /__src/. /__dst/ 2>/dev/null; ls -A /__dst | head -1',
3734
+ ], { shell: false });
3735
+ if (r.stdout && r.stdout.trim()) { copied++; sendLog(`[migrate] ${volName} → ${hostDir}`); }
3736
+ else sendLog(`[migrate] ${volName} rỗng hoặc không chép được — kiểm tra tay: ${hostDir}`);
3737
+ }
3738
+ }
3739
+ sendLog(`[migrate] đã đưa ${copied} volume xuống đĩa.`);
3740
+
3741
+ // 3. Rewrite what only made sense inside the container.
3742
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
3743
+ if (existsSync(cfgPath)) {
3744
+ const before = await fsp.readFile(cfgPath, 'utf8');
3745
+ await fsp.writeFile(`${cfgPath}.bak-truoc-native`, before, 'utf8');
3746
+ let cfg = null;
3747
+ try { cfg = JSON.parse(before); } catch { cfg = null; }
3748
+ if (cfg) {
3749
+ // 3a. Container-absolute workspace paths. On Windows these become "C:\home\node\..." and
3750
+ // every plugin fails its install-dir check.
3751
+ const projPosix = join(projectDir, '.openclaw').replace(/\\/g, '/');
3752
+ let text = JSON.stringify(cfg, null, 2).split(`${CONTAINER_HOME}/.openclaw`).join(projPosix);
3753
+ cfg = JSON.parse(text);
3754
+ // 3a-ter. RELATIVE workspace paths. Inside the container the process always ran from
3755
+ // /home/node/project so ".openclaw/workspace-x" resolved correctly. Native has no such
3756
+ // guarantee: the customer double-clicks a launcher from wherever they filed it, and the
3757
+ // agent then looks for its workspace under THAT folder and dies with WORKSPACE_VANISHED
3758
+ // — while agents that happen to carry an absolute path keep working, so only some bots
3759
+ // break. Exactly what happened when the launchers were moved into a Desktop subfolder
3760
+ // (win_kha 10/09/2026). Pin every workspace to the project.
3761
+ {
3762
+ const ag = cfg.agents || {};
3763
+ const list = ag.entries && typeof ag.entries === 'object' ? Object.entries(ag.entries)
3764
+ : Array.isArray(ag.list) ? ag.list.map((a) => [a && a.id, a]) : [];
3765
+ for (const [aid, meta] of list) {
3766
+ if (!meta || typeof meta.workspace !== 'string') continue;
3767
+ const w = meta.workspace;
3768
+ if (/^[a-zA-Z]:[\\/]/.test(w) || w.startsWith('/')) continue; // already absolute
3769
+ meta.workspace = join(projectDir, w.replace(/^\.[\\/]/, '')).replace(/\\/g, '/');
3770
+ sendLog(`[migrate] workspace ${aid}: "${w}" → "${meta.workspace}" (đường tương đối phụ thuộc chỗ bấm tệp)`);
3771
+ }
3772
+ }
3773
+ text = JSON.stringify(cfg, null, 2);
3774
+
3775
+ // 3a-bis. Docker-only HOSTNAMES. `http://9router:20128` resolves through compose's internal
3776
+ // DNS and nowhere else, so on native every model call dies with
3777
+ // `getaddrinfo ENOTFOUND 9router` — the bot still reacts (that is the channel, no model
3778
+ // needed) but can never reply, which reads as "bot ignores me". Same story for
3779
+ // host.docker.internal and the 192.168.65.x Desktop bridge. Measured win_kha 10/09/2026.
3780
+ text = text
3781
+ .split('http://9router:20128').join('http://127.0.0.1:20128')
3782
+ .split('http://host.docker.internal:20128').join('http://127.0.0.1:20128')
3783
+ .replace(/http:\/\/192\.168\.65\.\d+:(\d+)/g, 'http://127.0.0.1:$1');
3784
+ cfg = JSON.parse(text);
3785
+ // 3b. bind. Inside docker, 0.0.0.0 was safe because the port was published to loopback only.
3786
+ // Native binds the host directly, so the same value would expose the gateway to the LAN.
3787
+ cfg.gateway = (cfg.gateway && typeof cfg.gateway === 'object') ? cfg.gateway : {};
3788
+ if (cfg.gateway.bind !== 'loopback') {
3789
+ cfg.gateway.bind = 'loopback';
3790
+ delete cfg.gateway.customBindHost;
3791
+ if (cfg.gateway.controlUi) {
3792
+ cfg.gateway.controlUi.allowedOrigins = ['http://localhost:' + (cfg.gateway.port || NATIVE_DEFAULT_GATEWAY_PORT), 'http://127.0.0.1:' + (cfg.gateway.port || NATIVE_DEFAULT_GATEWAY_PORT)];
3793
+ }
3794
+ sendLog('[migrate] gateway.bind → loopback (native bind thẳng lên host, 0.0.0.0 là mở ra mạng)');
3795
+ }
3796
+ // 3c. The browser-automation plugin rewrites the browser block on every boot, so simply
3797
+ // deleting the bad key below is not enough — it grows back and the config is invalid
3798
+ // again by the next restart (same shape as the old toolResultMaxChars reinfection).
3799
+ // Take the pen away from it; patchDocker is pointless here now too.
3800
+ const baEntry = cfg.plugins && cfg.plugins.entries && cfg.plugins.entries['browser-automation'];
3801
+ if (baEntry && typeof baEntry === 'object') {
3802
+ baEntry.config = (baEntry.config && typeof baEntry.config === 'object') ? baEntry.config : {};
3803
+ baEntry.config.manageBrowserConfig = false;
3804
+ baEntry.config.patchDocker = false;
3805
+ sendLog('[migrate] tắt browser-automation.manageBrowserConfig (nó ghi lại khoá 2026.9 từ chối mỗi lần boot)');
3806
+ }
3807
+ // 3d. browser profile colour: required up to 2026.8, rejected outright by 2026.9.
3808
+ const profiles = (cfg.browser && cfg.browser.profiles) || null;
3809
+ if (profiles && typeof profiles === 'object') {
3810
+ for (const key of Object.keys(profiles)) {
3811
+ if (profiles[key] && typeof profiles[key] === 'object' && profiles[key].color !== undefined) {
3812
+ delete profiles[key].color;
3813
+ sendLog(`[migrate] gỡ browser.profiles.${key}.color (2026.9 không nhận)`);
3814
+ }
3815
+ }
3816
+ }
3817
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
3818
+ }
3819
+ }
3820
+
3821
+ // 4. Backup folders sitting inside extensions/ are still scanned as real plugins — that is where
3822
+ // the long-standing "duplicate plugin id" warnings came from. A leading dot does NOT hide them
3823
+ // on Windows, so move them out of the tree entirely.
3824
+ const extDir = join(projectDir, '.openclaw', 'extensions');
3825
+ if (existsSync(extDir)) {
3826
+ const parked = join(projectDir, '_plugin-backups');
3827
+ for (const entry of await fsp.readdir(extDir).catch(() => [])) {
3828
+ if (!/\.(bak|broken|old)[-.]|^\.openclaw-install-backups$/i.test(entry)) continue;
3829
+ await fsp.mkdir(parked, { recursive: true });
3830
+ await fsp.rename(join(extDir, entry), join(parked, entry.replace(/^\./, ''))).catch(() => {});
3831
+ sendLog(`[migrate] dời bản sao lưu plugin ra khỏi extensions/: ${entry}`);
3832
+ }
3833
+ }
3834
+
3835
+ // 5. zalo-connect wrote its Zalo sessions next to the container's HOME, which was itself
3836
+ // <project>/.openclaw — leaving them one level too deep. Lift them so 3.1.5's one-time
3837
+ // migration finds them; without this every account comes back "not authenticated".
3838
+ const nested = join(projectDir, '.openclaw', '.openclaw');
3839
+ if (existsSync(nested)) {
3840
+ for (const entry of await fsp.readdir(nested).catch(() => [])) {
3841
+ if (!/^zalo-connect-credentials.*\.json$/.test(entry)) continue;
3842
+ const target = join(projectDir, '.openclaw', entry);
3843
+ if (existsSync(target)) continue;
3844
+ await fsp.copyFile(join(nested, entry), target).catch(() => {});
3845
+ sendLog(`[migrate] nâng phiên Zalo lên đúng chỗ: ${entry}`);
3846
+ }
3847
+ }
3848
+
3849
+ sendLog('[migrate] Xong phần dữ liệu. Container và image giữ nguyên để còn quay lui.');
3850
+ return true;
3851
+ }
3852
+
3548
3853
  /**
3549
3854
  * Bring a native project up end to end: 9router → smart-route sync → resolve its API key into
3550
3855
  * openclaw.json → install the gateway as a managed service. Order matters: the gateway must boot
@@ -3562,6 +3867,15 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
3562
3867
  'utf8',
3563
3868
  );
3564
3869
 
3870
+ // Windows has no usable service story for this (see windows-launcher-gen.js): the Scheduled
3871
+ // Task openclaw installs is bound to a login session, so it dies with the SSH session and is
3872
+ // refused outright when set to run at boot. Give the owner double-click launchers instead —
3873
+ // the same shape as the .command files on macOS — and put the three they actually press on
3874
+ // the Desktop where they can find them.
3875
+ if (process.platform === 'win32') {
3876
+ await writeWindowsLaunchers(projectDir, gwPort, rtPort).catch((e) => sendLog(`[native] launcher: ${e.message}`));
3877
+ }
3878
+
3565
3879
  await startNative9Router(projectDir);
3566
3880
 
3567
3881
  // Same smart-route sync the docker sidecar runs, pointed at the native DB path. Without it
@@ -3830,6 +4144,30 @@ async function updateRuntime(target, projectDir) {
3830
4144
  probeCacheClear();
3831
4145
  return { ok: true, target, spec, mode: 'native' };
3832
4146
  }
4147
+ // A docker project asked to update is a docker project about to rebuild its image — which is
4148
+ // exactly the step that lands it on openclaw >=2026.9 and breaks writing through the bind mount
4149
+ // (see migrateDockerProjectToNative). So the update IS the migration: move the data onto native
4150
+ // first, then update the native way. Doing it here rather than on every page load keeps it a
4151
+ // deliberate act by the customer, not something that reshapes their machine behind their back.
4152
+ if (!isNativeProject(projectDir) && projectDir && existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'))) {
4153
+ sendLog('[update] Project đang chạy Docker — chuyển sang native trước khi cập nhật.');
4154
+ await ensureNodeInstalled();
4155
+ await migrateDockerProjectToNative(projectDir, { osChoice: state.os || '' });
4156
+ await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
4157
+ invalidateHostOpenclawVersion();
4158
+ await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
4159
+ const meta = readNativeMeta(projectDir) || {};
4160
+ await startNativeRuntime({
4161
+ projectDir,
4162
+ osChoice: state.os || '',
4163
+ gatewayPort: meta.gatewayPort || state.gatewayPort,
4164
+ routerPort: meta.routerPort || state.routerPort,
4165
+ });
4166
+ await syncRuntimeState(projectDir, { full: true }).catch(() => {});
4167
+ probeCacheClear();
4168
+ sendLog('[update] Đã chuyển sang native. Container Docker được giữ nguyên (đã dừng) để còn quay lui.');
4169
+ return { ok: true, target, spec, mode: 'native', migratedFromDocker: true };
4170
+ }
3833
4171
  if (state.mode === 'docker' && projectDir) {
3834
4172
  const dockerDir = join(projectDir, 'docker', 'openclaw');
3835
4173
  if (isRouter) {
@@ -4143,11 +4481,63 @@ function detectHostApps() {
4143
4481
  add('zalo', [join(local, 'Programs', 'Zalo', 'Zalo.exe'), join(local, 'Zalo', 'Zalo.exe')]);
4144
4482
  add('explorer', ['C:\\Windows\\explorer.exe']);
4145
4483
  add('notepad', ['C:\\Windows\\System32\\notepad.exe']);
4484
+ // A hand-written shortlist only covers what WE thought of. Every customer machine has its
4485
+ // own software, and the bot is useless the moment it is asked for something not on the list —
4486
+ // "mở TeamViewer" fails not because the app is missing but because nobody enumerated it.
4487
+ // The Start Menu is the one place Windows guarantees an entry per installed app, and a .lnk
4488
+ // launches correctly without knowing where the .exe actually lives. Scanning it turns the
4489
+ // list from "5 apps we guessed" into "everything this machine has", and it stays correct
4490
+ // when the customer installs something new. Measured on win_kha: 5 → 182 apps.
4491
+ Object.assign(apps, scanWindowsStartMenuApps(), apps); // hand-written entries win
4146
4492
  } else if (process.platform === 'darwin') {
4147
4493
  add('teamviewer', ['/Applications/TeamViewer.app']);
4148
4494
  add('chrome', ['/Applications/Google Chrome.app']);
4149
4495
  add('zalo', ['/Applications/Zalo.app']);
4150
4496
  add('finder', ['/System/Library/CoreServices/Finder.app']);
4497
+ // Same idea as Windows: enumerate what is really installed instead of guessing.
4498
+ Object.assign(apps, scanMacApplications(), apps);
4499
+ }
4500
+ return apps;
4501
+ }
4502
+
4503
+ /** Every .lnk under both Start Menu trees, keyed by a slug of its name. */
4504
+ function scanWindowsStartMenuApps() {
4505
+ const apps = {};
4506
+ // Uninstallers and doc links are not apps; opening one by accident is worse than not having it.
4507
+ const SKIP = /(uninstall|gỡ cài đặt|go cai dat|readme|help|documentation|website|release notes|license|repair|modify)/i;
4508
+ const roots = [
4509
+ join(process.env.ProgramData || 'C:\\ProgramData', 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
4510
+ join(process.env.APPDATA || '', 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
4511
+ ];
4512
+ const walk = (dir, depth = 0) => {
4513
+ if (depth > 4) return;
4514
+ let entries = [];
4515
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
4516
+ for (const e of entries) {
4517
+ const p = join(dir, e.name);
4518
+ if (e.isDirectory()) { walk(p, depth + 1); continue; }
4519
+ if (!/\.lnk$/i.test(e.name)) continue;
4520
+ const name = e.name.replace(/\.lnk$/i, '');
4521
+ if (SKIP.test(name)) continue;
4522
+ const key = slugify(name, '');
4523
+ if (key && !apps[key]) apps[key] = p;
4524
+ }
4525
+ };
4526
+ for (const r of roots) if (r) walk(r);
4527
+ return apps;
4528
+ }
4529
+
4530
+ /** Installed .app bundles, so macOS gets the same "everything on this machine" list. */
4531
+ function scanMacApplications() {
4532
+ const apps = {};
4533
+ for (const dir of ['/Applications', '/System/Applications', join(os.homedir(), 'Applications')]) {
4534
+ let entries = [];
4535
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
4536
+ for (const e of entries) {
4537
+ if (!e.name.endsWith('.app')) continue;
4538
+ const key = slugify(e.name.replace(/\.app$/, ''), '');
4539
+ if (key && !apps[key]) apps[key] = join(dir, e.name);
4540
+ }
4151
4541
  }
4152
4542
  return apps;
4153
4543
  }
@@ -5004,9 +5394,19 @@ async function writeHostControlAccess(projectDir, cfg) {
5004
5394
  '',
5005
5395
  '- macOS: `open -a "<Tên app>"` — ví dụ `open -a "TeamViewer"`',
5006
5396
  '- Linux: `xdg-open <app|url>` hoặc chạy binary trực tiếp',
5007
- '- Windows: `cmd /c start "" "<app>"`',
5397
+ ...(process.platform === 'win32' ? [
5398
+ '- Windows: **đừng** gọi `Start-Process <TênApp>` — tên app KHÔNG phải lệnh trên PATH, bạn sẽ nhận',
5399
+ ' `The system cannot find the file specified`. Hãy tra shortcut trong Start Menu rồi mở nó:',
5400
+ '',
5401
+ '```sh',
5402
+ 'powershell -NoProfile -Command "$n=\'TeamViewer\'; $r=@(\"$env:ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\",\"$env:APPDATA\\Microsoft\\Windows\\Start Menu\\Programs\"); $l=Get-ChildItem $r -Recurse -Filter \"*$n*.lnk\" -EA SilentlyContinue | Select -First 1; if($l){ Start-Process $l.FullName } else { \"khong tim thay $n\" }"',
5403
+ '```',
5404
+ '',
5405
+ 'Đổi `TeamViewer` thành tên app cần mở. Cách này đúng với MỌI app đã cài, kể cả app chủ mới cài hôm qua,',
5406
+ 'vì Windows luôn đặt shortcut vào Start Menu và `.lnk` tự biết đường dẫn thật.',
5407
+ ] : ['- Windows: `cmd /c start "" "<app>"`']),
5008
5408
  '',
5009
- apps.length ? `App chủ gợi ý: ${apps.map((a) => `\`${a}\``).join(', ')}.` : 'Chủ chưa liệt kê app — mở theo tên app có trên máy.',
5409
+ apps.length ? `App trên máy này: ${apps.map((a) => `\`${a}\``).join(', ')}.` : 'Chủ chưa liệt kê app — mở theo tên app có trên máy.',
5010
5410
  ...captureBlock,
5011
5411
  ...scriptBlock,
5012
5412
  '',
@@ -5308,6 +5708,79 @@ async function waitForDockerDaemon(timeoutMs) {
5308
5708
  }
5309
5709
  }
5310
5710
 
5711
+ // OpenClaw's own engines range, copied verbatim from its package.json:
5712
+ // ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0"
5713
+ // The gaps matter — Node 23.x and 25.0–25.8 are NOT usable — so "install the latest Node" is the
5714
+ // wrong instinct here. When we have to install, we install 24 LTS, which sits inside a supported
5715
+ // window and is what every working host in the fleet already runs.
5716
+ const NODE_TARGET_MAJOR = 24;
5717
+ function nodeVersionSupported(raw) {
5718
+ const m = String(raw || '').match(/v?(\d+)\.(\d+)\.(\d+)/);
5719
+ if (!m) return false;
5720
+ const [maj, min, pat] = [Number(m[1]), Number(m[2]), Number(m[3])];
5721
+ const atLeast = (a, b, c) => maj > a || (maj === a && (min > b || (min === b && pat >= c)));
5722
+ if (maj === 22) return atLeast(22, 22, 3);
5723
+ if (maj === 24) return atLeast(24, 15, 0);
5724
+ if (maj >= 25) return maj > 25 || atLeast(25, 9, 0);
5725
+ return false;
5726
+ }
5727
+
5728
+ // Native mode runs openclaw straight off the host, so the host's Node IS the runtime — unlike
5729
+ // docker mode, where the image carried its own. A customer machine that never needed Node before
5730
+ // (or carries one outside openclaw's supported range) must get a usable one before anything else,
5731
+ // otherwise the install dies deep inside `npm i -g openclaw` with an unreadable engine error.
5732
+ async function ensureNodeInstalled() {
5733
+ const current = await commandExists('node', ['--version']);
5734
+ if (current.ok && nodeVersionSupported(current.output)) {
5735
+ sendLog(`[node] Node ${current.output.trim()} hợp lệ.`);
5736
+ return;
5737
+ }
5738
+ const why = current.ok
5739
+ ? `Node ${current.output.trim()} nằm ngoài dải OpenClaw hỗ trợ`
5740
+ : 'Máy chưa có Node';
5741
+ sendLog(`[node] ${why} — đang cài Node ${NODE_TARGET_MAJOR} LTS...`);
5742
+
5743
+ if (process.platform === 'linux') {
5744
+ const root = typeof process.getuid === 'function' && process.getuid() === 0;
5745
+ const sudo = root ? '' : 'sudo ';
5746
+ // NodeSource covers deb/rpm and pins the major, so we land inside the supported window
5747
+ // instead of whatever "latest" happens to be that week.
5748
+ await run('sh', ['-c',
5749
+ `curl -fsSL https://deb.nodesource.com/setup_${NODE_TARGET_MAJOR}.x | ${sudo}bash - && ${sudo}apt-get install -y nodejs`,
5750
+ ]).catch(async () => {
5751
+ sendLog('[node] apt không dùng được — thử nvm...');
5752
+ await run('sh', ['-c',
5753
+ `export NVM_DIR="$HOME/.nvm"; curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash && . "$NVM_DIR/nvm.sh" && nvm install ${NODE_TARGET_MAJOR} && nvm alias default ${NODE_TARGET_MAJOR}`,
5754
+ ]);
5755
+ });
5756
+ } else if (process.platform === 'darwin') {
5757
+ const brew = await commandExists('brew', ['--version']);
5758
+ if (!brew.ok) throw httpError(400, 'macOS: cần Homebrew để tự cài Node. Cài tại https://brew.sh rồi thử lại.');
5759
+ await run('brew', ['install', `node@${NODE_TARGET_MAJOR}`]);
5760
+ await run('brew', ['link', '--overwrite', '--force', `node@${NODE_TARGET_MAJOR}`]).catch(() => {});
5761
+ } else if (process.platform === 'win32') {
5762
+ const winget = await commandExists('winget', ['--version']);
5763
+ const choco = await commandExists('choco', ['--version']);
5764
+ if (winget.ok) {
5765
+ await run('winget', ['install', '-e', '--id', `OpenJS.NodeJS.LTS`, '--accept-source-agreements', '--accept-package-agreements']);
5766
+ } else if (choco.ok) {
5767
+ await run('choco', ['install', 'nodejs-lts', '-y']);
5768
+ } else {
5769
+ throw httpError(400, `Windows: cần winget hoặc Chocolatey để tự cài Node (hoặc cài Node ${NODE_TARGET_MAJOR} LTS thủ công tại https://nodejs.org) rồi thử lại.`);
5770
+ }
5771
+ } else {
5772
+ throw httpError(400, `Hệ điều hành không tự cài Node được. Hãy cài Node ${NODE_TARGET_MAJOR} LTS thủ công rồi thử lại.`);
5773
+ }
5774
+
5775
+ // A fresh install lands on a PATH this process never picked up, so re-check through a new shell
5776
+ // rather than trusting our own env.
5777
+ const after = await commandExists('node', ['--version']);
5778
+ if (!after.ok || !nodeVersionSupported(after.output)) {
5779
+ throw httpError(500, `Đã cài Node nhưng phiên bản hiện tại (${after.output || 'không đọc được'}) vẫn ngoài dải OpenClaw hỗ trợ. Mở lại cửa sổ dòng lệnh (hoặc đăng xuất/đăng nhập lại) để PATH cập nhật, rồi cài lại.`);
5780
+ }
5781
+ sendLog(`[node] Node ${after.output.trim()} đã sẵn sàng.`);
5782
+ }
5783
+
5311
5784
  // Ensure Docker (engine + compose) is available before a docker-mode install, auto-installing the
5312
5785
  // latest version appropriate for the host OS when it is missing:
5313
5786
  // • Linux → Docker's official convenience script (get.docker.com; auto-detects the distro) + start daemon
@@ -5407,7 +5880,10 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
5407
5880
  // Make sure Docker is present (auto-install on Linux/VPS) before doing any work — fail fast
5408
5881
  // with a clear message rather than deep inside `docker compose up`. Native mode has no
5409
5882
  // container, so it skips this entirely (that is much of the point of choosing it).
5410
- if (mode !== 'native') await ensureDockerInstalled(osChoice);
5883
+ // Native has no image to carry a runtime, so the host's Node IS the runtime — get a supported
5884
+ // one on the machine before npm touches anything. Docker still needs the daemon instead.
5885
+ if (mode === 'native') await ensureNodeInstalled();
5886
+ else await ensureDockerInstalled(osChoice);
5411
5887
  await writeCoreProject({ projectDir, osChoice, mode, gatewayPort, routerPort, userTimezone });
5412
5888
  await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
5413
5889
  // The global runtime just changed — every version gate after this point (notably
@@ -5632,6 +6108,34 @@ async function discoverDockerBotProjectRoots() {
5632
6108
  // dir symlinked to it — without this filter the account home shows up as a phantom "root"
5633
6109
  // project tab after every refresh (measured on vps_c-thu). The native marker's `label` was
5634
6110
  // written from the ORIGINAL project dir, so a mismatch identifies the phantom.
6111
+ /**
6112
+ * Two paths can be the same project: a native install links the account home and the project dir
6113
+ * together, so `~/.openclaw` and `<project>/.openclaw` resolve to one directory on disk. Scanning
6114
+ * finds both and the dashboard then lists the SAME bots twice under two project names (seen on
6115
+ * win_kha as "bot" and "VT 2025", 4 identical bots online in each — 10/09/2026).
6116
+ *
6117
+ * Dedupe on where `.openclaw` really lands, and keep the entry that IS the real directory: the
6118
+ * link side is an artefact of the install layout, not somewhere the customer put a project.
6119
+ * Works whichever way the link points, which matters because the direction differs between a
6120
+ * fresh native install and one migrated over from docker.
6121
+ */
6122
+ function dedupeProjectsByRealState(dirs) {
6123
+ const byReal = new Map();
6124
+ for (const dir of dirs) {
6125
+ if (!dir) continue;
6126
+ const statePath = join(dir, '.openclaw');
6127
+ let real = statePath;
6128
+ let isLink = false;
6129
+ try { real = fs.realpathSync(statePath); } catch {}
6130
+ try { isLink = fs.lstatSync(statePath).isSymbolicLink(); } catch {}
6131
+ const key = resolve(real).toLowerCase();
6132
+ const prev = byReal.get(key);
6133
+ // First one wins, unless we already kept a link and now meet the real directory.
6134
+ if (!prev || (prev.isLink && !isLink)) byReal.set(key, { dir, isLink });
6135
+ }
6136
+ return [...byReal.values()].map((v) => v.dir);
6137
+ }
6138
+
5635
6139
  function isPhantomStateDirProject(dir) {
5636
6140
  try {
5637
6141
  const meta = readNativeMeta(dir);
@@ -5663,7 +6167,7 @@ async function discoverNativeProjectRoots(rootProjectDir) {
5663
6167
  if (rootProjectDir && existsSync(nativeMarkerPath(rootProjectDir)) && existsSync(join(rootProjectDir, '.openclaw', 'openclaw.json'))) {
5664
6168
  roots.add(resolve(rootProjectDir));
5665
6169
  }
5666
- return [...roots].filter((dir) => !isPhantomStateDirProject(dir));
6170
+ return dedupeProjectsByRealState([...roots].filter((dir) => !isPhantomStateDirProject(dir)));
5667
6171
  }
5668
6172
 
5669
6173
  async function findLatestProject(rootProjectDir) {
@@ -5718,7 +6222,10 @@ async function findLatestProject(rootProjectDir) {
5718
6222
  candidates.push({ dir: dr, mtimeMs: (st?.mtimeMs || 0) + 1e15 });
5719
6223
  }
5720
6224
  candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
5721
- return candidates[0]?.dir || null;
6225
+ // Drop the linked twin before picking a winner, or the account-home view of the same project
6226
+ // can outrank the project itself purely on mtime.
6227
+ const keep = new Set(dedupeProjectsByRealState(candidates.map((c) => c.dir)));
6228
+ return candidates.find((c) => keep.has(c.dir))?.dir || candidates[0]?.dir || null;
5722
6229
  }
5723
6230
 
5724
6231
  async function ensureProjectsLoaded(rootProjectDir) {
@@ -5729,7 +6236,10 @@ async function ensureProjectsLoaded(rootProjectDir) {
5729
6236
  try {
5730
6237
  const saved = JSON.parse(await fsp.readFile(file, 'utf8'));
5731
6238
  if (Array.isArray(saved?.projects)) {
5732
- state.projects = saved.projects;
6239
+ // Filter on the way IN: a linked twin already written to the state file would otherwise
6240
+ // survive every later dedupe, because the dashboard reads state.projects directly.
6241
+ const keep = new Set(dedupeProjectsByRealState(saved.projects.map((p) => p && p.projectDir)));
6242
+ state.projects = saved.projects.filter((p) => p && keep.has(p.projectDir));
5733
6243
  }
5734
6244
  } catch {}
5735
6245
  }
@@ -5774,6 +6284,11 @@ async function computeDiscoverProjects(rootProjectDir) {
5774
6284
  // Drop phantom state-dir "projects" that slipped into the saved list (e.g. the account
5775
6285
  // home after a 2026.8.x native state move) — and keep them out of the persisted state.
5776
6286
  state.projects = state.projects.filter((p) => !isPhantomStateDirProject(p.projectDir));
6287
+ // The label check above only catches projects that carry a marker with a mismatched label.
6288
+ // A linked twin without one still slips through and the dashboard shows the same bots under
6289
+ // two project names, both "online" — collapse those onto the real directory.
6290
+ const keepDirs = new Set(dedupeProjectsByRealState(state.projects.map((p) => p.projectDir)));
6291
+ state.projects = state.projects.filter((p) => keepDirs.has(p.projectDir));
5777
6292
 
5778
6293
  // In parallel: each buildProjectMeta runs runtime detection (docker calls, port probes), so a
5779
6294
  // handful of projects turned into seconds of dashboard load when this was a sequential loop.
@@ -6935,6 +7450,18 @@ async function handler(req, res, rootProjectDir) {
6935
7450
  // capture / recording and node-script permissions get granted (opt out with grants:false).
6936
7451
  const granted = cfg.enabled && body.grants !== false ? grantHostCapabilities(cfg) : [];
6937
7452
  if (granted.length) sendLog(`[host-control] Đã cấp thêm quyền chạy: ${granted.join(', ')}.`);
7453
+ // Pressing this button means "let the bot drive this machine", so it must deliver the real
7454
+ // thing — OpenClaw's own `computer` tool (screenshot → click → type → drag), not just the
7455
+ // ability to launch an app. That tool needs three separate pieces switched on together, and
7456
+ // any one missing leaves the bot insisting it has no permission:
7457
+ // • the tool allowed for agents, • the cua-computer plugin (mandatory on Windows),
7458
+ // • a running node host advertising computer.act + screen.snapshot.
7459
+ // Doing it here rather than in a side script matters: the node host must live in a real
7460
+ // interactive desktop session, and the operator pressing this button IS in one.
7461
+ const computerUse = await setComputerUse(projectDir, cfg.enabled).catch((e) => {
7462
+ sendLog(`[host-control] computer-use: ${e.message}`);
7463
+ return { ok: false, error: e.message };
7464
+ });
6938
7465
  await fsp.writeFile(hostControlConfigPath(projectDir), JSON.stringify(cfg, null, 2), 'utf8');
6939
7466
  let started = { ok: false, reason: 'disabled' };
6940
7467
  if (cfg.enabled) started = await ensureHostControl(projectDir);
@@ -6942,6 +7469,7 @@ async function handler(req, res, rootProjectDir) {
6942
7469
  // disabling strips it so a bot never keeps instructions for an endpoint now refusing.
6943
7470
  await writeHostControlAccess(projectDir, cfg).catch(() => {});
6944
7471
  sendLog(`[host-control] ${cfg.enabled ? 'Đã BẬT' : 'Đã TẮT'} quyền điều khiển máy cho bot.`);
7472
+ if (cfg.enabled && computerUse?.ok) sendLog('[host-control] Bot có thể chụp màn hình, click chuột, gõ phím trên máy này.');
6945
7473
  // Make sure the Codex desktop app can actually do GUI work, so `codex exec` is enough for
6946
7474
  // the bot: install computer-use into the app and repair its MCP registration. Nothing is
6947
7475
  // installed into the OpenClaw project and the gateway never restarts.
@@ -7433,4 +7961,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
7433
7961
  ]).catch(() => {});
7434
7962
  }
7435
7963
 
7436
- export { patchBrowserAutomationHostPreference, debugChromeProfileDir, defaultChromeProfileDir, createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations, runHostCommand, detectHostCommands, detectHostCapabilityCommands, grantHostCapabilities, detectCodexApp, detectCodexMarketplace, resolveCodexCli, openPrivacyPane, projectDeployMode, isNativeProject, nativeServiceLabel, nativeEnv, ocArgv, migrateNativePaths, discoverNativeProjectRoots, detectOs, stripCliWarnings, migrationLeaseDeadline, ensureNativePlugins, findFreeHostPort, syncNativeServiceEnv, adoptStrayNativeHome, runNativeConfigMigrations, detectExistingSetupUi, hardenNativeServiceRestarts, clearNativeServiceFailure, describePortHolder, reportNativeGatewayBlockage };
7964
+ export { patchBrowserAutomationHostPreference, debugChromeProfileDir, defaultChromeProfileDir, createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations, runHostCommand, detectHostCommands, detectHostCapabilityCommands, grantHostCapabilities, detectCodexApp, detectCodexMarketplace, resolveCodexCli, openPrivacyPane, projectDeployMode, isNativeProject, nativeServiceLabel, nativeEnv, ocArgv, migrateNativePaths, discoverNativeProjectRoots, detectOs, stripCliWarnings, migrationLeaseDeadline, ensureNativePlugins, findFreeHostPort, syncNativeServiceEnv, adoptStrayNativeHome, runNativeConfigMigrations, detectExistingSetupUi, nodeVersionSupported, migrateDockerProjectToNative, dedupeProjectsByRealState, hardenNativeServiceRestarts, clearNativeServiceFailure, describePortHolder, reportNativeGatewayBlockage };
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Double-click launchers for a NATIVE bot on Windows.
5
+ *
6
+ * Why these exist instead of a Scheduled Task: `openclaw daemon install` creates a task with
7
+ * `LogonType: Interactive`, which means the gateway is tied to whoever's login session started
8
+ * it. Two consequences measured on win_kha (10/09/2026):
9
+ * • anything started over SSH dies the moment the session closes (LastTaskResult 0xC000013A);
10
+ * • a task set to run at boot is refused outright, because "at startup" and "only while
11
+ * someone is logged in" contradict each other (0x800710E0).
12
+ * Making it run without a login needs the account password stored in the task — Task Scheduler
13
+ * rejected that too ("The user account is unknown, the password is incorrect"). So the owner
14
+ * drives it by hand, exactly like the .command files on macOS.
15
+ *
16
+ * Every trap below cost a round trip with a customer waiting, so they are encoded here:
17
+ *
18
+ * 1. `.cmd` MUST be CRLF. LF-only files break on Windows.
19
+ * 2. Never name a launcher after the command it calls. `9router.cmd` in the project dir plus
20
+ * `cd /d <project>` makes `call 9router` re-enter the same file — BATCH RECURSION, and the
21
+ * process dies before doing anything.
22
+ * 3. Never append to a fixed log file. 9router holds its log open for its whole lifetime, so
23
+ * the SECOND run cannot open it and exits with "The process cannot access the file because
24
+ * it is being used by another process" — invisible on the first run, which is why it is easy
25
+ * to ship broken.
26
+ * 4. 9router shows a `Choose Interface` menu when its stdout looks like a real console. Started
27
+ * hidden, nobody can answer it, so it hangs and never opens its port. `> NUL` is what tells
28
+ * it to run unattended. Symptom without it: works when run by hand, silently dead in the
29
+ * background — same command.
30
+ * 5. `Start-Process -WindowStyle Hidden` does NOT hide a console a .cmd opens for itself. Only
31
+ * WScript.Shell.Run(..., 0, False) truly hides it. Leaving a visible window is not cosmetic:
32
+ * it IS the bot, and closing it kills the bot.
33
+ * 6. Anchor the working directory. Agents may carry relative workspace paths, which resolve
34
+ * against the launcher's cwd — move the files into a folder and those bots die with
35
+ * WORKSPACE_VANISHED while the others keep working.
36
+ */
37
+
38
+ const CRLF = (s) => s.replace(/\r?\n/g, '\r\n');
39
+
40
+ /** Hidden-launch helper. WScript is the only reliable way to start a .cmd with no window. */
41
+ function buildRunHiddenVbs() {
42
+ return CRLF(
43
+ 'Set sh = CreateObject("WScript.Shell")\n' +
44
+ 'sh.Run "cmd /c """ & WScript.Arguments(0) & """", 0, False\n',
45
+ );
46
+ }
47
+
48
+ /** Starts 9router. Named start-9router.cmd on purpose — see trap 2. */
49
+ function build9RouterCmd({ projectDir, routerPort }) {
50
+ return CRLF(
51
+ '@echo off\n' +
52
+ 'rem Ten tep KHONG duoc la "9router.cmd": trung ten thi lenh ben duoi goi lai chinh no.\n' +
53
+ 'rem "> NUL" la bat buoc: neu dau ra la man hinh that, 9router bay menu "Choose Interface"\n' +
54
+ 'rem roi cho nguoi chon va khong bao gio mo cong.\n' +
55
+ `cd /d ${projectDir}\n` +
56
+ `set "DATA_DIR=${projectDir}\\.9router"\n` +
57
+ `call 9router -n -l -H 127.0.0.1 -p ${routerPort} --skip-update > NUL 2>&1\n`,
58
+ );
59
+ }
60
+
61
+ /** Starts the gateway. Calls node directly — see the --task-supervisor note below. */
62
+ function buildGatewayCmd({ projectDir, gatewayPort }) {
63
+ return CRLF(
64
+ '@echo off\n' +
65
+ 'rem Goi thang node, KHONG qua gateway.cmd cua openclaw: tep do chay kem --task-supervisor,\n' +
66
+ 'rem no di tim Scheduled Task de ban giao; khong co task thi no de them tien trinh + cua so.\n' +
67
+ 'rem Khong redirect ra tep co dinh (xem trap 3) — openclaw tu ghi nhat ky trong %TEMP%\\openclaw.\n' +
68
+ `cd /d ${projectDir}\n` +
69
+ 'set "HOME=%USERPROFILE%"\n' +
70
+ `set "OPENCLAW_GATEWAY_PORT=${gatewayPort}"\n` +
71
+ `set "OPENCLAW_PORT=${gatewayPort}"\n` +
72
+ '"%ProgramFiles%\\nodejs\\node.exe" --max-old-space-size=8192 ' +
73
+ `"%APPDATA%\\npm\\node_modules\\openclaw\\dist\\index.js" gateway --port ${gatewayPort}\n`,
74
+ );
75
+ }
76
+
77
+ function buildSetupUiCmd({ projectDir, setupPort }) {
78
+ return CRLF(
79
+ '@echo off\n' +
80
+ 'rem Ten tep nhat ky kem gio, de lan chay sau khong dung vao tep dang bi giu (trap 3).\n' +
81
+ `cd /d ${projectDir}\n` +
82
+ 'for /f "tokens=1-4 delims=/: " %%a in ("%TIME%") do set "T=%%a%%b%%c"\n' +
83
+ `call npx create-openclaw-bot --host=127.0.0.1 --port=${setupPort} --no-open --project-dir=${projectDir}` +
84
+ ` > "${projectDir}\\setup-ui-%T%.log" 2>&1\n`,
85
+ );
86
+ }
87
+
88
+ function buildStartBotCmd({ projectDir, gatewayPort, routerPort }) {
89
+ const dashPort = gatewayPort + 1;
90
+ return CRLF(
91
+ '@echo off\n' +
92
+ 'title Khoi dong bot OpenClaw\n' +
93
+ 'color 0A\n' +
94
+ `cd /d ${projectDir}\n` +
95
+ 'echo.\n' +
96
+ 'echo ============================================\n' +
97
+ `echo KHOI DONG BOT -- ${projectDir}\n` +
98
+ 'echo ============================================\n' +
99
+ 'echo.\n' +
100
+ 'echo Dang bat 9Router...\n' +
101
+ `powershell -NoProfile -Command "if (-not (Get-NetTCPConnection -LocalPort ${routerPort} -State Listen -EA SilentlyContinue)) { Start-Process wscript -ArgumentList '${projectDir}\\run-hidden.vbs','${projectDir}\\start-9router.cmd' -WindowStyle Hidden }" >nul 2>&1\n` +
102
+ 'echo Cho 9Router san sang...\n' +
103
+ `powershell -NoProfile -Command "for($i=0;$i -lt 20;$i++){ try{ Invoke-WebRequest 'http://127.0.0.1:${routerPort}/' -UseBasicParsing -TimeoutSec 3 | Out-Null; break } catch { Start-Sleep -Seconds 2 } }"\n` +
104
+ 'echo Dang bat bot (gateway)...\n' +
105
+ `powershell -NoProfile -Command "if (-not (Get-NetTCPConnection -LocalPort ${gatewayPort} -State Listen -EA SilentlyContinue)) { Start-Process wscript -ArgumentList '${projectDir}\\run-hidden.vbs','${projectDir}\\gateway-start.cmd' -WindowStyle Hidden }" >nul 2>&1\n` +
106
+ 'echo Cho bot san sang (co the mat 30-60 giay)...\n' +
107
+ 'echo.\n' +
108
+ `powershell -NoProfile -Command "$ok=$false; for($i=0;$i -lt 40;$i++){ try{ Invoke-WebRequest 'http://127.0.0.1:${gatewayPort}/health' -UseBasicParsing -TimeoutSec 3 | Out-Null; $ok=$true; break } catch { Start-Sleep -Seconds 3 } }; if($ok){ Write-Host ' [OK] Bot da chay.' -ForegroundColor Green } else { Write-Host ' [LOI] Bot chua len.' -ForegroundColor Red }"\n` +
109
+ 'echo.\n' +
110
+ 'echo --- Trang thai ---\n' +
111
+ `powershell -NoProfile -Command "foreach($x in @(@('Bot (gateway)','http://127.0.0.1:${gatewayPort}/health'),@('9Router','http://127.0.0.1:${routerPort}/'),@('Bang dieu khien Zalo','http://127.0.0.1:${dashPort}/dashboard'))){ try{ (Invoke-WebRequest $x[1] -UseBasicParsing -TimeoutSec 6) | Out-Null; Write-Host (' {0,-22} DANG CHAY' -f $x[0]) -ForegroundColor Green } catch { Write-Host (' {0,-22} TAT' -f $x[0]) -ForegroundColor Red } }"\n` +
112
+ 'echo.\n' +
113
+ 'echo Bot chay AN, khong co cua so nao de lo tay dong.\n' +
114
+ 'echo Bot chi tat khi ban DANG XUAT / TAT may, hoac bam "3 - DUNG BOT".\n' +
115
+ 'echo.\n' +
116
+ 'timeout /t 12 >nul\n',
117
+ );
118
+ }
119
+
120
+ function buildOpenUiCmd({ projectDir, setupPort }) {
121
+ return CRLF(
122
+ '@echo off\n' +
123
+ 'title Mo giao dien bot\n' +
124
+ 'color 0B\n' +
125
+ `cd /d ${projectDir}\n` +
126
+ 'echo.\n' +
127
+ 'echo ============================================\n' +
128
+ 'echo MO GIAO DIEN QUAN TRI BOT\n' +
129
+ 'echo ============================================\n' +
130
+ 'echo.\n' +
131
+ `powershell -NoProfile -Command "if (-not (Get-NetTCPConnection -LocalPort ${setupPort} -State Listen -EA SilentlyContinue)) { Write-Host ' Dang bat giao dien...'; Start-Process wscript -ArgumentList '${projectDir}\\run-hidden.vbs','${projectDir}\\setup-ui.cmd' -WindowStyle Hidden } else { Write-Host ' Giao dien dang chay san.' }"\n` +
132
+ 'echo Cho giao dien san sang...\n' +
133
+ `powershell -NoProfile -Command "for($i=0;$i -lt 30;$i++){ try{ Invoke-WebRequest 'http://127.0.0.1:${setupPort}/' -UseBasicParsing -TimeoutSec 3 | Out-Null; break } catch { Start-Sleep -Seconds 2 } }"\n` +
134
+ `start "" http://127.0.0.1:${setupPort}\n` +
135
+ 'echo.\n' +
136
+ 'echo Da mo trinh duyet. Dong cua so nay duoc roi.\n' +
137
+ 'timeout /t 8 >nul\n',
138
+ );
139
+ }
140
+
141
+ function buildStopBotCmd({ gatewayPort, routerPort }) {
142
+ return CRLF(
143
+ '@echo off\n' +
144
+ 'title Dung bot OpenClaw\n' +
145
+ 'color 0C\n' +
146
+ 'echo.\n' +
147
+ 'echo Dang dung bot va 9Router...\n' +
148
+ 'powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"Name=\'node.exe\'\\" | Where-Object { $_.CommandLine -like \'*openclaw*gateway*\' -or $_.CommandLine -like \'*9router*\' } | ForEach-Object { Write-Host (\' dung PID \' + $_.ProcessId); Stop-Process -Id $_.ProcessId -Force -EA SilentlyContinue }"\n' +
149
+ 'echo.\n' +
150
+ `powershell -NoProfile -Command "Start-Sleep -Seconds 3; foreach($x in @(@('Bot',${gatewayPort}),@('9Router',${routerPort}))){ $c=Get-NetTCPConnection -LocalPort $x[1] -State Listen -EA SilentlyContinue; if($c){ Write-Host (' {0,-10} VAN CHAY' -f $x[0]) -ForegroundColor Yellow } else { Write-Host (' {0,-10} da tat' -f $x[0]) -ForegroundColor Green } }"\n` +
151
+ 'echo.\n' +
152
+ 'pause\n',
153
+ );
154
+ }
155
+
156
+ function buildReadme({ projectDir, gatewayPort, routerPort, setupPort }) {
157
+ return CRLF(
158
+ 'BOT OPENCLAW TREN MAY NAY - HUONG DAN NHANH\n' +
159
+ '===========================================\n\n' +
160
+ 'Ba tep .cmd, bam dup la chay. Khong can go lenh.\n\n' +
161
+ ' 1 - KHOI DONG BOT.cmd Bat bot len. Bam sau moi lan khoi dong may.\n' +
162
+ ' 2 - MO GIAO DIEN.cmd Mo trang quan tri bot trong trinh duyet.\n' +
163
+ ' 3 - DUNG BOT.cmd Tat bot (chi dung khi can).\n\n' +
164
+ 'QUAN TRONG\n' +
165
+ '----------\n' +
166
+ '- Bot chay AN hoan toan: khong co cua so den nao nam tren man hinh.\n' +
167
+ '- Bot KHONG tu chay khi bat may. Sau moi lan khoi dong lai may,\n' +
168
+ ' bam "1 - KHOI DONG BOT" mot lan la xong.\n' +
169
+ '- Bot se tat khi ban DANG XUAT hoac TAT may.\n' +
170
+ '- Dat cac tep nay o dau cung duoc, chung deu neo ve ' + projectDir + '.\n\n' +
171
+ 'Cong dang dung:\n' +
172
+ ` ${gatewayPort} bot (gateway)\n` +
173
+ ` ${gatewayPort + 1} bang dieu khien Zalo\n` +
174
+ ` ${routerPort} 9Router\n` +
175
+ ` ${setupPort} giao dien quan tri\n`,
176
+ );
177
+ }
178
+
179
+ /** All launcher files for a native Windows project, as { relativeName: content }. */
180
+ function buildWindowsLaunchers({ projectDir, gatewayPort = 18789, routerPort = 20128, setupPort = 51789 }) {
181
+ const opts = { projectDir, gatewayPort, routerPort, setupPort };
182
+ return {
183
+ 'run-hidden.vbs': buildRunHiddenVbs(),
184
+ 'start-9router.cmd': build9RouterCmd(opts),
185
+ 'gateway-start.cmd': buildGatewayCmd(opts),
186
+ 'setup-ui.cmd': buildSetupUiCmd(opts),
187
+ '0 - DOC TRUOC.txt': buildReadme(opts),
188
+ '1 - KHOI DONG BOT.cmd': buildStartBotCmd(opts),
189
+ '2 - MO GIAO DIEN.cmd': buildOpenUiCmd(opts),
190
+ '3 - DUNG BOT.cmd': buildStopBotCmd(opts),
191
+ };
192
+ }
193
+
194
+ /** Which of the generated files belong on the Desktop rather than in the project folder. */
195
+ const WINDOWS_DESKTOP_LAUNCHERS = ['0 - DOC TRUOC.txt', '1 - KHOI DONG BOT.cmd', '2 - MO GIAO DIEN.cmd', '3 - DUNG BOT.cmd'];
196
+
197
+ const api = { buildWindowsLaunchers, WINDOWS_DESKTOP_LAUNCHERS };
198
+ if (typeof globalThis !== 'undefined') globalThis.__openclawWindowsLaunchers = api;
199
+ if (typeof exports !== 'undefined') Object.assign(exports, api);
package/dist/web/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const $ = (sel) => document.querySelector(sel);
2
- const state = { tab: 'dashboard', system: null, install: null, files: [], catalog: { skills: [], plugins: [] }, logs: [], zaloLoginOpen: false, zaloLoginLines: [], zaloQrDataUrl: '', lang: localStorage.getItem('openclaw-lang') || 'vi', theme: localStorage.getItem('openclaw-theme') || 'dark', tz: localStorage.getItem('openclaw-tz') || 'Asia/Ho_Chi_Minh', navCollapsed: localStorage.getItem('openclaw-nav')==='1', os: null, mode: null, donateOpen: false, botModalOpen: false, botEditId: '', installModalOpen: false, fbPluginModalOpen: false, installTab: 'docker', installDraft: null, pathModal: null, confirmModal: null, botChannel: 'telegram', botPane: 'list', activeBotId: '', selectedFile: '', botMessage: '', projectConnectMessage: '', pendingProjectDir: '', selectedProjectDir: '', featureFlags: {}, featureInstalled: {}, featureLoading: {}, featureLocked: {}, zaloBackend: '', zaloHealth: null, openDirs: {} };
2
+ const state = { tab: 'dashboard', system: null, install: null, files: [], catalog: { skills: [], plugins: [] }, logs: [], zaloLoginOpen: false, zaloLoginLines: [], zaloQrDataUrl: '', lang: localStorage.getItem('openclaw-lang') || 'vi', theme: localStorage.getItem('openclaw-theme') || 'dark', tz: localStorage.getItem('openclaw-tz') || 'Asia/Ho_Chi_Minh', navCollapsed: localStorage.getItem('openclaw-nav')==='1', os: null, mode: null, donateOpen: false, botModalOpen: false, botEditId: '', installModalOpen: false, fbPluginModalOpen: false, installTab: 'native', installDraft: null, pathModal: null, confirmModal: null, botChannel: 'telegram', botPane: 'list', activeBotId: '', selectedFile: '', botMessage: '', projectConnectMessage: '', pendingProjectDir: '', selectedProjectDir: '', featureFlags: {}, featureInstalled: {}, featureLoading: {}, featureLocked: {}, zaloBackend: '', zaloHealth: null, openDirs: {} };
3
3
  const SVG_CDN = 'https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons';
4
4
  const OS_OPTIONS = [
5
5
  { id: 'win', title: 'Windows', subtitle: 'Auto-detected desktop', icon: `${SVG_CDN}/windows/default.svg`, badge: 'Desktop' },
@@ -212,19 +212,24 @@ function installModal() {
212
212
  const sys = state.system || {};
213
213
  const draft = refreshInstallDraft();
214
214
  const os = draft.os || state.os || sys?.os || 'win';
215
- const mode = draft.mode || state.installTab || state.mode || sys?.recommendedMode || 'docker';
215
+ const mode = draft.mode || state.installTab || 'native';
216
216
  const pathExample = os === 'win' ? 'C:\\openclaw-setup' : os === 'macos' ? '/Users/you/openclaw-setup' : '/home/you/openclaw-setup';
217
217
  const osChoices = OS_OPTIONS.map(o => [o.id, t(o.title, o.title), trChoice(o).subtitle]);
218
+ // Docker is closed for NEW projects: openclaw >=2026.9 writes config through fs-safe, which
219
+ // fstat()s the file after an atomic rename \u2014 a check that cannot pass through a Docker Desktop
220
+ // bind mount on Windows, so the first image rebuild after that takes the gateway down for good
221
+ // (measured on win_kha, 09/09/2026). Existing docker projects keep running and move to native on
222
+ // their next update; the tile stays visible but disabled so the reason is on screen.
218
223
  const modeChoices = [
219
- ['docker', 'Docker', t('\u0043ontainer c\u00f4 l\u1eadp, an to\u00e0n nh\u1ea5t', 'Isolated containers, safest default')],
220
- ['native', 'Native', t('Ch\u1ea1y th\u1eb3ng tr\u00ean m\u00e1y n\u00e0y \u2014 kh\u00f4ng c\u1ea7n Docker, \u0111i\u1ec1u khi\u1ec3n \u0111\u01b0\u1ee3c app', 'Runs on this machine \u2014 no Docker, can drive desktop apps')],
224
+ ['native', 'Native', t('Ch\u1ea1y th\u1eb3ng tr\u00ean m\u00e1y n\u00e0y \u2014 kh\u00f4ng c\u1ea7n Docker, \u0111i\u1ec1u khi\u1ec3n \u0111\u01b0\u1ee3c app', 'Runs on this machine \u2014 no Docker, can drive desktop apps'), false],
225
+ ['docker', 'Docker', t('\u0110\u00e3 ng\u1eebng \u2014 OpenClaw 2026.9 kh\u00f4ng ghi \u0111\u01b0\u1ee3c c\u1ea5u h\u00ecnh qua Docker tr\u00ean Windows', 'Retired \u2014 OpenClaw 2026.9 cannot write config through Docker on Windows'), true],
221
226
  ];
222
227
  return `<div class="modal-backdrop install-backdrop" data-install-modal="close">
223
228
  <section class="donate-modal install-modal" role="dialog" aria-modal="true" aria-label="${t('T\u1ea1o Project','Create Project')}" onclick="event.stopPropagation()">
224
229
  <button class="modal-x" data-install-modal="close" aria-label="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
225
230
  <div class="donate-head"><span aria-hidden="true">+</span><div><p>${t('T\u1ea1o Project','Create Project')}</p><h2>${t('T\u1ea1o Project','Create Project')}</h2><small>${t('Ch\u1ecdn s\u1eb5n ch\u1ebf \u0111\u1ed9 \u1edf tab tr\u00ean. B\u00ean d\u01b0\u1edbi ch\u1ec9 c\u1ea7n ch\u1ecdn OS v\u00e0 nh\u1eadp ho\u1eb7c ch\u1ecdn \u0111\u01b0\u1eddng d\u1eabn project.','Mode stays in the tabs above. Below, choose OS and enter or pick the project path.')}</small></div></div>
226
231
  <form id="install-form" class="install-form">
227
- <div class="install-tabs">${modeChoices.map(([id,label,desc]) => `<button type="button" class="install-tab ${mode===id?'is-active':''}" data-install-set="mode" data-value="${id}"><strong>${escapeHtml(label)}</strong><small>${escapeHtml(desc)}</small></button>`).join('')}</div>
232
+ <div class="install-tabs">${modeChoices.map(([id,label,desc,locked]) => `<button type="button" class="install-tab ${mode===id?'is-active':''}${locked?' is-locked':''}" data-install-set="mode" data-value="${id}"${locked?' disabled aria-disabled="true"':''}><strong>${escapeHtml(label)}${locked?' 🔒':''}</strong><small>${escapeHtml(desc)}</small></button>`).join('')}</div>
228
233
  <div class="install-grid install-grid--compact">
229
234
  <div class="field wide"><span>${t('H\u1ec7 \u0111i\u1ec1u h\u00e0nh','Operating system')}</span>${pillGroup('os', os, osChoices)}<small>${t('\u0110\u00e3 ch\u1ecdn s\u1eb5n theo m\u00e1y \u0111ang ch\u1ea1y','Preselected from the current machine')}</small></div>
230
235
  <label class="field wide"><span>${t('Đường dẫn project','Project path')}</span><input name="projectDir" placeholder="${escapeHtml(pathExample)}" value="${escapeHtml(draft.projectDir || pathExample)}" /><small>${t('Ví dụ: C:\\openclaw-setup hoặc /home/you/openclaw-setup. Bạn có thể tự sửa tên folder bot thành tên bất kỳ.','Example: C:\\openclaw-setup or /home/you/openclaw-setup. You can rename folder bot to any name.')}</small></label>
@@ -1546,7 +1551,7 @@ document.querySelectorAll('[data-project-pick-folder]').forEach(btn => btn.oncli
1546
1551
  });
1547
1552
  $('#install')?.addEventListener('click', () => {
1548
1553
  state.installModalOpen = true;
1549
- state.installTab = document.querySelector('input[name=mode]:checked')?.value || state.mode || state.system?.recommendedMode || 'docker';
1554
+ state.installTab = document.querySelector('input[name=mode]:checked')?.value || 'native';
1550
1555
  const os = document.querySelector('input[name=os]:checked')?.value || state.os || state.system?.os || 'win';
1551
1556
  const defaultDir = os === 'win' ? 'E:\\bot' : os === 'macos' ? '/Users/you/openclaw-bot' : '/home/you/openclaw-bot';
1552
1557
 
@@ -2060,3 +2060,4 @@ body:has(.modal-backdrop) .bottom{display:none!important}
2060
2060
  .feature-switch span{width:42px;height:24px}
2061
2061
  .feature-switch span:after{top:2px;left:2px;width:18px;height:18px}
2062
2062
  .feature-switch input:checked + span:after{left:22px}
2063
+ .install-tab.is-locked{opacity:.45;cursor:not-allowed;filter:grayscale(1)}.install-tab.is-locked:hover{transform:none;border-color:var(--hair)}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-openclaw-bot",
3
- "version": "5.16.9",
3
+ "version": "5.17.0",
4
4
  "description": "Interactive CLI installer for OpenClaw Bot",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {