create-openclaw-bot 5.15.0 → 5.15.2

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
@@ -7,7 +7,7 @@
7
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.15.0-0EA5E9?style=for-the-badge" alt="Version 5.15.0" /></a>
10
+ <a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.15.2-0EA5E9?style=for-the-badge" alt="Version 5.15.2" /></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.15.0
40
+ ## 🆕 What's New in v5.15.2
41
41
 
42
42
  - 🖥️ **"Control PC" — let the bot open Chrome & apps on your machine**: a new toggle on each bot card turns on the ability for the bot to open Chrome or an allow-listed app (TeamViewer, Zalo, …) on the computer running it. **Off by default**, token-gated, allow-list only, desktop-only.
43
43
  - 🌐 **Browsing works out of the box on every OS**: the bot drives your real Chrome on a desktop (your logged-in profile, so sites don't flag it) and starts its own headless Chromium on a server — no more "no browser available" just because Chrome wasn't running.
package/README.vi.md CHANGED
@@ -7,7 +7,7 @@
7
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.*
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.15.0-0EA5E9?style=for-the-badge" alt="Version 5.15.0" /></a>
10
+ <a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.15.2-0EA5E9?style=for-the-badge" alt="Version 5.15.2" /></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.15.0
40
+ ## 🆕 Có gì mới trong v5.15.2
41
41
 
42
42
  - 🖥️ **"Điều khiển máy" — cho bot mở Chrome & ứng dụng trên máy bạn**: nút mới trên thẻ mỗi bot, bật lên là bot mở được Chrome hoặc app trong danh sách cho phép (TeamViewer, Zalo, …) trên máy đang chạy bot. **Mặc định TẮT**, cần token, chỉ giới hạn danh sách bạn khai, chỉ dùng trên máy có màn hình.
43
43
  - 🌐 **Duyệt web chạy ngay trên mọi hệ điều hành**: máy để bàn thì bot dùng **Chrome thật** (profile đã đăng nhập, web ít nghi là bot), server thì tự mở Chromium headless — hết cảnh "không có trình duyệt" chỉ vì chưa mở Chrome.
@@ -1,5 +1,5 @@
1
1
  import http from 'http';
2
- import fs, { createReadStream, existsSync, promises as fsp } from 'fs';
2
+ import fs, { createReadStream, existsSync, readFileSync, promises as fsp } from 'fs';
3
3
  import { createRequire } from 'module';
4
4
  import { basename, dirname, extname, join, normalize, resolve } from 'path';
5
5
  import { fileURLToPath } from 'url';
@@ -24,7 +24,7 @@ const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
24
24
  // scripts) runs a dedicated profile seeded from the operator's real one. Kept outside Chrome's
25
25
  // own folders: the block is an exact match on the default directory, and a sibling of it is a
26
26
  // needless bet on that staying true.
27
- const CHROME_SCRIPT_MARKER = 'OPENCLAW_CHROME_PROFILE_V2';
27
+ const CHROME_SCRIPT_MARKER = 'OPENCLAW_CHROME_PROFILE_V3';
28
28
  const CHROME_DEBUG_PROFILE_LEAF_WIN = 'OpenClaw\\chrome-profile';
29
29
  const CHROME_DEBUG_PROFILE_LEAF_MAC = 'Library/Application Support/OpenClaw/chrome-profile';
30
30
  const CHROME_DEBUG_PROFILE_LEAF_LINUX = '.config/openclaw/chrome-profile';
@@ -61,6 +61,58 @@ async function syncExecApprovals(projectDir, cfg) {
61
61
  await fsp.writeFile(path2, JSON.stringify(approvals, null, 2), 'utf8');
62
62
  }
63
63
 
64
+ /**
65
+ * Write files into the bot container's plugin folder. Needed when `.openclaw/extensions` is a
66
+ * Docker named volume: the host sees an empty directory, so there is nothing on disk to patch.
67
+ * Best-effort — a stopped container or a project without Docker just yields 0.
68
+ */
69
+ async function pushBrowserScriptsIntoContainer(projectDir, aliases, files, sendLog = () => {}) {
70
+ if (isNativeProject(projectDir)) return 0;
71
+ const container = getBotContainerName(projectDir);
72
+ if (!container) return 0;
73
+ const running = await runCapture('docker', ['inspect', '-f', '{{.State.Running}}', container], { shell: false, timeout: 8000 }).catch(() => null);
74
+ if (String(running?.stdout || '').trim() !== 'true') return 0;
75
+
76
+ const homeOut = await runCapture('docker', ['exec', container, 'sh', '-c', 'echo "${OPENCLAW_HOME:-/home/node/project/.openclaw}"'], { shell: false, timeout: 8000 }).catch(() => null);
77
+ const openclawHome = String(homeOut?.stdout || '').trim() || '/home/node/project/.openclaw';
78
+
79
+ let pushed = 0;
80
+ for (const alias of aliases) {
81
+ const dir = `${openclawHome}/extensions/${alias}`;
82
+ const check = await runCapture('docker', ['exec', container, 'sh', '-c', `[ -d "${dir}" ] && echo yes || echo no`], { shell: false, timeout: 8000 }).catch(() => null);
83
+ if (String(check?.stdout || '').trim() !== 'yes') continue;
84
+ for (const [name, content, mode] of files) {
85
+ const tmp = join(os.tmpdir(), `openclaw-${Date.now()}-${name}`);
86
+ try {
87
+ await fsp.writeFile(tmp, content, 'utf8');
88
+ // runCapture, not run: run() forces a shell on Windows, and the temp path goes through
89
+ // a home directory that usually has a space in it ("VT 2025") — cmd then splits it and
90
+ // docker cp fails with a usage error.
91
+ const cp = await runCapture('docker', ['cp', tmp, `${container}:${dir}/${name}`], { shell: false, timeout: 20000 });
92
+ if (cp.code !== 0) throw new Error(String(cp.stderr || cp.stdout || 'docker cp failed').trim());
93
+ // A world-writable file makes OpenClaw refuse to load the plugin, and a copy landing
94
+ // from a Windows host is exactly that.
95
+ await runCapture('docker', ['exec', container, 'sh', '-c', `chmod ${mode} "${dir}/${name}"`], { shell: false, timeout: 20000 });
96
+ pushed += 1;
97
+ } catch (err) {
98
+ sendLog(`[browser] Could not push ${name} into ${container}: ${err.message}`);
99
+ } finally {
100
+ await fsp.rm(tmp, { force: true }).catch(() => {});
101
+ }
102
+ }
103
+ }
104
+ return pushed;
105
+ }
106
+
107
+ /**
108
+ * The browser-automation plugin defaults every high-impact behaviour to off (ClawHub's review reads
109
+ * broad defaults as "rogue agent", fairly). A dashboard install is an explicit, informed action, so
110
+ * the flags are turned on here — and stay visible in openclaw.json for anyone who wants them off.
111
+ */
112
+ function browserAutomationOptIns() {
113
+ return { patchDocker: true, allowPageScripting: true, allowFileUpload: true };
114
+ }
115
+
64
116
  async function patchBrowserAutomationHostPreference(projectDir, aliases = [], sendLog = () => {}) {
65
117
  const preferredCdpBlock = `const dns = require('dns').promises;
66
118
  const DEFAULT_CDP_URLS = [
@@ -157,9 +209,20 @@ async function connectPreferredChrome() {
157
209
  // the same way and does not care.
158
210
  'ping -n 3 127.0.0.1 >nul',
159
211
  '',
212
+ // Copying the real profile duplicates cookies, logins, history and extensions into a second
213
+ // directory and has to close every Chrome window to do it. That is the operator's call, not
214
+ // a silent default: without OPENCLAW_CHROME_SEED_PROFILE=1 this starts a clean profile and
215
+ // they sign in once, in the window that opens.
160
216
  'if not exist "%OPENCLAW_CHROME_PROFILE_DIR%\\Default" (',
161
217
  // No parentheses in text inside an if-block: cmd closes the block on the first ")".
162
- ' echo Lan dau: dang dong Chrome de chep profile - cookie, dang nhap...',
218
+ ' echo Chua co profile dieu khien - se tao MOI va ban dang nhap 1 lan trong cua so vua mo.',
219
+ ' echo Muon dung san dang nhap cua Chrome thuong thi dat OPENCLAW_CHROME_SEED_PROFILE=1 roi chay lai.',
220
+ ' echo Luu y: viec do CHEP cookie, dang nhap, lich su, extension sang profile dieu khien',
221
+ ' echo va phai DONG het cua so Chrome dang mo de chep.',
222
+ ')',
223
+ '',
224
+ 'if not exist "%OPENCLAW_CHROME_PROFILE_DIR%\\Default" if "%OPENCLAW_CHROME_SEED_PROFILE%"=="1" (',
225
+ ' echo Dang dong Chrome de chep profile theo yeu cau cua ban...',
163
226
  ' taskkill /F /IM chrome.exe >nul 2>&1',
164
227
  ' ping -n 4 127.0.0.1 >nul',
165
228
  ' echo Dang chep profile Chrome that sang "%OPENCLAW_CHROME_PROFILE_DIR%" ...',
@@ -171,7 +234,9 @@ async function connectPreferredChrome() {
171
234
  'echo Dang mo Chrome - profile: %OPENCLAW_CHROME_PROFILE_DIR%',
172
235
  'start "" "%CHROME_BIN%" ^',
173
236
  ' --remote-debugging-port=9222 ^',
174
- ' --remote-allow-origins=* ^',
237
+ // Loopback only, and no --remote-allow-origins=*: a CDP client written in Node sends no
238
+ // Origin header, so the wildcard bought nothing and only widened who could drive Chrome.
239
+ ' --remote-debugging-address=127.0.0.1 ^',
175
240
  ' --user-data-dir="%OPENCLAW_CHROME_PROFILE_DIR%" ^',
176
241
  ' --profile-directory=Default ^',
177
242
  ' --no-first-run ^',
@@ -211,7 +276,8 @@ async function connectPreferredChrome() {
211
276
  'fi',
212
277
  '',
213
278
  '# Chrome 136+ refuses --remote-debugging-port on the default profile directory, so run a',
214
- '# dedicated one seeded from the real profile (keeps cookies, logins, history, extensions).',
279
+ '# dedicated one. It starts empty: copying the real profile duplicates cookies and logins,',
280
+ '# so it only happens when the operator asks with OPENCLAW_CHROME_SEED_PROFILE=1.',
215
281
  ': "${OPENCLAW_CHROME_PROFILE_DIR:=$DEFAULT_DEBUG_PROFILE}"',
216
282
  '',
217
283
  'echo "Using: $CHROME_BIN"',
@@ -219,21 +285,28 @@ async function connectPreferredChrome() {
219
285
  'pkill -f -- "--remote-debugging-port=9222" 2>/dev/null || true',
220
286
  'sleep 2',
221
287
  '',
222
- 'if [ ! -d "$OPENCLAW_CHROME_PROFILE_DIR/Default" ] && [ -d "$REAL_PROFILE/Default" ]; then',
223
- ' echo "First run: copying the real Chrome profile into $OPENCLAW_CHROME_PROFILE_DIR ..."',
224
- ' mkdir -p "$OPENCLAW_CHROME_PROFILE_DIR/Default"',
225
- ' cp -R "$REAL_PROFILE/Default/." "$OPENCLAW_CHROME_PROFILE_DIR/Default/" 2>/dev/null || true',
226
- ' cp -f "$REAL_PROFILE/Local State" "$OPENCLAW_CHROME_PROFILE_DIR/Local State" 2>/dev/null || true',
227
- ` for junk in ${chromeProfileCacheJunk.map((n) => `"${n}"`).join(' ')}; do`,
228
- ' rm -rf "$OPENCLAW_CHROME_PROFILE_DIR/Default/$junk"',
229
- ' done',
288
+ 'if [ ! -d "$OPENCLAW_CHROME_PROFILE_DIR/Default" ]; then',
289
+ ' if [ "$OPENCLAW_CHROME_SEED_PROFILE" = "1" ] && [ -d "$REAL_PROFILE/Default" ]; then',
290
+ ' echo "Copying your real Chrome profile into $OPENCLAW_CHROME_PROFILE_DIR as requested"',
291
+ ' echo "(cookies, logins, history and extensions are duplicated into that directory)."',
292
+ ' mkdir -p "$OPENCLAW_CHROME_PROFILE_DIR/Default"',
293
+ ' cp -R "$REAL_PROFILE/Default/." "$OPENCLAW_CHROME_PROFILE_DIR/Default/" 2>/dev/null || true',
294
+ ' cp -f "$REAL_PROFILE/Local State" "$OPENCLAW_CHROME_PROFILE_DIR/Local State" 2>/dev/null || true',
295
+ ` for junk in ${chromeProfileCacheJunk.map((n) => `"${n}"`).join(' ')}; do`,
296
+ ' rm -rf "$OPENCLAW_CHROME_PROFILE_DIR/Default/$junk"',
297
+ ' done',
298
+ ' else',
299
+ ' echo "Starting a clean automation profile - sign in once in the window that opens."',
300
+ ' echo "To reuse your existing logins instead: OPENCLAW_CHROME_SEED_PROFILE=1 $0"',
301
+ ' echo "(that copies cookies, logins, history and extensions into the automation profile)."',
302
+ ' fi',
230
303
  'fi',
231
304
  'mkdir -p "$OPENCLAW_CHROME_PROFILE_DIR"',
232
305
  '',
233
306
  'echo "Starting Chrome (profile: $OPENCLAW_CHROME_PROFILE_DIR)..."',
234
307
  '"$CHROME_BIN" \\',
235
308
  ' --remote-debugging-port=9222 \\',
236
- ' --remote-allow-origins=* \\',
309
+ ' --remote-debugging-address=127.0.0.1 \\',
237
310
  ' --user-data-dir="$OPENCLAW_CHROME_PROFILE_DIR" \\',
238
311
  ' --profile-directory=Default \\',
239
312
  ' --no-first-run \\',
@@ -300,9 +373,10 @@ async function connectPreferredChrome() {
300
373
 
301
374
  // Patch the plugin's own copies too: the plugin re-syncs its skill folder into every
302
375
  // workspace on startup, so an unpatched source would undo the profile change below.
376
+ const sourceScriptNames = ['start-chrome-debug.bat', 'start-chrome-debug.sh'];
303
377
  let scriptsPatched = 0;
304
378
  for (const dir of extensionDirs) {
305
- for (const name of ['start-chrome-debug.bat', 'start-chrome-debug.sh']) {
379
+ for (const name of sourceScriptNames) {
306
380
  const file = join(dir, name);
307
381
  if (!existsSync(file)) continue;
308
382
  const content = await fsp.readFile(file, 'utf8');
@@ -314,7 +388,22 @@ async function connectPreferredChrome() {
314
388
  }
315
389
  }
316
390
  if (scriptsPatched > 0) {
317
- sendLog(`[browser] Patched ${scriptsPatched} start-chrome script(s) to use the real Chrome profile (set OPENCLAW_CHROME_PROFILE_DIR to override).`);
391
+ sendLog(`[browser] Patched ${scriptsPatched} start-chrome script(s) to use a dedicated Chrome profile seeded from yours (set OPENCLAW_CHROME_PROFILE_DIR to override).`);
392
+ }
393
+
394
+ // Some Docker projects mount `.openclaw/extensions` as a named volume, so the plugin's own
395
+ // files exist only inside the container. Every host path above then quietly finds nothing,
396
+ // the plugin re-syncs its stale script into the workspaces on each boot, and the operator
397
+ // keeps running the version that cannot open the debug port. Push the script in through the
398
+ // container instead.
399
+ if (!extensionDirs.some((dir) => existsSync(join(dir, sourceScriptNames[0])) || existsSync(join(dir, sourceScriptNames[1])))) {
400
+ const pushed = await pushBrowserScriptsIntoContainer(projectDir, aliases, [
401
+ ['start-chrome-debug.bat', startChromeBat, '644'],
402
+ ['start-chrome-debug.sh', startChromeSh, '755'],
403
+ ], sendLog);
404
+ if (pushed > 0) {
405
+ sendLog(`[browser] Extensions live in a Docker volume — pushed ${pushed} start-chrome script(s) into the container so the plugin delivers the current one.`);
406
+ }
318
407
  }
319
408
 
320
409
  const browserMd = `# Browser Automation
@@ -366,12 +455,10 @@ Do not call \`search-tool.js\`; browser-automation does not own search. Use \`we
366
455
  const hostOs = normalizeHostOs(await resolveProjectHostOs(projectDir));
367
456
  const shouldKeepBat = hostOs === 'win';
368
457
  // Shipped as start-chrome-debug.* by the plugin; delivered to the workspace as
369
- // start-chrome.* — it launches the operator's normal Chrome (with the debug port open),
370
- // so "debug" in the name only ever made people think it was a developer-only thing.
371
- const sourceName = shouldKeepBat ? 'start-chrome-debug.bat' : 'start-chrome-debug.sh';
458
+ // start-chrome.* — it launches Chrome with the debug port open, so "debug" in the name only
459
+ // ever made people think it was a developer-only thing.
372
460
  const scriptToKeep = shouldKeepBat ? 'start-chrome.bat' : 'start-chrome.sh';
373
461
  const legacyScripts = ['start-chrome-debug.bat', 'start-chrome-debug.sh', shouldKeepBat ? 'start-chrome.sh' : 'start-chrome.bat'];
374
- const sourceScript = extensionDirs.map((dir) => join(dir, sourceName)).find((file) => existsSync(file));
375
462
  const sourceBrowserTool = extensionDirs.map((dir) => join(dir, 'browser-tool.js')).find((file) => existsSync(file));
376
463
 
377
464
  let sanitized = 0;
@@ -396,16 +483,12 @@ Do not call \`search-tool.js\`; browser-automation does not own search. Use \`we
396
483
  if (next !== content) await fsp.writeFile(targetBrowserTool, next, 'utf8');
397
484
  }
398
485
  }
399
- if (sourceScript) {
400
- const targetScript = join(pluginSkillPath, scriptToKeep);
401
- await fsp.copyFile(sourceScript, targetScript).catch(() => {});
402
- if (existsSync(targetScript)) {
403
- const scriptContent = await fsp.readFile(targetScript, 'utf8');
404
- const nextScript = patchChromeDebugScript(scriptContent, scriptToKeep.endsWith('.bat'));
405
- if (nextScript !== scriptContent) await fsp.writeFile(targetScript, nextScript, 'utf8');
406
- }
407
- if (scriptToKeep.endsWith('.sh')) await fsp.chmod(targetScript, 0o755).catch(() => {});
408
- }
486
+ // Written from the generator, not copied from the plugin: on a project whose extensions
487
+ // folder is a Docker volume there is no host copy to read, and the workspace would be left
488
+ // without a starter at all.
489
+ const targetScript = join(pluginSkillPath, scriptToKeep);
490
+ await fsp.writeFile(targetScript, scriptToKeep.endsWith('.bat') ? startChromeBat : startChromeSh, 'utf8');
491
+ if (scriptToKeep.endsWith('.sh')) await fsp.chmod(targetScript, 0o755).catch(() => {});
409
492
  await fsp.writeFile(join(pluginSkillPath, 'BROWSER.md'), browserMd, 'utf8');
410
493
  for (const dirName of ['cl-stealth-search', 'openclaw-smart-search']) {
411
494
  await fsp.rm(join(workspacePath, 'plugin-skills', dirName), { recursive: true, force: true }).catch(() => {});
@@ -637,17 +720,58 @@ function run(cmd, args, opts = {}) {
637
720
  });
638
721
  }
639
722
 
723
+ // Where npm puts global packages, derived from the running node binary so we never pay for
724
+ // `npm root -g` (~100ms) just to find a version string.
725
+ function globalNodeModulesDirs() {
726
+ const dirs = [];
727
+ const nodeDir = dirname(process.execPath);
728
+ dirs.push(join(nodeDir, '..', 'lib', 'node_modules')); // unix prefix layout
729
+ dirs.push(join(nodeDir, 'node_modules')); // windows npm prefix
730
+ if (process.env.APPDATA) dirs.push(join(process.env.APPDATA, 'npm', 'node_modules'));
731
+ dirs.push('/usr/local/lib/node_modules', '/usr/lib/node_modules', '/opt/homebrew/lib/node_modules');
732
+ return dirs;
733
+ }
734
+
735
+ // `9router --version` boots the whole CLI and takes ~4 SECONDS on a normal machine. /api/system
736
+ // used to pay that on every single call — and the UI calls it after every action, so the whole
737
+ // dashboard felt slow for one version string. The version is right there in package.json.
738
+ function readGlobalPackageVersion(name) {
739
+ for (const dir of globalNodeModulesDirs()) {
740
+ const pkg = join(dir, name, 'package.json');
741
+ try {
742
+ if (!existsSync(pkg)) continue;
743
+ const version = String(JSON.parse(readFileSync(pkg, 'utf8')).version || '').trim();
744
+ if (version) return version;
745
+ } catch {}
746
+ }
747
+ return '';
748
+ }
749
+
640
750
  async function getCurrentRuntimeVersions() {
641
- const [openclaw, nineRouter, node] = await Promise.all([
642
- commandExists('openclaw', ['--version']),
643
- commandExists('9router', ['--version']),
644
- commandExists('node', ['--version']),
645
- ]);
646
- return {
647
- openclaw: openclaw.ok ? (openclaw.output.split(/\r?\n/)[0] || '').trim() : '',
648
- nineRouter: nineRouter.ok ? (nineRouter.output.split(/\r?\n/)[0] || '').trim() : '',
649
- node: node.ok ? (node.output.split(/\r?\n/)[0] || '').trim() : process.version,
751
+ const ck = 'hostver:runtimes';
752
+ const cached = probeCacheGet(ck);
753
+ if (cached) return cached;
754
+
755
+ const fromDisk = {
756
+ openclaw: readGlobalPackageVersion('openclaw'),
757
+ nineRouter: readGlobalPackageVersion('9router'),
758
+ node: process.version || '',
650
759
  };
760
+ // Only shell out for what disk did not answer — a global install in a prefix we do not know
761
+ // about, mostly. Still cached, so an odd layout costs the slow probe once, not every request.
762
+ const needCli = !fromDisk.openclaw || !fromDisk.nineRouter;
763
+ if (needCli) {
764
+ const [openclaw, nineRouter] = await Promise.all([
765
+ fromDisk.openclaw ? null : commandExists('openclaw', ['--version']),
766
+ fromDisk.nineRouter ? null : commandExists('9router', ['--version']),
767
+ ]);
768
+ if (openclaw?.ok) fromDisk.openclaw = (openclaw.output.split(/\r?\n/)[0] || '').trim();
769
+ if (nineRouter?.ok) fromDisk.nineRouter = (nineRouter.output.split(/\r?\n/)[0] || '').trim();
770
+ }
771
+ // Versions only change on an install/update, and those paths already call probeCacheClear().
772
+ // The TTL is just a backstop for a package installed behind this server's back.
773
+ probeCacheSet(ck, fromDisk, 10 * 60 * 1000);
774
+ return fromDisk;
651
775
  }
652
776
 
653
777
  // Per-project cache for EXPENSIVE runtime/version probes (docker exec + openclaw CLI). These
@@ -663,8 +787,35 @@ function probeCacheGet(key) {
663
787
  }
664
788
  function probeCacheSet(key, value, ttlMs = 0) { _probeCache.set(key, { value, exp: ttlMs ? Date.now() + ttlMs : 0 }); }
665
789
  function probeCacheClear(prefix = '') {
666
- if (!prefix) { _probeCache.clear(); return; }
790
+ if (!prefix) { _probeCache.clear(); _probeInflight.clear(); return; }
667
791
  for (const k of [..._probeCache.keys()]) if (k.startsWith(prefix)) _probeCache.delete(k);
792
+ for (const k of [..._probeInflight.keys()]) if (k.startsWith(prefix)) _probeInflight.delete(k);
793
+ }
794
+
795
+ // One probe per key at a time. The dashboard fires several requests at once and the startup
796
+ // prefetch runs alongside them, so without this the same docker/CLI round-trip ran two or three
797
+ // times over and every caller waited for the slowest copy. Serves a warm value immediately and
798
+ // refreshes in the background once it is half-stale, so a click never waits on a probe.
799
+ const _probeInflight = new Map();
800
+ function sharedProbe(key, ttlMs, compute) {
801
+ const cached = probeCacheGet(key);
802
+ if (cached) {
803
+ if (Date.now() - cached.at > ttlMs / 2 && !_probeInflight.has(key)) {
804
+ const bg = compute()
805
+ .then((value) => { probeCacheSet(key, { value, at: Date.now() }, ttlMs); return value; })
806
+ .finally(() => _probeInflight.delete(key));
807
+ _probeInflight.set(key, bg);
808
+ bg.catch(() => {});
809
+ }
810
+ return Promise.resolve(cached.value);
811
+ }
812
+ const existing = _probeInflight.get(key);
813
+ if (existing) return existing;
814
+ const run = compute()
815
+ .then((value) => { probeCacheSet(key, { value, at: Date.now() }, ttlMs); return value; })
816
+ .finally(() => _probeInflight.delete(key));
817
+ _probeInflight.set(key, run);
818
+ return run;
668
819
  }
669
820
 
670
821
  async function resolveProjectRuntimeVersions(projectDir, mode = state.mode || 'docker') {
@@ -2010,6 +2161,8 @@ async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, chan
2010
2161
  }
2011
2162
 
2012
2163
  async function startZaloLogin(projectDir, agentId = "") {
2164
+ // A fresh login changes what channels status reports; do not serve the cached "not connected".
2165
+ probeCacheClear(`zalohealth:${projectDir || ''}`);
2013
2166
  const cfgPath = join(projectDir, ".openclaw", "openclaw.json");
2014
2167
  if (!existsSync(cfgPath)) throw httpError(404, "openclaw.json not found");
2015
2168
  const cfg = JSON.parse(await fsp.readFile(cfgPath, "utf8"));
@@ -2268,7 +2421,17 @@ function buildZaloHealthSnapshot(cfg = {}, statusJson = null, credentialNames =
2268
2421
  // ── Zalo health snapshot for the dashboard ──────────────────────────────────────
2269
2422
  // Runtime JSON is authoritative and account-aware. Text parsing remains only as a
2270
2423
  // compatibility fallback for older OpenClaw builds.
2271
- async function getZaloHealth(projectDir) {
2424
+ // `openclaw channels status` is a CLI round-trip (docker exec on a container, or the host
2425
+ // gateway) and costs ~3 seconds. The dashboard asks for this on every page load and after every
2426
+ // action, so it is cached for a few seconds and refreshed in the background: a second visit is
2427
+ // instant, and the number on screen is never more than a few seconds stale. Login/restart paths
2428
+ // clear it (probeCacheClear) so a state change shows up immediately.
2429
+ const ZALO_HEALTH_TTL_MS = 4000;
2430
+ function getZaloHealth(projectDir) {
2431
+ return sharedProbe(`zalohealth:${projectDir || ''}`, ZALO_HEALTH_TTL_MS, () => computeZaloHealth(projectDir));
2432
+ }
2433
+
2434
+ async function computeZaloHealth(projectDir) {
2272
2435
  const meta = {
2273
2436
  supportedVersion: ZALO_CONNECT_VERSION,
2274
2437
  installedVersion: null,
@@ -2285,19 +2448,24 @@ async function getZaloHealth(projectDir) {
2285
2448
  if (meta.zaloModVersion) meta.zaloModInstalled = true;
2286
2449
 
2287
2450
  const botContainer = getBotContainerName(projectDir);
2451
+ const native = isNativeProject(projectDir);
2288
2452
  if (cfg.channels?.['zalo-connect']?.enabled) {
2289
2453
  const manifestHost = join(projectDir, '.openclaw', 'extensions', 'zalo-connect', 'openclaw.plugin.json');
2290
2454
  try {
2291
2455
  meta.installedVersion = JSON.parse(await fsp.readFile(manifestHost, 'utf8')).version || null;
2292
2456
  } catch {
2293
- try {
2294
- const r = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', 'cat "${OPENCLAW_HOME:-/home/node/project/.openclaw}/extensions/zalo-connect/openclaw.plugin.json" 2>/dev/null'], { cwd: projectDir, shell: false, timeout: 8000 });
2295
- meta.installedVersion = JSON.parse(String(r.stdout || '{}')).version || null;
2296
- } catch {}
2457
+ // Only Docker projects keep the manifest inside a container. A native project has no
2458
+ // container at all, and this fallback used to shell into one anyway seconds of waiting on
2459
+ // a `docker exec` that could never succeed, on a request the dashboard makes constantly.
2460
+ if (!native) {
2461
+ try {
2462
+ const r = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', 'cat "${OPENCLAW_HOME:-/home/node/project/.openclaw}/extensions/zalo-connect/openclaw.plugin.json" 2>/dev/null'], { cwd: projectDir, shell: false, timeout: 8000 });
2463
+ meta.installedVersion = JSON.parse(String(r.stdout || '{}')).version || null;
2464
+ } catch {}
2465
+ }
2297
2466
  }
2298
2467
  }
2299
2468
 
2300
- const native = isNativeProject(projectDir);
2301
2469
  let containerRunning = false;
2302
2470
  let statusJson = null;
2303
2471
  let textStatus = '';
@@ -2307,7 +2475,9 @@ async function getZaloHealth(projectDir) {
2307
2475
  // /health over loopback, then read channel status + credentials directly on the host.
2308
2476
  const nmeta = readNativeMeta(projectDir) || {};
2309
2477
  const port = String(nmeta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
2310
- containerRunning = await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500);
2478
+ // Loopback: a live gateway answers in milliseconds. The old 2.5s budget was pure waiting
2479
+ // whenever the runtime was down, on a request the dashboard makes on every page load.
2480
+ containerRunning = await probeHttpOk(`http://127.0.0.1:${port}/health`, 900);
2311
2481
  if (containerRunning) {
2312
2482
  try {
2313
2483
  const r = await ocCapture(projectDir, ['channels', 'status', '--json'], { timeout: 20000 });
@@ -3764,7 +3934,8 @@ async function ensureChromeRelay() {
3764
3934
  // Launch real host Chrome in remote-debugging mode (port 9222) so the browser-automation plugin
3765
3935
  // can drive the user's actual Chrome (logged-in profile) instead of headless Chromium. The bot
3766
3936
  // reaches it via CDP (host.docker.internal:9222 from the container). Detached: keeps running after
3767
- // this request. `--remote-allow-origins=*` is required by modern Chrome for cross-origin CDP.
3937
+ // this request. The debug port stays on loopback and no origin wildcard is passed — a Node CDP
3938
+ // client sends no Origin header, so the wildcard only widened who could drive the browser.
3768
3939
  // On a headless VPS there is no Chrome to open here — instead we start the bridge relay and hand
3769
3940
  // back copy-paste commands so the user runs Chrome on THEIR machine + a reverse SSH tunnel.
3770
3941
  // Where Chrome keeps the operator's own profile, per OS. Chrome must not already be running
@@ -3847,8 +4018,10 @@ async function startChromeDebug() {
3847
4018
  ok: true,
3848
4019
  headless: true,
3849
4020
  port: 9222,
3850
- chromeCmdMac: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --user-data-dir="$HOME/.openclaw-chrome-debug" --remote-allow-origins='*'`,
3851
- chromeCmdWin: `"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir=%TEMP%\\openclaw-chrome-debug --remote-allow-origins=*`,
4021
+ // Same dedicated profile directories the local button and the generated scripts use, so
4022
+ // an operator who has already run one of those keeps the session they signed in with.
4023
+ chromeCmdMac: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --user-data-dir="$HOME/${CHROME_DEBUG_PROFILE_LEAF_MAC}" --profile-directory=Default --remote-debugging-address=127.0.0.1`,
4024
+ chromeCmdWin: `"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\\${CHROME_DEBUG_PROFILE_LEAF_WIN}" --profile-directory=Default --remote-debugging-address=127.0.0.1`,
3852
4025
  tunnelCmd: `ssh -N -R 9222:127.0.0.1:9222 ${user}@${ip}`,
3853
4026
  };
3854
4027
  }
@@ -3867,12 +4040,20 @@ async function startChromeDebug() {
3867
4040
  // --enable-automation, so navigator.webdriver stays false and there is no banner.
3868
4041
  // Set OPENCLAW_CHROME_PROFILE_DIR to point somewhere else (anything but the default profile).
3869
4042
  const userDataDir = process.env.OPENCLAW_CHROME_PROFILE_DIR || debugChromeProfileDir();
3870
- await seedDebugChromeProfile(defaultChromeProfileDir(), userDataDir, sendLog);
4043
+ // Only when the operator asked for their logins: the copy duplicates cookies and sessions into
4044
+ // a second profile directory, which is not something to do on a button press by default.
4045
+ if (process.env.OPENCLAW_CHROME_SEED_PROFILE === '1') {
4046
+ await seedDebugChromeProfile(defaultChromeProfileDir(), userDataDir, sendLog);
4047
+ } else if (!existsSync(join(userDataDir, 'Default'))) {
4048
+ sendLog('[chrome] Mở Chrome với profile điều khiển trống — đăng nhập 1 lần trong cửa sổ vừa mở. Muốn dùng sẵn đăng nhập của Chrome thường thì đặt OPENCLAW_CHROME_SEED_PROFILE=1 (sẽ chép cookie/đăng nhập/lịch sử sang profile đó).');
4049
+ }
3871
4050
  const args = [
3872
4051
  `--remote-debugging-port=${port}`,
4052
+ // Loopback only, and no --remote-allow-origins=*: a Node CDP client sends no Origin header,
4053
+ // so the wildcard only widened who could drive this browser.
4054
+ '--remote-debugging-address=127.0.0.1',
3873
4055
  `--user-data-dir=${userDataDir}`,
3874
4056
  '--profile-directory=Default',
3875
- '--remote-allow-origins=*',
3876
4057
  '--no-first-run',
3877
4058
  '--no-default-browser-check',
3878
4059
  ];
@@ -4090,6 +4271,9 @@ async function listMarkdownFiles(projectDir, agentId = '') {
4090
4271
  }
4091
4272
 
4092
4273
  async function saveState(rootProjectDir) {
4274
+ // Selecting, adding or removing a project all end up here, and all of them make the cached
4275
+ // project list wrong — drop it so the next request rebuilds instead of showing the old set.
4276
+ probeCacheClear('projects:');
4093
4277
  const file = join(rootProjectDir, STATE_FILE);
4094
4278
  await fsp.writeFile(file, JSON.stringify({
4095
4279
  projectDir: state.projectDir,
@@ -4171,6 +4355,9 @@ function isRestrictedSystemDir(dirPath) {
4171
4355
  // so a fresh `npx github:…` run (e.g. on a VPS where bots are already running) targets the
4172
4356
  // live project instead of defaulting to an empty ~/openclaw-setup folder.
4173
4357
  async function discoverDockerBotProjectRoots() {
4358
+ const ck = 'dockerroots';
4359
+ const cached = probeCacheGet(ck);
4360
+ if (cached) return cached;
4174
4361
  const roots = [];
4175
4362
  try {
4176
4363
  const r = await runCapture(
@@ -4189,7 +4376,9 @@ async function discoverDockerBotProjectRoots() {
4189
4376
  }
4190
4377
  }
4191
4378
  } catch {}
4192
- return [...new Set(roots)];
4379
+ const unique = [...new Set(roots)];
4380
+ probeCacheSet(ck, unique, 15000);
4381
+ return unique;
4193
4382
  }
4194
4383
 
4195
4384
  // Native installs have no container to inspect, so we can't detect them the way Docker bots are
@@ -4288,7 +4477,15 @@ async function ensureProjectsLoaded(rootProjectDir) {
4288
4477
  }
4289
4478
  }
4290
4479
 
4291
- async function discoverProjects(rootProjectDir) {
4480
+ // The project list costs docker/native probes per project. It changes when someone creates or
4481
+ // deletes a project — not between two page loads — so serve it from a short cache and refresh in
4482
+ // the background: the dashboard opens instantly and is at most a few seconds stale.
4483
+ const PROJECTS_TTL_MS = 10000;
4484
+ function discoverProjects(rootProjectDir) {
4485
+ return sharedProbe(`projects:${rootProjectDir || ''}`, PROJECTS_TTL_MS, () => computeDiscoverProjects(rootProjectDir));
4486
+ }
4487
+
4488
+ async function computeDiscoverProjects(rootProjectDir) {
4292
4489
  await ensureProjectsLoaded(rootProjectDir);
4293
4490
 
4294
4491
  // Surface projects whose bot is running in Docker even if this install has no saved
@@ -4316,16 +4513,14 @@ async function discoverProjects(rootProjectDir) {
4316
4513
  }
4317
4514
  }
4318
4515
 
4319
- const updatedProjects = [];
4320
- for (const p of state.projects) {
4321
- if (existsSync(join(p.projectDir, '.openclaw', 'openclaw.json'))) {
4322
- const meta = await buildProjectMeta(p.projectDir).catch(() => null);
4323
- if (meta) {
4324
- updatedProjects.push(meta);
4325
- }
4326
- }
4327
- }
4328
- state.projects = updatedProjects;
4516
+ // In parallel: each buildProjectMeta runs runtime detection (docker calls, port probes), so a
4517
+ // handful of projects turned into seconds of dashboard load when this was a sequential loop.
4518
+ const metas = await Promise.all(
4519
+ state.projects
4520
+ .filter((p) => existsSync(join(p.projectDir, '.openclaw', 'openclaw.json')))
4521
+ .map((p) => buildProjectMeta(p.projectDir).catch(() => null)),
4522
+ );
4523
+ state.projects = metas.filter(Boolean);
4329
4524
 
4330
4525
  state.projects.sort((a, b) => {
4331
4526
  const aActive = state.projectDir && resolve(state.projectDir) === resolve(a.projectDir);
@@ -4914,6 +5109,11 @@ async function installFeature(projectDir, agentId, kind, id) {
4914
5109
  if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
4915
5110
  cfg.plugins.entries[existingKey].config = Object.assign({}, cfg.plugins.entries[existingKey].config, {
4916
5111
  hostOs: await resolveProjectHostOs(projectDir),
5112
+ // The plugin ships these off: editing the Docker build files, running page JavaScript
5113
+ // and uploading local files are things it will not do until an operator says so.
5114
+ // Installing it from this dashboard IS that operator saying so — otherwise browsing
5115
+ // would need a hand-edited config right after a one-click install.
5116
+ ...browserAutomationOptIns(),
4917
5117
  });
4918
5118
  cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
4919
5119
  cfg.tools.alsoAllow = Array.from(new Set([...(cfg.tools.alsoAllow || []), 'group:web']));
@@ -5013,6 +5213,11 @@ async function installFeature(projectDir, agentId, kind, id) {
5013
5213
  if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
5014
5214
  cfg.plugins.entries[existingKey].config = Object.assign({}, cfg.plugins.entries[existingKey].config, {
5015
5215
  hostOs: await resolveProjectHostOs(projectDir),
5216
+ // The plugin ships these off: editing the Docker build files, running page JavaScript
5217
+ // and uploading local files are things it will not do until an operator says so.
5218
+ // Installing it from this dashboard IS that operator saying so — otherwise browsing
5219
+ // would need a hand-edited config right after a one-click install.
5220
+ ...browserAutomationOptIns(),
5016
5221
  });
5017
5222
  cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
5018
5223
  cfg.tools.alsoAllow = Array.from(new Set([...(cfg.tools.alsoAllow || []), 'group:web']));
@@ -5868,6 +6073,15 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
5868
6073
  // Bring the host-control service back up when the operator left it enabled, so the bot's
5869
6074
  // saved instructions keep working across installer restarts.
5870
6075
  ensureHostControl(projectDir).catch(() => {});
6076
+ // Warm the probes the first page load would otherwise wait on (project list, runtime versions,
6077
+ // public IP, Zalo status). They run while the browser is still starting, so the dashboard opens
6078
+ // against a warm cache instead of paying for docker and CLI round-trips on first paint.
6079
+ Promise.all([
6080
+ discoverProjects(projectDir).catch(() => {}),
6081
+ getCurrentRuntimeVersions().catch(() => {}),
6082
+ getPublicIp().catch(() => {}),
6083
+ existsSync(join(projectDir, '.openclaw', 'openclaw.json')) ? getZaloHealth(projectDir).catch(() => {}) : null,
6084
+ ]).catch(() => {});
5871
6085
  }
5872
6086
 
5873
6087
  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 };
package/dist/web/app.js CHANGED
@@ -623,7 +623,7 @@ function wireSkillsHandlers(scope = document) {
623
623
  scope.querySelectorAll('[data-chrome-debug]').forEach(btn => btn.onclick = async () => {
624
624
  btn.disabled = true;
625
625
  try {
626
- const r = await api('/api/browser/start-chrome-debug', { method: 'POST', body: {} });
626
+ const r = await api('/api/browser/start-chrome', { method: 'POST', body: {} });
627
627
  if (r.headless) {
628
628
  // VPS has no display — guide the user to run Chrome on THEIR machine + a reverse tunnel.
629
629
  state.confirmModal = {
@@ -639,8 +639,10 @@ function wireSkillsHandlers(scope = document) {
639
639
  };
640
640
  render();
641
641
  } else {
642
- showToast(t('Đã mở Chrome debug', 'Chrome debug started'),
643
- `${t('Cổng', 'Port')} ${r.port} ${t('bot sẽ ưu tiên dùng Chrome này', 'the bot will prefer this Chrome')}`, 'success');
642
+ // The profile is a copy of the operator's own, so their normal Chrome can stay open —
643
+ // say so, because the old flow required closing every Chrome window first.
644
+ showToast(t('Đã mở Chrome cho bot', 'Chrome opened for the bot'),
645
+ `${t('Cổng', 'Port')} ${r.port} — ${t('cửa sổ Chrome mới này dùng bản sao profile của bạn (đã đăng nhập sẵn); Chrome thường của bạn cứ để nguyên.', 'the new window runs a copy of your profile (already signed in); your normal Chrome can stay open.')}`, 'success');
644
646
  }
645
647
  } catch (err) {
646
648
  showToast(t('Thất bại', 'Failed'), err.message, 'error');
@@ -1166,7 +1168,7 @@ function botSkillsPanel() {
1166
1168
  secs.push(`<button class="secondary icon-btn2 update-plugin-btn" type="button" data-feature-install="${key}" ${loading ? 'disabled' : ''} title="${t('Cập nhật lên bản mới nhất','Update to latest version')}" style="padding: 4px 8px; font-size: 11px; height: 28px; border-width: 1px; color:#ffb020; border-color: rgba(255,176,32,0.25); background: rgba(255,176,32,0.05);">${actionIcon('refresh')}<span>${t('Cập nhật','Update')}</span></button>`);
1167
1169
  }
1168
1170
  if (item.id === 'browser-automation' || item.id === 'openclaw-browser-automation') {
1169
- secs.push(`<button class="secondary icon-btn2" type="button" data-chrome-debug title="${t('Mở Chrome (chế độ debug) trên máy này để bot dùng trình duyệt thật (đã đăng nhập)','Open real Chrome in debug mode on this machine so the bot can use it')}" style="padding: 4px 8px; font-size: 11px; height: 28px; border-width: 1px; color:#4285F4; border-color: rgba(66,133,244,0.3); background: rgba(66,133,244,0.06);">${actionIcon('link')}<span>${t('Mở Chrome debug','Open Chrome debug')}</span></button>`);
1171
+ secs.push(`<button class="secondary icon-btn2" type="button" data-chrome-debug title="${t('Mở Chrome trên máy này cho bot dùng. Chạy bản sao profile của bạn nên vẫn đăng nhập sẵn, Chrome thường của bạn không bị đóng.','Open Chrome on this machine for the bot. It runs a copy of your profile, so it stays signed in, and your normal Chrome is left alone.')}" style="padding: 4px 8px; font-size: 11px; height: 28px; border-width: 1px; color:#4285F4; border-color: rgba(66,133,244,0.3); background: rgba(66,133,244,0.06);">${actionIcon('link')}<span>${t('Mở Chrome cho bot','Open Chrome for bot')}</span></button>`);
1170
1172
  }
1171
1173
  } else {
1172
1174
  secs.push(`<button class="secondary icon-btn2" type="button" data-feature-install="${key}" ${loading ? 'disabled' : ''}>${actionIcon('download')} ${ui('installVerb')}</button>`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-openclaw-bot",
3
- "version": "5.15.0",
3
+ "version": "5.15.2",
4
4
  "description": "Interactive CLI installer for OpenClaw Bot",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {