create-openclaw-bot 5.15.1 → 5.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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';
@@ -16,6 +16,7 @@ function loadSharedModule(modulePath, globalName) {
16
16
  const { buildWorkspaceFileMap, buildCronjobSkillMd, buildInfographicGeneratorSkillMd, buildInfographicGeneratorJs } = loadSharedModule('../setup/shared/workspace-gen.js', '__openclawWorkspace');
17
17
  const { buildOpenclawJson, buildEnvFileContent, buildExecApprovalsJson, buildZaloConnectChannelConfig } = loadSharedModule('../setup/shared/bot-config-gen.js', '__openclawBotConfig');
18
18
  const { buildDockerArtifacts } = loadSharedModule('../setup/shared/docker-gen.js', '__openclawDockerGen');
19
+ const { HOST_UI_PS1, HOST_UI_PS1_VERSION } = loadSharedModule('../setup/shared/host-ui-ps1.js', '__openclawHostUiPs1');
19
20
  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');
20
21
  const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
21
22
 
@@ -24,7 +25,7 @@ const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
24
25
  // scripts) runs a dedicated profile seeded from the operator's real one. Kept outside Chrome's
25
26
  // own folders: the block is an exact match on the default directory, and a sibling of it is a
26
27
  // needless bet on that staying true.
27
- const CHROME_SCRIPT_MARKER = 'OPENCLAW_CHROME_PROFILE_V2';
28
+ const CHROME_SCRIPT_MARKER = 'OPENCLAW_CHROME_PROFILE_V3';
28
29
  const CHROME_DEBUG_PROFILE_LEAF_WIN = 'OpenClaw\\chrome-profile';
29
30
  const CHROME_DEBUG_PROFILE_LEAF_MAC = 'Library/Application Support/OpenClaw/chrome-profile';
30
31
  const CHROME_DEBUG_PROFILE_LEAF_LINUX = '.config/openclaw/chrome-profile';
@@ -104,6 +105,15 @@ async function pushBrowserScriptsIntoContainer(projectDir, aliases, files, sendL
104
105
  return pushed;
105
106
  }
106
107
 
108
+ /**
109
+ * The browser-automation plugin defaults every high-impact behaviour to off (ClawHub's review reads
110
+ * broad defaults as "rogue agent", fairly). A dashboard install is an explicit, informed action, so
111
+ * the flags are turned on here — and stay visible in openclaw.json for anyone who wants them off.
112
+ */
113
+ function browserAutomationOptIns() {
114
+ return { patchDocker: true, allowPageScripting: true, allowFileUpload: true };
115
+ }
116
+
107
117
  async function patchBrowserAutomationHostPreference(projectDir, aliases = [], sendLog = () => {}) {
108
118
  const preferredCdpBlock = `const dns = require('dns').promises;
109
119
  const DEFAULT_CDP_URLS = [
@@ -200,9 +210,20 @@ async function connectPreferredChrome() {
200
210
  // the same way and does not care.
201
211
  'ping -n 3 127.0.0.1 >nul',
202
212
  '',
213
+ // Copying the real profile duplicates cookies, logins, history and extensions into a second
214
+ // directory and has to close every Chrome window to do it. That is the operator's call, not
215
+ // a silent default: without OPENCLAW_CHROME_SEED_PROFILE=1 this starts a clean profile and
216
+ // they sign in once, in the window that opens.
203
217
  'if not exist "%OPENCLAW_CHROME_PROFILE_DIR%\\Default" (',
204
218
  // No parentheses in text inside an if-block: cmd closes the block on the first ")".
205
- ' echo Lan dau: dang dong Chrome de chep profile - cookie, dang nhap...',
219
+ ' echo Chua co profile dieu khien - se tao MOI va ban dang nhap 1 lan trong cua so vua mo.',
220
+ ' echo Muon dung san dang nhap cua Chrome thuong thi dat OPENCLAW_CHROME_SEED_PROFILE=1 roi chay lai.',
221
+ ' echo Luu y: viec do CHEP cookie, dang nhap, lich su, extension sang profile dieu khien',
222
+ ' echo va phai DONG het cua so Chrome dang mo de chep.',
223
+ ')',
224
+ '',
225
+ 'if not exist "%OPENCLAW_CHROME_PROFILE_DIR%\\Default" if "%OPENCLAW_CHROME_SEED_PROFILE%"=="1" (',
226
+ ' echo Dang dong Chrome de chep profile theo yeu cau cua ban...',
206
227
  ' taskkill /F /IM chrome.exe >nul 2>&1',
207
228
  ' ping -n 4 127.0.0.1 >nul',
208
229
  ' echo Dang chep profile Chrome that sang "%OPENCLAW_CHROME_PROFILE_DIR%" ...',
@@ -214,7 +235,9 @@ async function connectPreferredChrome() {
214
235
  'echo Dang mo Chrome - profile: %OPENCLAW_CHROME_PROFILE_DIR%',
215
236
  'start "" "%CHROME_BIN%" ^',
216
237
  ' --remote-debugging-port=9222 ^',
217
- ' --remote-allow-origins=* ^',
238
+ // Loopback only, and no --remote-allow-origins=*: a CDP client written in Node sends no
239
+ // Origin header, so the wildcard bought nothing and only widened who could drive Chrome.
240
+ ' --remote-debugging-address=127.0.0.1 ^',
218
241
  ' --user-data-dir="%OPENCLAW_CHROME_PROFILE_DIR%" ^',
219
242
  ' --profile-directory=Default ^',
220
243
  ' --no-first-run ^',
@@ -254,7 +277,8 @@ async function connectPreferredChrome() {
254
277
  'fi',
255
278
  '',
256
279
  '# Chrome 136+ refuses --remote-debugging-port on the default profile directory, so run a',
257
- '# dedicated one seeded from the real profile (keeps cookies, logins, history, extensions).',
280
+ '# dedicated one. It starts empty: copying the real profile duplicates cookies and logins,',
281
+ '# so it only happens when the operator asks with OPENCLAW_CHROME_SEED_PROFILE=1.',
258
282
  ': "${OPENCLAW_CHROME_PROFILE_DIR:=$DEFAULT_DEBUG_PROFILE}"',
259
283
  '',
260
284
  'echo "Using: $CHROME_BIN"',
@@ -262,21 +286,28 @@ async function connectPreferredChrome() {
262
286
  'pkill -f -- "--remote-debugging-port=9222" 2>/dev/null || true',
263
287
  'sleep 2',
264
288
  '',
265
- 'if [ ! -d "$OPENCLAW_CHROME_PROFILE_DIR/Default" ] && [ -d "$REAL_PROFILE/Default" ]; then',
266
- ' echo "First run: copying the real Chrome profile into $OPENCLAW_CHROME_PROFILE_DIR ..."',
267
- ' mkdir -p "$OPENCLAW_CHROME_PROFILE_DIR/Default"',
268
- ' cp -R "$REAL_PROFILE/Default/." "$OPENCLAW_CHROME_PROFILE_DIR/Default/" 2>/dev/null || true',
269
- ' cp -f "$REAL_PROFILE/Local State" "$OPENCLAW_CHROME_PROFILE_DIR/Local State" 2>/dev/null || true',
270
- ` for junk in ${chromeProfileCacheJunk.map((n) => `"${n}"`).join(' ')}; do`,
271
- ' rm -rf "$OPENCLAW_CHROME_PROFILE_DIR/Default/$junk"',
272
- ' done',
289
+ 'if [ ! -d "$OPENCLAW_CHROME_PROFILE_DIR/Default" ]; then',
290
+ ' if [ "$OPENCLAW_CHROME_SEED_PROFILE" = "1" ] && [ -d "$REAL_PROFILE/Default" ]; then',
291
+ ' echo "Copying your real Chrome profile into $OPENCLAW_CHROME_PROFILE_DIR as requested"',
292
+ ' echo "(cookies, logins, history and extensions are duplicated into that directory)."',
293
+ ' mkdir -p "$OPENCLAW_CHROME_PROFILE_DIR/Default"',
294
+ ' cp -R "$REAL_PROFILE/Default/." "$OPENCLAW_CHROME_PROFILE_DIR/Default/" 2>/dev/null || true',
295
+ ' cp -f "$REAL_PROFILE/Local State" "$OPENCLAW_CHROME_PROFILE_DIR/Local State" 2>/dev/null || true',
296
+ ` for junk in ${chromeProfileCacheJunk.map((n) => `"${n}"`).join(' ')}; do`,
297
+ ' rm -rf "$OPENCLAW_CHROME_PROFILE_DIR/Default/$junk"',
298
+ ' done',
299
+ ' else',
300
+ ' echo "Starting a clean automation profile - sign in once in the window that opens."',
301
+ ' echo "To reuse your existing logins instead: OPENCLAW_CHROME_SEED_PROFILE=1 $0"',
302
+ ' echo "(that copies cookies, logins, history and extensions into the automation profile)."',
303
+ ' fi',
273
304
  'fi',
274
305
  'mkdir -p "$OPENCLAW_CHROME_PROFILE_DIR"',
275
306
  '',
276
307
  'echo "Starting Chrome (profile: $OPENCLAW_CHROME_PROFILE_DIR)..."',
277
308
  '"$CHROME_BIN" \\',
278
309
  ' --remote-debugging-port=9222 \\',
279
- ' --remote-allow-origins=* \\',
310
+ ' --remote-debugging-address=127.0.0.1 \\',
280
311
  ' --user-data-dir="$OPENCLAW_CHROME_PROFILE_DIR" \\',
281
312
  ' --profile-directory=Default \\',
282
313
  ' --no-first-run \\',
@@ -561,7 +592,18 @@ function detectOs() {
561
592
  const platform = process.platform;
562
593
  if (platform === 'win32') return 'win';
563
594
  if (platform === 'darwin') return 'macos';
564
- if (platform === 'linux') return os.release().toLowerCase().includes('microsoft') ? 'linux-desktop' : 'linux-desktop';
595
+ if (platform === 'linux') {
596
+ // WSL always has a Windows desktop behind it, so it counts as a desktop. Otherwise a session
597
+ // with no display server is a headless server, and the distinction is not cosmetic: 'vps' is
598
+ // what opens the gateway bind past loopback (bot-config-gen) — pick 'linux-desktop' on a VPS
599
+ // and the dashboard is only reachable through an SSH tunnel, while the browser tooling is set
600
+ // up as though a local Chrome existed.
601
+ if (os.release().toLowerCase().includes('microsoft')) return 'linux-desktop';
602
+ const sessionType = String(process.env.XDG_SESSION_TYPE || '').toLowerCase();
603
+ const hasDisplay = !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY || process.env.XDG_CURRENT_DESKTOP)
604
+ || sessionType === 'x11' || sessionType === 'wayland';
605
+ return hasDisplay ? 'linux-desktop' : 'vps';
606
+ }
565
607
  return 'linux-desktop';
566
608
  }
567
609
 
@@ -690,17 +732,58 @@ function run(cmd, args, opts = {}) {
690
732
  });
691
733
  }
692
734
 
735
+ // Where npm puts global packages, derived from the running node binary so we never pay for
736
+ // `npm root -g` (~100ms) just to find a version string.
737
+ function globalNodeModulesDirs() {
738
+ const dirs = [];
739
+ const nodeDir = dirname(process.execPath);
740
+ dirs.push(join(nodeDir, '..', 'lib', 'node_modules')); // unix prefix layout
741
+ dirs.push(join(nodeDir, 'node_modules')); // windows npm prefix
742
+ if (process.env.APPDATA) dirs.push(join(process.env.APPDATA, 'npm', 'node_modules'));
743
+ dirs.push('/usr/local/lib/node_modules', '/usr/lib/node_modules', '/opt/homebrew/lib/node_modules');
744
+ return dirs;
745
+ }
746
+
747
+ // `9router --version` boots the whole CLI and takes ~4 SECONDS on a normal machine. /api/system
748
+ // used to pay that on every single call — and the UI calls it after every action, so the whole
749
+ // dashboard felt slow for one version string. The version is right there in package.json.
750
+ function readGlobalPackageVersion(name) {
751
+ for (const dir of globalNodeModulesDirs()) {
752
+ const pkg = join(dir, name, 'package.json');
753
+ try {
754
+ if (!existsSync(pkg)) continue;
755
+ const version = String(JSON.parse(readFileSync(pkg, 'utf8')).version || '').trim();
756
+ if (version) return version;
757
+ } catch {}
758
+ }
759
+ return '';
760
+ }
761
+
693
762
  async function getCurrentRuntimeVersions() {
694
- const [openclaw, nineRouter, node] = await Promise.all([
695
- commandExists('openclaw', ['--version']),
696
- commandExists('9router', ['--version']),
697
- commandExists('node', ['--version']),
698
- ]);
699
- return {
700
- openclaw: openclaw.ok ? (openclaw.output.split(/\r?\n/)[0] || '').trim() : '',
701
- nineRouter: nineRouter.ok ? (nineRouter.output.split(/\r?\n/)[0] || '').trim() : '',
702
- node: node.ok ? (node.output.split(/\r?\n/)[0] || '').trim() : process.version,
763
+ const ck = 'hostver:runtimes';
764
+ const cached = probeCacheGet(ck);
765
+ if (cached) return cached;
766
+
767
+ const fromDisk = {
768
+ openclaw: readGlobalPackageVersion('openclaw'),
769
+ nineRouter: readGlobalPackageVersion('9router'),
770
+ node: process.version || '',
703
771
  };
772
+ // Only shell out for what disk did not answer — a global install in a prefix we do not know
773
+ // about, mostly. Still cached, so an odd layout costs the slow probe once, not every request.
774
+ const needCli = !fromDisk.openclaw || !fromDisk.nineRouter;
775
+ if (needCli) {
776
+ const [openclaw, nineRouter] = await Promise.all([
777
+ fromDisk.openclaw ? null : commandExists('openclaw', ['--version']),
778
+ fromDisk.nineRouter ? null : commandExists('9router', ['--version']),
779
+ ]);
780
+ if (openclaw?.ok) fromDisk.openclaw = (openclaw.output.split(/\r?\n/)[0] || '').trim();
781
+ if (nineRouter?.ok) fromDisk.nineRouter = (nineRouter.output.split(/\r?\n/)[0] || '').trim();
782
+ }
783
+ // Versions only change on an install/update, and those paths already call probeCacheClear().
784
+ // The TTL is just a backstop for a package installed behind this server's back.
785
+ probeCacheSet(ck, fromDisk, 10 * 60 * 1000);
786
+ return fromDisk;
704
787
  }
705
788
 
706
789
  // Per-project cache for EXPENSIVE runtime/version probes (docker exec + openclaw CLI). These
@@ -716,8 +799,35 @@ function probeCacheGet(key) {
716
799
  }
717
800
  function probeCacheSet(key, value, ttlMs = 0) { _probeCache.set(key, { value, exp: ttlMs ? Date.now() + ttlMs : 0 }); }
718
801
  function probeCacheClear(prefix = '') {
719
- if (!prefix) { _probeCache.clear(); return; }
802
+ if (!prefix) { _probeCache.clear(); _probeInflight.clear(); return; }
720
803
  for (const k of [..._probeCache.keys()]) if (k.startsWith(prefix)) _probeCache.delete(k);
804
+ for (const k of [..._probeInflight.keys()]) if (k.startsWith(prefix)) _probeInflight.delete(k);
805
+ }
806
+
807
+ // One probe per key at a time. The dashboard fires several requests at once and the startup
808
+ // prefetch runs alongside them, so without this the same docker/CLI round-trip ran two or three
809
+ // times over and every caller waited for the slowest copy. Serves a warm value immediately and
810
+ // refreshes in the background once it is half-stale, so a click never waits on a probe.
811
+ const _probeInflight = new Map();
812
+ function sharedProbe(key, ttlMs, compute) {
813
+ const cached = probeCacheGet(key);
814
+ if (cached) {
815
+ if (Date.now() - cached.at > ttlMs / 2 && !_probeInflight.has(key)) {
816
+ const bg = compute()
817
+ .then((value) => { probeCacheSet(key, { value, at: Date.now() }, ttlMs); return value; })
818
+ .finally(() => _probeInflight.delete(key));
819
+ _probeInflight.set(key, bg);
820
+ bg.catch(() => {});
821
+ }
822
+ return Promise.resolve(cached.value);
823
+ }
824
+ const existing = _probeInflight.get(key);
825
+ if (existing) return existing;
826
+ const run = compute()
827
+ .then((value) => { probeCacheSet(key, { value, at: Date.now() }, ttlMs); return value; })
828
+ .finally(() => _probeInflight.delete(key));
829
+ _probeInflight.set(key, run);
830
+ return run;
721
831
  }
722
832
 
723
833
  async function resolveProjectRuntimeVersions(projectDir, mode = state.mode || 'docker') {
@@ -815,6 +925,11 @@ function runCapture(cmd, args, opts = {}) {
815
925
  windowsHide: opts.windowsHide ?? true,
816
926
  env: { ...process.env, ...(opts.env || {}) },
817
927
  });
928
+ // Some callers need to feed stdin (pbcopy/xclip take the clipboard text that way).
929
+ if (opts.input != null) {
930
+ try { child.stdin.write(String(opts.input)); } catch (_) {}
931
+ try { child.stdin.end(); } catch (_) {}
932
+ }
818
933
  let timedOut = false;
819
934
  const timer = Number.isFinite(opts.timeout) && opts.timeout > 0
820
935
  ? setTimeout(() => {
@@ -1657,6 +1772,25 @@ function portStatus(port) {
1657
1772
  });
1658
1773
  }
1659
1774
 
1775
+ /**
1776
+ * First port at or after `start` that nothing on this host is listening on.
1777
+ *
1778
+ * The install-time allocator only knows about setup-managed projects, so it cannot see a docker
1779
+ * project from another install, an SSH tunnel forwarding a remote bot's ports, or any other
1780
+ * listener. Docker tolerates that (compose publishes into loopback and fails loudly on a clash);
1781
+ * native binds the host directly, so it has to ask the host.
1782
+ *
1783
+ * `reserveNext` also requires port+1 to be free — that is where the zalo-mod dashboard lands.
1784
+ */
1785
+ async function findFreeHostPort(start, { reserveNext = false, limit = 100 } = {}) {
1786
+ for (let port = start; port < start + limit; port++) {
1787
+ if ((await portStatus(port)) === 'online') continue;
1788
+ if (reserveNext && (await portStatus(port + 1)) === 'online') continue;
1789
+ return port;
1790
+ }
1791
+ return start;
1792
+ }
1793
+
1660
1794
  async function buildBotStatus() {
1661
1795
  if (state.projectDir) await syncRuntimeState(state.projectDir).catch(() => {});
1662
1796
  const [gatewayStatus, routerStatus, bots, runtimeVersions] = await Promise.all([
@@ -2006,6 +2140,35 @@ async function waitForDockerContainer(name, timeoutMs = 30000) {
2006
2140
  return false;
2007
2141
  }
2008
2142
 
2143
+ /**
2144
+ * Drop OpenClaw's boxed "Config warnings" banner (and any stray warning line) from CLI output.
2145
+ *
2146
+ * The banner prints on EVERY invocation and quotes the offending config keys verbatim, so a project
2147
+ * whose zalo-connect plugin is missing has `channels.zalo-connect: unknown channel id: zalo-connect`
2148
+ * in the output of *any* command. A readiness check that greps stdout for a channel id therefore
2149
+ * reports "channel loaded" precisely when the plugin is absent — the check inverts itself. Strip the
2150
+ * warnings before matching so only real command output counts.
2151
+ */
2152
+ function stripCliWarnings(text = '') {
2153
+ const kept = [];
2154
+ let inBanner = false;
2155
+ for (const line of String(text).split(/\r?\n/)) {
2156
+ if (/◇\s*Config warnings/.test(line)) { inBanner = true; continue; }
2157
+ // The banner is drawn as a box; its bottom edge is the only line starting with ├ or └.
2158
+ if (inBanner) {
2159
+ if (/^\s*[├└]/.test(line)) inBanner = false;
2160
+ continue;
2161
+ }
2162
+ if (/unknown channel id|plugin not found|stale config|no channel plugin is installed/i.test(line)) continue;
2163
+ kept.push(line);
2164
+ }
2165
+ return kept.join('\n');
2166
+ }
2167
+
2168
+ // Both keywords are load-bearing, and callers must not narrow them to the id alone: `channels
2169
+ // status` lists a loaded channel by its DISPLAY NAME ("OpenClaw Zalo Connect default: enabled, …"),
2170
+ // so the hyphenated id shows up only in the stale-config warnings stripCliWarnings now removes.
2171
+ // Match on the id alone and the check can never pass once the plugin is actually installed.
2009
2172
  async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 90000, channelKeywords = ['zalo-connect', 'openclaw zalo connect']) {
2010
2173
  const started = Date.now();
2011
2174
  // Use dynamic port from env: OPENCLAW_GATEWAY_PORT → OPENCLAW_PORT → fallback 18789
@@ -2020,7 +2183,7 @@ async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 900
2020
2183
  const status = String(out.stdout || '').trim();
2021
2184
  if (status === 'READY') {
2022
2185
  const pluginCheck = await runCapture('docker', ['exec', botContainer, 'sh', '-c', 'openclaw channels status 2>&1 || true'], { cwd: projectDir, shell: false });
2023
- const output = ((pluginCheck.stdout || '') + ' ' + (pluginCheck.stderr || '')).toLowerCase();
2186
+ const output = stripCliWarnings((pluginCheck.stdout || '') + '\n' + (pluginCheck.stderr || '')).toLowerCase();
2024
2187
  if (channelKeywords.some((kw) => output.includes(kw))) {
2025
2188
  ready = true;
2026
2189
  break;
@@ -2044,13 +2207,21 @@ async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, chan
2044
2207
  const started = Date.now();
2045
2208
  const meta = readNativeMeta(projectDir) || {};
2046
2209
  const port = String(meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
2210
+ const extDir = join(projectDir, '.openclaw', 'extensions', 'zalo-connect');
2047
2211
  let ready = false;
2048
2212
  let attempts = 0;
2049
2213
  while (Date.now() - started < timeoutMs) {
2050
2214
  attempts++;
2215
+ // Decisive, free, and immune to the warning-banner trap above: with no plugin folder the channel
2216
+ // cannot possibly be loaded, so return right away and let the caller install it instead of
2217
+ // burning the whole timeout waiting for something that will never appear.
2218
+ if (!existsSync(extDir)) {
2219
+ sendLog('[zalo-connect] Plugin folder .openclaw/extensions/zalo-connect is absent — not waiting.');
2220
+ return false;
2221
+ }
2051
2222
  if (await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500)) {
2052
2223
  const st = await ocCapture(projectDir, ['channels', 'status']).catch(() => ({ stdout: '', stderr: '' }));
2053
- const output = ((st.stdout || '') + ' ' + (st.stderr || '')).toLowerCase();
2224
+ const output = stripCliWarnings((st.stdout || '') + '\n' + (st.stderr || '')).toLowerCase();
2054
2225
  if (channelKeywords.some((kw) => output.includes(kw))) { ready = true; break; }
2055
2226
  if (attempts > 2) sendLog('[zalo-connect] Gateway healthy but Zalo Connect is not loaded yet (' + Math.round((Date.now() - started) / 1000) + 's)...');
2056
2227
  } else if (attempts > 2 && attempts % 3 === 0) {
@@ -2063,6 +2234,8 @@ async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, chan
2063
2234
  }
2064
2235
 
2065
2236
  async function startZaloLogin(projectDir, agentId = "") {
2237
+ // A fresh login changes what channels status reports; do not serve the cached "not connected".
2238
+ probeCacheClear(`zalohealth:${projectDir || ''}`);
2066
2239
  const cfgPath = join(projectDir, ".openclaw", "openclaw.json");
2067
2240
  if (!existsSync(cfgPath)) throw httpError(404, "openclaw.json not found");
2068
2241
  const cfg = JSON.parse(await fsp.readFile(cfgPath, "utf8"));
@@ -2098,20 +2271,17 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2098
2271
  // No container: the gateway runs as a managed service on the host. Wait for it to
2099
2272
  // report the zalo-connect channel; if it never does and the plugin folder is absent,
2100
2273
  // install it on the host (into this project's .openclaw/extensions) and reload.
2101
- const gatewayReady = await waitForNativeGatewayZaloReady(projectDir, 180000, ['zalo-connect']);
2274
+ const gatewayReady = await waitForNativeGatewayZaloReady(projectDir, 180000);
2102
2275
  if (!gatewayReady) {
2103
- const extDir = join(projectDir, '.openclaw', 'extensions', 'zalo-connect');
2104
- if (!existsSync(extDir)) {
2105
- sendLog(`[zalo-connect] Plugin missing installing ${ZALO_CONNECT_PLUGIN_SPEC} natively...`);
2106
- const inst = await ocCapture(projectDir, ['plugins', 'install', ZALO_CONNECT_PLUGIN_SPEC, '--force', '--acknowledge-clawhub-risk']);
2107
- const instOut = `${inst.stdout}\n${inst.stderr}`;
2108
- for (const line of instOut.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
2109
- if (/installed plugin/i.test(instOut) || existsSync(extDir)) {
2110
- await restartNativeRuntime(projectDir).catch((err) => sendLog(`[native] restart skipped/failed: ${err.message}`));
2111
- await waitForNativeGatewayZaloReady(projectDir, 180000, ['zalo-connect']);
2112
- } else {
2113
- sendLog('[zalo-connect] Cài plugin không thành công — thử lại bằng nút "Đăng nhập Zalo".');
2114
- }
2276
+ // ensureNativePlugins is the single place that knows what a native project owes itself, and
2277
+ // it skips whatever is already on disk — so this covers learning-memory too, and reconnects
2278
+ // on a healthy project cost nothing.
2279
+ const installed = await ensureNativePlugins(projectDir);
2280
+ if (installed.includes(ZALO_PLUGIN_ID)) {
2281
+ await restartNativeRuntime(projectDir).catch((err) => sendLog(`[native] restart skipped/failed: ${err.message}`));
2282
+ await waitForNativeGatewayZaloReady(projectDir, 180000);
2283
+ } else if (!existsSync(join(projectDir, '.openclaw', 'extensions', 'zalo-connect'))) {
2284
+ sendLog('[zalo-connect] Cài plugin không thành công — thử lại bằng nút "Đăng nhập Zalo".');
2115
2285
  }
2116
2286
  }
2117
2287
  } else {
@@ -2124,7 +2294,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2124
2294
  // backend-aware entrypoint existed).
2125
2295
  const containerUp = await waitForDockerContainer(botContainer, 90000);
2126
2296
  if (!containerUp) sendLog(`[zalo-connect] ${botContainer} chưa chạy sau 90s — vẫn thử tiếp...`);
2127
- const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
2297
+ const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000);
2128
2298
  if (!gatewayReady) {
2129
2299
  const check = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', '[ -d "${OPENCLAW_HOME:-/home/node/project/.openclaw}/extensions/zalo-connect" ] && echo OK || echo MISSING'], { cwd: projectDir, shell: false }).catch(() => ({ stdout: 'ERR' }));
2130
2300
  if (String(check.stdout || '').trim() === 'MISSING') {
@@ -2137,7 +2307,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2137
2307
  // Gateway must reload to pick the plugin up — safe here: the gateway is past
2138
2308
  // its boot (we only reach this branch when it answered the exec above).
2139
2309
  await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
2140
- await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
2310
+ await waitForGatewayZaloReady(botContainer, projectDir, 180000);
2141
2311
  } else {
2142
2312
  sendLog('[zalo-connect] Cài plugin không thành công — thử lại bằng nút "Đăng nhập Zalo" sau khi container ổn định.');
2143
2313
  }
@@ -2321,7 +2491,17 @@ function buildZaloHealthSnapshot(cfg = {}, statusJson = null, credentialNames =
2321
2491
  // ── Zalo health snapshot for the dashboard ──────────────────────────────────────
2322
2492
  // Runtime JSON is authoritative and account-aware. Text parsing remains only as a
2323
2493
  // compatibility fallback for older OpenClaw builds.
2324
- async function getZaloHealth(projectDir) {
2494
+ // `openclaw channels status` is a CLI round-trip (docker exec on a container, or the host
2495
+ // gateway) and costs ~3 seconds. The dashboard asks for this on every page load and after every
2496
+ // action, so it is cached for a few seconds and refreshed in the background: a second visit is
2497
+ // instant, and the number on screen is never more than a few seconds stale. Login/restart paths
2498
+ // clear it (probeCacheClear) so a state change shows up immediately.
2499
+ const ZALO_HEALTH_TTL_MS = 4000;
2500
+ function getZaloHealth(projectDir) {
2501
+ return sharedProbe(`zalohealth:${projectDir || ''}`, ZALO_HEALTH_TTL_MS, () => computeZaloHealth(projectDir));
2502
+ }
2503
+
2504
+ async function computeZaloHealth(projectDir) {
2325
2505
  const meta = {
2326
2506
  supportedVersion: ZALO_CONNECT_VERSION,
2327
2507
  installedVersion: null,
@@ -2338,19 +2518,24 @@ async function getZaloHealth(projectDir) {
2338
2518
  if (meta.zaloModVersion) meta.zaloModInstalled = true;
2339
2519
 
2340
2520
  const botContainer = getBotContainerName(projectDir);
2521
+ const native = isNativeProject(projectDir);
2341
2522
  if (cfg.channels?.['zalo-connect']?.enabled) {
2342
2523
  const manifestHost = join(projectDir, '.openclaw', 'extensions', 'zalo-connect', 'openclaw.plugin.json');
2343
2524
  try {
2344
2525
  meta.installedVersion = JSON.parse(await fsp.readFile(manifestHost, 'utf8')).version || null;
2345
2526
  } catch {
2346
- try {
2347
- 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 });
2348
- meta.installedVersion = JSON.parse(String(r.stdout || '{}')).version || null;
2349
- } catch {}
2527
+ // Only Docker projects keep the manifest inside a container. A native project has no
2528
+ // container at all, and this fallback used to shell into one anyway seconds of waiting on
2529
+ // a `docker exec` that could never succeed, on a request the dashboard makes constantly.
2530
+ if (!native) {
2531
+ try {
2532
+ 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 });
2533
+ meta.installedVersion = JSON.parse(String(r.stdout || '{}')).version || null;
2534
+ } catch {}
2535
+ }
2350
2536
  }
2351
2537
  }
2352
2538
 
2353
- const native = isNativeProject(projectDir);
2354
2539
  let containerRunning = false;
2355
2540
  let statusJson = null;
2356
2541
  let textStatus = '';
@@ -2360,7 +2545,9 @@ async function getZaloHealth(projectDir) {
2360
2545
  // /health over loopback, then read channel status + credentials directly on the host.
2361
2546
  const nmeta = readNativeMeta(projectDir) || {};
2362
2547
  const port = String(nmeta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
2363
- containerRunning = await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500);
2548
+ // Loopback: a live gateway answers in milliseconds. The old 2.5s budget was pure waiting
2549
+ // whenever the runtime was down, on a request the dashboard makes on every page load.
2550
+ containerRunning = await probeHttpOk(`http://127.0.0.1:${port}/health`, 900);
2364
2551
  if (containerRunning) {
2365
2552
  try {
2366
2553
  const r = await ocCapture(projectDir, ['channels', 'status', '--json'], { timeout: 20000 });
@@ -2446,10 +2633,13 @@ function getBotServiceName(projectDir) {
2446
2633
  // single fixed one, so without this a second native project would take over the first's service.
2447
2634
 
2448
2635
  const NATIVE_MARKER = 'native.json';
2449
- // Native ports sit one hundred above the docker ones (18789/20128) so a native project can run
2450
- // next to a docker project or next to an SSH tunnel forwarding a remote bot's ports untouched.
2451
- const NATIVE_DEFAULT_GATEWAY_PORT = 18889;
2452
- const NATIVE_DEFAULT_ROUTER_PORT = 20228;
2636
+ // Native uses the same ports as everything else: openclaw's 18789 and 9router's 20128. It used to
2637
+ // jump a hundred above them unconditionally so it could sit next to a docker project, but that fired
2638
+ // even on a machine with nothing running at all — a fresh VPS still landed on 18889/20228, so every
2639
+ // tunnel command, bookmark and doc pointed at a port the user never chose. findFreeHostPort() now
2640
+ // handles coexistence by asking the host what is actually taken, which the fixed offset never did.
2641
+ const NATIVE_DEFAULT_GATEWAY_PORT = 18789;
2642
+ const NATIVE_DEFAULT_ROUTER_PORT = 20128;
2453
2643
 
2454
2644
  function nativeMarkerPath(projectDir) {
2455
2645
  return join(projectDir || state.projectDir || '', '.openclaw', NATIVE_MARKER);
@@ -2521,6 +2711,47 @@ function ocCapture(projectDir, args, opts = {}) {
2521
2711
  return runCapture(a.cmd, a.args, { shell: false, ...a.opts, ...opts, env: { ...(a.opts.env || {}), ...(opts.env || {}) } });
2522
2712
  }
2523
2713
 
2714
+ /** Probe the managed gateway's own /health until it answers. */
2715
+ async function waitForNativeGatewayHealthy(projectDir, timeoutMs = 120000) {
2716
+ const meta = readNativeMeta(projectDir) || {};
2717
+ const port = String(meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
2718
+ const started = Date.now();
2719
+ let attempts = 0;
2720
+ while (Date.now() - started < timeoutMs) {
2721
+ if (await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500)) return true;
2722
+ attempts++;
2723
+ if (attempts % 5 === 0) sendLog(`[native] Waiting for gateway on ${port}... (${Math.round((Date.now() - started) / 1000)}s)`);
2724
+ await new Promise((r) => setTimeout(r, 3000));
2725
+ }
2726
+ sendLog(`[native] Gateway did not answer /health on ${port} within ${Math.round(timeoutMs / 1000)}s.`);
2727
+ return false;
2728
+ }
2729
+
2730
+ /**
2731
+ * The first gateway boot runs OpenClaw's startup migrations under a state-directory lease, and a
2732
+ * second gateway that tries to start meanwhile exits 1 with this message rather than waiting. The
2733
+ * docker path sidesteps it by never poking a booting container (see startZaloConnectLogin); when we
2734
+ * do hit it natively, the message carries the exact instant the lease frees — so wait that out
2735
+ * instead of retrying blind into systemd's StartLimitBurst (5 per 60s, after which the unit is
2736
+ * abandoned for good).
2737
+ */
2738
+ function migrationLeaseDeadline(text = '') {
2739
+ const m = String(text).match(/migrations are already running[\s\S]*?after\s+(\d{4}-\d{2}-\d{2}T[\d:.]+Z)/i);
2740
+ if (!m) return 0;
2741
+ const t = Date.parse(m[1]);
2742
+ return Number.isFinite(t) ? t : 0;
2743
+ }
2744
+
2745
+ /** `openclaw daemon <verb>` for a native project: streams output to the UI log AND returns it. */
2746
+ async function ocDaemon(projectDir, verb, extraArgs = []) {
2747
+ const args = ['daemon', verb, ...extraArgs];
2748
+ sendLog(`$ openclaw ${args.join(' ')}`);
2749
+ const out = await runCapture('openclaw', args, { cwd: projectDir, env: nativeEnv(projectDir), shell: false, timeout: 120000 });
2750
+ const text = `${out.stdout || ''}\n${out.stderr || ''}`;
2751
+ for (const line of text.split(/\r?\n/).map((l) => l.trimEnd()).filter(Boolean)) sendLog(line);
2752
+ return { ...out, text };
2753
+ }
2754
+
2524
2755
  /**
2525
2756
  * Restart the native gateway service.
2526
2757
  *
@@ -2529,20 +2760,105 @@ function ocCapture(projectDir, args, opts = {}) {
2529
2760
  * newly installed plugins never load, silently). stop+start is what actually works there, and it
2530
2761
  * works everywhere else too, so Windows takes that path and other systems keep `restart` with
2531
2762
  * stop+start as a fallback.
2763
+ *
2764
+ * Health is confirmed over /health at the end rather than trusted from the CLI's exit code: the
2765
+ * CLI gives up verifying after ~13s while the generated unit allows 30s to start, so a slow but
2766
+ * perfectly healthy gateway reports "restart failed" — which used to send callers down a pointless
2767
+ * stop+start that raced the migration lease all over again.
2532
2768
  */
2533
2769
  async function restartNativeRuntime(projectDir) {
2534
- const env = nativeEnv(projectDir);
2535
2770
  const stopStart = async () => {
2536
- await run('openclaw', ['daemon', 'stop'], { cwd: projectDir, env }).catch(() => {});
2537
- await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
2771
+ await ocDaemon(projectDir, 'stop');
2772
+ return ocDaemon(projectDir, 'start');
2538
2773
  };
2539
- if (process.platform === 'win32') return stopStart();
2540
- try {
2541
- await run('openclaw', ['daemon', 'restart'], { cwd: projectDir, env });
2542
- } catch (e) {
2543
- sendLog(`[native] daemon restart failed (${e.message}); falling back to stop+start`);
2544
- await stopStart();
2774
+ let res;
2775
+ if (process.platform === 'win32') {
2776
+ res = await stopStart();
2777
+ } else {
2778
+ res = await ocDaemon(projectDir, 'restart');
2779
+ // A lease collision is a "come back in a moment", not a broken service: stop+start would only
2780
+ // collide again, so fall through to the wait below instead.
2781
+ if (res.code !== 0 && !migrationLeaseDeadline(res.text)) {
2782
+ sendLog(`[native] daemon restart exited ${res.code}; falling back to stop+start`);
2783
+ res = await stopStart();
2784
+ }
2785
+ }
2786
+ const deadline = migrationLeaseDeadline(res.text);
2787
+ if (deadline) {
2788
+ const waitMs = Math.max(0, Math.min(deadline - Date.now(), 300000)) + 3000;
2789
+ sendLog(`[native] Startup migrations hold the state lease — waiting ${Math.ceil(waitMs / 1000)}s before retrying.`);
2790
+ await new Promise((r) => setTimeout(r, waitMs));
2791
+ res = await ocDaemon(projectDir, 'restart');
2792
+ if (res.code !== 0) res = await stopStart();
2545
2793
  }
2794
+ // systemd keeps restarting a crash-looping unit every RestartSec, so a gateway blocked by a lease
2795
+ // we never saw still comes up on its own — give it room before calling the restart a failure.
2796
+ if (!(await waitForNativeGatewayHealthy(projectDir, 180000))) {
2797
+ throw new Error('gateway did not answer /health after restart');
2798
+ }
2799
+ return true;
2800
+ }
2801
+
2802
+ /**
2803
+ * `openclaw daemon install` has no `--system` flag, so on Linux the gateway becomes a systemd USER
2804
+ * unit — and a user manager without linger is torn down when that user's last session exits. On a
2805
+ * desktop the graphical session holds it open, which is why this never showed up on macOS or a
2806
+ * Linux desktop; on a VPS the bot dies the moment the operator closes SSH and never comes back
2807
+ * after a reboot. Linger is what makes a user unit behave like the `restart: always` container it
2808
+ * replaces. Best-effort: a box without loginctl just keeps the old behaviour, loudly.
2809
+ */
2810
+ async function ensureSystemdLinger() {
2811
+ if (process.platform !== 'linux') return false;
2812
+ let user = '';
2813
+ try { user = process.env.SUDO_USER || os.userInfo().username; } catch { return false; }
2814
+ if (!user) return false;
2815
+ const cur = await runCapture('loginctl', ['show-user', user, '-p', 'Linger'], { shell: false, timeout: 10000 });
2816
+ if (/Linger=yes/i.test(cur.stdout || '')) return true;
2817
+ const out = await runCapture('loginctl', ['enable-linger', user], { shell: false, timeout: 20000 });
2818
+ if (out.code === 0) {
2819
+ sendLog(`[native] systemd linger enabled for "${user}" — the gateway now survives logout and reboot.`);
2820
+ return true;
2821
+ }
2822
+ sendLog(`[native] WARNING: could not enable systemd linger for "${user}" (${(out.stderr || out.stdout || '').trim() || `exit ${out.code}`}).`);
2823
+ sendLog(`[native] The gateway will stop when this user's last session ends. Fix it with: sudo loginctl enable-linger ${user}`);
2824
+ return false;
2825
+ }
2826
+
2827
+ /**
2828
+ * Native counterpart of the docker entrypoint's `ensure_plugin` (docker-gen.js).
2829
+ *
2830
+ * A container reinstalls its missing plugins on every boot; a native project has no entrypoint, so
2831
+ * nothing ever put zalo-connect or learning-memory on disk. The generated config declares both
2832
+ * anyway (bot-config-gen writes plugins.entries + allow + slots.contextEngine), so without this the
2833
+ * gateway boots with "plugin not found" warnings, `channels.zalo-connect` has no owner — Zalo login
2834
+ * fails with `Unsupported channel "zalo-connect"` — and the bot silently runs with no context
2835
+ * engine at all. Same set and same skip-if-present cheapness as ensure_plugin.
2836
+ */
2837
+ async function ensureNativePlugins(projectDir, { restart = false } = {}) {
2838
+ if (!isNativeProject(projectDir)) return [];
2839
+ let cfg = {};
2840
+ try { cfg = JSON.parse(await fsp.readFile(join(projectDir, '.openclaw', 'openclaw.json'), 'utf8')); } catch {}
2841
+ // learning-memory backs plugins.slots.contextEngine for every bot; zalo-connect only when a bot
2842
+ // actually declares the channel (mirrors docker-gen's `if (zaloBackend === 'zalo-connect')`).
2843
+ const wanted = new Set(['learning-memory']);
2844
+ if (cfg?.channels?.[ZALO_CHANNEL_ID] || cfg?.plugins?.entries?.[ZALO_PLUGIN_ID]) wanted.add(ZALO_PLUGIN_ID);
2845
+ const installed = [];
2846
+ for (const id of wanted) {
2847
+ const dir = join(projectDir, '.openclaw', 'extensions', id);
2848
+ if (existsSync(dir)) continue;
2849
+ const spec = id === ZALO_PLUGIN_ID ? ZALO_CONNECT_PLUGIN_SPEC : pluginInstallSpec(id);
2850
+ sendLog(`[native] plugin ${id} missing; installing ${spec}`);
2851
+ const out = await ocCapture(projectDir, ['plugins', 'install', spec, '--force', '--acknowledge-clawhub-risk'], { timeout: 300000 });
2852
+ const text = `${out.stdout || ''}\n${out.stderr || ''}`;
2853
+ for (const line of text.split(/\r?\n/).map((l) => l.trimEnd()).filter(Boolean)) sendLog(`[native] ${line}`);
2854
+ if (existsSync(dir) || /installed plugin/i.test(text)) installed.push(id);
2855
+ else sendLog(`[native] WARNING: could not install plugin ${id} — the bot will run without it.`);
2856
+ }
2857
+ if (installed.length && restart) {
2858
+ sendLog(`[native] Restarting gateway to load: ${installed.join(', ')}`);
2859
+ await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart after plugin install: ${e.message}`));
2860
+ }
2861
+ return installed;
2546
2862
  }
2547
2863
 
2548
2864
  /** Fire-and-forget background process (9router has no service wrapper of its own). */
@@ -2646,11 +2962,20 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
2646
2962
  await new Promise((r) => setTimeout(r, 8000));
2647
2963
  await applyResolved9RouterApiKey(projectDir).catch(() => {});
2648
2964
 
2965
+ // Plugins BEFORE the gateway's first boot — the container entrypoint installs them ahead of the
2966
+ // gateway for the same reason: a gateway that boots with its plugins already on disk loads them
2967
+ // straight away, needs no follow-up restart, and prints no "plugin not found" warnings.
2968
+ await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin bootstrap skipped: ${e.message}`));
2969
+
2649
2970
  // Managed service = auto-restart (KeepAlive/Restart=always) and start-at-login, the native
2650
2971
  // equivalent of docker's `restart: always`. --force so re-running install updates the port.
2651
2972
  const env = nativeEnv(projectDir);
2973
+ await ensureSystemdLinger();
2652
2974
  await run('openclaw', ['daemon', 'install', '--force', '--port', String(gwPort)], { cwd: projectDir, env });
2653
2975
  await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
2976
+ // Let the first boot finish its state migrations here, while nothing else is competing for the
2977
+ // lease. Every later action (create bot, install plugin) then restarts a settled gateway.
2978
+ await waitForNativeGatewayHealthy(projectDir, 180000);
2654
2979
  sendLog(`[native] gateway service "${label}" running on 127.0.0.1:${gwPort}, 9router on 127.0.0.1:${rtPort}`);
2655
2980
  return { gatewayPort: gwPort, routerPort: rtPort, label };
2656
2981
  }
@@ -2799,6 +3124,14 @@ async function recreateDockerBot(projectDir) {
2799
3124
  // Native: there is no image to rebuild — the gateway reads openclaw.json from disk on boot, so
2800
3125
  // reloading config after a bot/plugin change is just a service restart. Callers stay unchanged.
2801
3126
  if (isNativeProject(projectDir)) {
3127
+ // Never restart a gateway that is still on its first boot: OpenClaw runs startup migrations
3128
+ // under a state lease, a restart mid-migration exits 1, and systemd's start limit can then
3129
+ // abandon the unit. This is the same trap the docker path avoids by waiting for the container
3130
+ // before touching it (see startZaloConnectLogin) — wait for /health first.
3131
+ await waitForNativeGatewayHealthy(projectDir, 180000);
3132
+ // The bot that was just created/edited may have added the Zalo channel or the context engine to
3133
+ // openclaw.json; put those plugins on disk now so this one reload loads them too.
3134
+ await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin ensure skipped: ${e.message}`));
2802
3135
  sendLog('[native] Reloading gateway to pick up openclaw.json changes...');
2803
3136
  await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart failed: ${e.message}`));
2804
3137
  probeCacheClear();
@@ -3160,13 +3493,58 @@ function whichSync(name) {
3160
3493
  try {
3161
3494
  const finder = process.platform === 'win32' ? 'where' : 'which';
3162
3495
  const out = execFileSync(finder, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
3163
- const first = String(out).split(/\r?\n/).map((s) => s.trim()).find(Boolean);
3164
- return first || '';
3496
+ const hits = String(out).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
3497
+ if (process.platform !== 'win32') return hits[0] || '';
3498
+ // `where claude` lists the extensionless npm shim FIRST — a shell script Windows cannot spawn
3499
+ // ("spawn ...\\npm\\claude ENOENT"), which is how an allow-listed CLI ended up unusable for the
3500
+ // bot. Prefer something Windows can actually execute.
3501
+ const rank = (f) => {
3502
+ const ext = extname(f).toLowerCase();
3503
+ const order = ['.exe', '.cmd', '.bat', '.com', '.ps1'];
3504
+ const idx = order.indexOf(ext);
3505
+ return idx === -1 ? order.length : idx;
3506
+ };
3507
+ return [...hits].sort((a, b) => rank(a) - rank(b))[0] || '';
3165
3508
  } catch (_) {
3166
3509
  return '';
3167
3510
  }
3168
3511
  }
3169
3512
 
3513
+ /**
3514
+ * What to actually spawn for an allow-listed command. Windows needs the indirection:
3515
+ * - the path may be the extensionless npm shim (a shell script) — try the real siblings;
3516
+ * - a `.cmd`/`.bat` shim cannot be spawned without a shell on current Node, so read it and run
3517
+ * what it points at (`…\pkg\bin\x.exe`, or node + a cli.js) directly.
3518
+ * Keeping shell:false matters: the bot supplies the arguments, and a shell would let one of them
3519
+ * become a second command.
3520
+ */
3521
+ function resolveHostExecutable(bin) {
3522
+ if (process.platform !== 'win32') return { file: bin, prefixArgs: [] };
3523
+ let target = bin;
3524
+ if (!extname(target)) {
3525
+ const candidate = ['.exe', '.cmd', '.bat'].map((ext) => target + ext).find((f) => existsSync(f));
3526
+ if (candidate) target = candidate;
3527
+ }
3528
+ const ext = extname(target).toLowerCase();
3529
+ if (ext !== '.cmd' && ext !== '.bat') return { file: target, prefixArgs: [] };
3530
+ try {
3531
+ const shim = readFileSync(target, 'utf8');
3532
+ const dir = dirname(target);
3533
+ const expand = (p) => resolve(dir, p.replace(/%~?dp0%\\?/gi, '').replace(/^\\+/, ''));
3534
+ const exeRef = shim.match(/"([^"\n]*?\.exe)"/i);
3535
+ if (exeRef) {
3536
+ const exe = expand(exeRef[1]);
3537
+ if (existsSync(exe)) return { file: exe, prefixArgs: [] };
3538
+ }
3539
+ const jsRef = shim.match(/"([^"\n]*?\.js)"/i);
3540
+ if (jsRef) {
3541
+ const js = expand(jsRef[1]);
3542
+ if (existsSync(js)) return { file: process.execPath, prefixArgs: [js] };
3543
+ }
3544
+ } catch (_) {}
3545
+ return { file: target, prefixArgs: [] };
3546
+ }
3547
+
3170
3548
  /**
3171
3549
  * CLI tools the bot may RUN (not just open) via /api/host/exec — output is captured and
3172
3550
  * returned. Kept as a name→path allow-list, mirroring detectHostApps: the executable is fixed,
@@ -3231,6 +3609,13 @@ function grantHostCapabilities(cfg) {
3231
3609
  added.push(name);
3232
3610
  }
3233
3611
  }
3612
+ // Desktop actions (/api/host/ui) come with the same grant: screenshot, pointer, keyboard,
3613
+ // clipboard, windows. Built in, so they work on a machine with no Codex and no extra tools —
3614
+ // on Linux they lean on xdotool/scrot, which the endpoint reports if missing.
3615
+ if (cfg.ui !== true) {
3616
+ cfg.ui = true;
3617
+ added.push('desktop actions (screenshot/click/type)');
3618
+ }
3234
3619
  return added;
3235
3620
  }
3236
3621
 
@@ -3502,7 +3887,8 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
3502
3887
  };
3503
3888
  let child;
3504
3889
  try {
3505
- child = spawn(bin, args, { shell: false, windowsHide: true });
3890
+ const target = resolveHostExecutable(bin);
3891
+ child = spawn(target.file, [...target.prefixArgs, ...args], { shell: false, windowsHide: true });
3506
3892
  } catch (e) {
3507
3893
  return finish({ ok: false, error: e.message }, 500);
3508
3894
  }
@@ -3522,6 +3908,262 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
3522
3908
  });
3523
3909
  }
3524
3910
 
3911
+ /**
3912
+ * Desktop actions for the bot: see the screen, move and click, type, read the clipboard, list and
3913
+ * focus windows. The bot runs in a container with no desktop of its own, so the installer — which
3914
+ * already runs on the operator's machine and already opens apps for it — performs them.
3915
+ *
3916
+ * No native modules: the approach follows the dependency-free tools (and Anthropic's own
3917
+ * computer-use reference, which drives xdotool + a screenshot binary):
3918
+ * Windows a version-stamped PowerShell helper (user32 P/Invoke, SendKeys, System.Drawing)
3919
+ * macOS screencapture + osascript/System Events + pbcopy/pbpaste
3920
+ * Linux xdotool + scrot|import|gnome-screenshot|spectacle + xclip|wl-copy
3921
+ * Whatever the OS, the bot sends the same JSON and gets the same shape back, so its instructions
3922
+ * do not fork per platform.
3923
+ *
3924
+ * Windows note: input injection and screen capture need a real desktop session. When the installer
3925
+ * itself was started over SSH there is none, and the capture fails — the error says so instead of
3926
+ * leaking a raw Win32Exception.
3927
+ */
3928
+ const HOST_UI_ACTIONS = new Set([
3929
+ 'screenshot', 'screen_size', 'mouse_move', 'click', 'drag', 'scroll',
3930
+ 'type', 'key', 'clipboard_get', 'clipboard_set', 'windows', 'focus',
3931
+ ]);
3932
+
3933
+ function hostUiScriptPath(projectDir) {
3934
+ return join(projectDir, '.openclaw', 'host-ui.ps1');
3935
+ }
3936
+
3937
+ async function ensureHostUiScript(projectDir) {
3938
+ const path = hostUiScriptPath(projectDir);
3939
+ const stamp = `# OpenClaw host UI helper — version ${HOST_UI_PS1_VERSION}`;
3940
+ try {
3941
+ if (existsSync(path) && (await fsp.readFile(path, 'utf8')).startsWith(stamp)) return path;
3942
+ } catch (_) {}
3943
+ await fsp.mkdir(dirname(path), { recursive: true }).catch(() => {});
3944
+ await fsp.writeFile(path, HOST_UI_PS1, 'utf8');
3945
+ return path;
3946
+ }
3947
+
3948
+ function firstExistingCommand(names) {
3949
+ for (const name of names) {
3950
+ const bin = whichSync(name);
3951
+ if (bin) return { name, bin };
3952
+ }
3953
+ return null;
3954
+ }
3955
+
3956
+ async function hostUiScreenshotTarget(projectDir) {
3957
+ const dir = join(projectDir, '.openclaw', 'media', 'host-ui');
3958
+ await fsp.mkdir(dir, { recursive: true }).catch(() => {});
3959
+ // Keep the folder from growing forever: the bot takes a lot of these.
3960
+ try {
3961
+ const files = (await fsp.readdir(dir)).filter((f) => f.endsWith('.png')).sort();
3962
+ for (const stale of files.slice(0, Math.max(0, files.length - 20))) {
3963
+ await fsp.rm(join(dir, stale), { force: true }).catch(() => {});
3964
+ }
3965
+ } catch (_) {}
3966
+ const name = `shot-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
3967
+ return { hostPath: join(dir, name), containerPath: `/home/node/project/.openclaw/media/host-ui/${name}` };
3968
+ }
3969
+
3970
+ async function runHostUiWindows(projectDir, action, body, shot) {
3971
+ const script = await ensureHostUiScript(projectDir);
3972
+ const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Action', action];
3973
+ const push = (flag, value) => { if (value !== undefined && value !== null && value !== '') args.push(flag, String(value)); };
3974
+ push('-X', body.x);
3975
+ push('-Y', body.y);
3976
+ push('-ToX', body.toX);
3977
+ push('-ToY', body.toY);
3978
+ push('-Amount', body.amount);
3979
+ push('-Text', body.text);
3980
+ push('-Button', body.button);
3981
+ push('-Clicks', body.clicks);
3982
+ push('-Title', body.title);
3983
+ if (shot) push('-Path', shot.hostPath);
3984
+ const r = await runCapture('powershell', args, { shell: false, timeout: 30000 });
3985
+ const parsed = parseJsonText(String(r.stdout || '').trim(), null);
3986
+ if (parsed) return parsed;
3987
+ const err = String(r.stderr || r.stdout || '').trim();
3988
+ if (/Win32Exception|CopyFromScreen|handle is invalid/i.test(err)) {
3989
+ return { ok: false, error: 'no desktop session available. The installer must run in the logged-in desktop session (not over SSH) for screen capture and input to work.' };
3990
+ }
3991
+ return { ok: false, error: err.split('\n')[0] || `powershell exited ${r.code}` };
3992
+ }
3993
+
3994
+ async function runHostUiMac(action, body, shot) {
3995
+ const osa = (script) => runCapture('osascript', ['-e', script], { shell: false, timeout: 20000 });
3996
+ const point = () => `{${Number(body.x) || 0}, ${Number(body.y) || 0}}`;
3997
+ switch (action) {
3998
+ case 'screenshot': {
3999
+ const r = await runCapture('screencapture', ['-x', shot.hostPath], { shell: false, timeout: 20000 });
4000
+ return r.code === 0 ? { ok: true, path: shot.hostPath } : { ok: false, error: String(r.stderr || 'screencapture failed').trim() };
4001
+ }
4002
+ case 'screen_size': {
4003
+ const r = await osa('tell application "Finder" to get bounds of window of desktop');
4004
+ const nums = String(r.stdout || '').trim().split(/\s*,\s*/).map(Number);
4005
+ return nums.length === 4 ? { ok: true, width: nums[2], height: nums[3] } : { ok: false, error: 'could not read screen bounds' };
4006
+ }
4007
+ case 'mouse_move':
4008
+ case 'click': {
4009
+ // System Events can click at a point; a plain move has no equivalent, so a move is a click
4010
+ // target set-up only. Accessibility permission is required (System Settings → Privacy).
4011
+ const clicks = Math.max(1, Number(body.clicks) || 1);
4012
+ if (action === 'mouse_move') return { ok: true, note: 'macOS has no pointer-move without a click; pass x/y to click instead', x: body.x, y: body.y };
4013
+ for (let i = 0; i < clicks; i++) {
4014
+ const r = await osa(`tell application "System Events" to click at ${point()}`);
4015
+ if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() || 'click failed (grant Accessibility permission)' };
4016
+ }
4017
+ return { ok: true, button: 'left', clicks };
4018
+ }
4019
+ case 'type': {
4020
+ const text = String(body.text || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
4021
+ const r = await osa(`tell application "System Events" to keystroke "${text}"`);
4022
+ return r.code === 0 ? { ok: true, typed: String(body.text || '').length } : { ok: false, error: String(r.stderr || '').trim() };
4023
+ }
4024
+ case 'key': {
4025
+ const map = { enter: 'return', esc: 'escape', pageup: 'page up', pagedown: 'page down' };
4026
+ for (const combo of String(body.text || '').split(/\s+/).filter(Boolean)) {
4027
+ const parts = combo.toLowerCase().split('+').map((p) => p.trim()).filter(Boolean);
4028
+ const key = map[parts[parts.length - 1]] || parts[parts.length - 1];
4029
+ const mods = parts.slice(0, -1).map((m) => ({ ctrl: 'control down', control: 'control down', cmd: 'command down', meta: 'command down', alt: 'option down', option: 'option down', shift: 'shift down' })[m]).filter(Boolean);
4030
+ const using = mods.length ? ` using {${mods.join(', ')}}` : '';
4031
+ const named = ['return', 'escape', 'tab', 'space', 'delete', 'up', 'down', 'left', 'right', 'home', 'end', 'page up', 'page down'];
4032
+ const script = named.includes(key)
4033
+ ? `tell application "System Events" to key code ${{ return: 36, escape: 53, tab: 48, space: 49, delete: 51, up: 126, down: 125, left: 123, right: 124, home: 115, end: 119, 'page up': 116, 'page down': 121 }[key]}${using}`
4034
+ : `tell application "System Events" to keystroke "${key}"${using}`;
4035
+ const r = await osa(script);
4036
+ if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() };
4037
+ }
4038
+ return { ok: true, keys: body.text };
4039
+ }
4040
+ case 'scroll': {
4041
+ const amount = Number(body.amount) || 3;
4042
+ const dir = amount < 0 ? 121 : 116; // page down / page up
4043
+ for (let i = 0; i < Math.abs(amount); i++) await osa(`tell application "System Events" to key code ${dir}`);
4044
+ return { ok: true, amount };
4045
+ }
4046
+ case 'clipboard_get': {
4047
+ const r = await runCapture('pbpaste', [], { shell: false, timeout: 10000 });
4048
+ return { ok: true, text: String(r.stdout || '') };
4049
+ }
4050
+ case 'clipboard_set': {
4051
+ const r = await runCapture('sh', ['-c', 'pbcopy'], { shell: false, timeout: 10000, input: String(body.text || '') });
4052
+ return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: 'pbcopy failed' };
4053
+ }
4054
+ case 'windows': {
4055
+ const r = await osa('tell application "System Events" to get name of every process whose background only is false');
4056
+ const list = String(r.stdout || '').trim().split(/\s*,\s*/).filter(Boolean).map((title) => ({ title, process: title }));
4057
+ return { ok: true, windows: list };
4058
+ }
4059
+ case 'focus': {
4060
+ const title = String(body.title || '').replace(/"/g, '');
4061
+ if (!title) return { ok: false, error: 'focus needs a title' };
4062
+ const r = await osa(`tell application "${title}" to activate`);
4063
+ return r.code === 0 ? { ok: true, focused: title } : { ok: false, error: String(r.stderr || '').trim() || `no app named ${title}` };
4064
+ }
4065
+ default:
4066
+ return { ok: false, error: `unsupported on macOS: ${action}` };
4067
+ }
4068
+ }
4069
+
4070
+ async function runHostUiLinux(action, body, shot) {
4071
+ const xdo = whichSync('xdotool');
4072
+ const need = (bin, hint) => ({ ok: false, error: `${hint} needs ${bin}; install it (e.g. apt install ${bin})` });
4073
+ switch (action) {
4074
+ case 'screenshot': {
4075
+ const tool = firstExistingCommand(['gnome-screenshot', 'scrot', 'spectacle', 'import']);
4076
+ if (!tool) return need('scrot', 'screenshot');
4077
+ const argv = tool.name === 'gnome-screenshot' ? ['-f', shot.hostPath]
4078
+ : tool.name === 'spectacle' ? ['-b', '-n', '-o', shot.hostPath]
4079
+ : tool.name === 'import' ? ['-window', 'root', shot.hostPath]
4080
+ : [shot.hostPath];
4081
+ const r = await runCapture(tool.bin, argv, { shell: false, timeout: 20000 });
4082
+ return r.code === 0 ? { ok: true, path: shot.hostPath, tool: tool.name } : { ok: false, error: String(r.stderr || 'capture failed').trim() };
4083
+ }
4084
+ case 'screen_size': {
4085
+ if (!xdo) return need('xdotool', 'screen_size');
4086
+ const r = await runCapture(xdo, ['getdisplaygeometry'], { shell: false, timeout: 10000 });
4087
+ const [w, h] = String(r.stdout || '').trim().split(/\s+/).map(Number);
4088
+ return w && h ? { ok: true, width: w, height: h } : { ok: false, error: 'could not read display geometry' };
4089
+ }
4090
+ case 'mouse_move':
4091
+ case 'click':
4092
+ case 'drag':
4093
+ case 'scroll':
4094
+ case 'type':
4095
+ case 'key':
4096
+ case 'windows':
4097
+ case 'focus': {
4098
+ if (!xdo) return need('xdotool', action);
4099
+ const button = { left: 1, middle: 2, right: 3 }[String(body.button || 'left')] || 1;
4100
+ const argvFor = {
4101
+ mouse_move: ['mousemove', String(body.x ?? 0), String(body.y ?? 0)],
4102
+ click: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'click', '--repeat', String(Math.max(1, Number(body.clicks) || 1)), String(button)],
4103
+ drag: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'mousedown', '1', 'mousemove', String(body.toX ?? 0), String(body.toY ?? 0), 'mouseup', '1'],
4104
+ scroll: ['click', '--repeat', String(Math.max(1, Math.abs(Number(body.amount) || 3))), (Number(body.amount) || 3) < 0 ? '5' : '4'],
4105
+ type: ['type', '--delay', '12', '--', String(body.text || '')],
4106
+ key: ['key', ...String(body.text || '').split(/\s+/).filter(Boolean)],
4107
+ windows: ['search', '--onlyvisible', '--name', '.'],
4108
+ focus: ['search', '--onlyvisible', '--name', String(body.title || ''), 'windowactivate'],
4109
+ }[action];
4110
+ const r = await runCapture(xdo, argvFor, { shell: false, timeout: 20000 });
4111
+ if (action === 'windows') {
4112
+ const ids = String(r.stdout || '').trim().split(/\s+/).filter(Boolean).slice(0, 40);
4113
+ const titles = [];
4114
+ for (const id of ids) {
4115
+ const t = await runCapture(xdo, ['getwindowname', id], { shell: false, timeout: 5000 });
4116
+ const title = String(t.stdout || '').trim();
4117
+ if (title) titles.push({ title, id });
4118
+ }
4119
+ return { ok: true, windows: titles };
4120
+ }
4121
+ return r.code === 0 ? { ok: true, action } : { ok: false, error: String(r.stderr || '').trim() || `xdotool exited ${r.code}` };
4122
+ }
4123
+ case 'clipboard_get': {
4124
+ const tool = firstExistingCommand(['wl-paste', 'xclip', 'xsel']);
4125
+ if (!tool) return need('xclip', 'clipboard_get');
4126
+ const argv = tool.name === 'xclip' ? ['-o', '-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-o'] : [];
4127
+ const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000 });
4128
+ return { ok: true, text: String(r.stdout || '') };
4129
+ }
4130
+ case 'clipboard_set': {
4131
+ const tool = firstExistingCommand(['wl-copy', 'xclip', 'xsel']);
4132
+ if (!tool) return need('xclip', 'clipboard_set');
4133
+ const argv = tool.name === 'xclip' ? ['-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-i'] : [];
4134
+ const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000, input: String(body.text || '') });
4135
+ return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: `${tool.name} failed` };
4136
+ }
4137
+ default:
4138
+ return { ok: false, error: `unsupported on Linux: ${action}` };
4139
+ }
4140
+ }
4141
+
4142
+ async function runHostUi(projectDir, body = {}) {
4143
+ const action = String(body.action || '').trim();
4144
+ if (!HOST_UI_ACTIONS.has(action)) {
4145
+ return { status: 400, payload: { ok: false, error: `unknown action: ${action || '(none)'}`, actions: [...HOST_UI_ACTIONS] } };
4146
+ }
4147
+ const shot = action === 'screenshot' ? await hostUiScreenshotTarget(projectDir) : null;
4148
+ let result;
4149
+ try {
4150
+ if (process.platform === 'win32') result = await runHostUiWindows(projectDir, action, body, shot);
4151
+ else if (process.platform === 'darwin') result = await runHostUiMac(action, body, shot);
4152
+ else result = await runHostUiLinux(action, body, shot);
4153
+ } catch (err) {
4154
+ result = { ok: false, error: err.message };
4155
+ }
4156
+ if (shot && result?.ok) {
4157
+ // The project folder is bind-mounted into the container, so hand back the path the bot can
4158
+ // actually open — otherwise it gets a Windows path it cannot read and reports failure.
4159
+ result.path = shot.hostPath;
4160
+ result.containerPath = shot.containerPath;
4161
+ result.bytes = existsSync(shot.hostPath) ? (await fsp.stat(shot.hostPath)).size : 0;
4162
+ }
4163
+ sendLog(`[host-control] UI "${action}" → ${result?.ok ? 'ok' : `lỗi: ${result?.error || 'unknown'}`}`);
4164
+ return { status: result?.ok ? 200 : 500, payload: result };
4165
+ }
4166
+
3525
4167
  async function handleHostControl(req, res, projectDir) {
3526
4168
  const cfg = await readHostControlConfig(projectDir);
3527
4169
  const url = new URL(req.url, 'http://localhost');
@@ -3539,6 +4181,16 @@ async function handleHostControl(req, res, projectDir) {
3539
4181
  if (url.pathname === '/api/host/apps' && req.method === 'GET') {
3540
4182
  return json(res, { ok: true, apps: Object.keys(cfg.apps || {}), commands: Object.keys(cfg.commands || {}), platform: process.platform });
3541
4183
  }
4184
+ if (url.pathname === '/api/host/ui' && req.method === 'POST') {
4185
+ // Part of PC control, but its own switch: seeing the screen and moving the pointer is a bigger
4186
+ // step than opening an app, so it only answers once the operator has granted capabilities.
4187
+ if (cfg.ui !== true) {
4188
+ return json(res, { ok: false, error: 'desktop actions are not granted. Ask the operator to press "Điều khiển máy" again in the dashboard (that writes ui:true).' }, 403);
4189
+ }
4190
+ const body = await readJson(req).catch(() => ({}));
4191
+ const { status, payload } = await runHostUi(projectDir, body || {});
4192
+ return json(res, payload, status);
4193
+ }
3542
4194
  if (url.pathname === '/api/host/exec' && req.method === 'POST') {
3543
4195
  const body = await readJson(req).catch(() => ({}));
3544
4196
  const name = String(body.command || '').trim().toLowerCase();
@@ -3606,6 +4258,39 @@ async function writeHostControlAccess(projectDir, cfg) {
3606
4258
  '',
3607
4259
  `Lệnh khả dụng: ${commands.map((c) => `\`${c}\``).join(', ')}. Lệnh mặc định timeout 180s, output tối đa ~200KB/luồng.`,
3608
4260
  ] : [];
4261
+ // Desktop actions: one endpoint, same JSON on every OS, so the bot does not need per-platform
4262
+ // instructions. Screenshots land in the project folder, which the container already sees.
4263
+ const uiBlock = cfg.ui === true ? [
4264
+ '',
4265
+ '### Thao tác trên màn hình chủ',
4266
+ '',
4267
+ 'Một endpoint duy nhất cho mọi hệ điều hành. Cách làm đúng: **chụp màn hình trước, xem toạ độ, rồi mới click** —',
4268
+ 'đừng đoán vị trí. Toạ độ tính bằng pixel màn hình, gốc ở góc trên-trái.',
4269
+ '',
4270
+ '```sh',
4271
+ `curl -s -X POST ${base}/api/host/ui -H "x-openclaw-token: ${cfg.token}" \\`,
4272
+ ' -H "content-type: application/json" -d \'{"action":"screenshot"}\'',
4273
+ '```',
4274
+ '',
4275
+ 'Trả về `containerPath` — **đọc/gửi ảnh bằng đường dẫn đó** (nằm trong project nên bạn thấy được),',
4276
+ 'kèm `width`/`height` để biết màn hình bao lớn.',
4277
+ '',
4278
+ 'Các action khác (cùng dạng `{"action":...}`):',
4279
+ '',
4280
+ '- `screen_size` — kích thước màn hình',
4281
+ '- `mouse_move` + `x`,`y` — di chuột',
4282
+ '- `click` + `x`,`y`, tuỳ chọn `button` (`left`/`right`/`middle`) và `clicks` (2 = double-click)',
4283
+ '- `drag` + `x`,`y`,`toX`,`toY` — kéo thả',
4284
+ '- `scroll` + `amount` (âm = xuống), tuỳ chọn `x`,`y`',
4285
+ '- `type` + `text` — gõ chữ vào cửa sổ đang focus',
4286
+ '- `key` + `text` — nhấn tổ hợp, ví dụ `"ctrl+c"`, `"enter"`, `"alt+tab"`; nhiều tổ hợp thì cách nhau bằng space',
4287
+ '- `clipboard_get` / `clipboard_set` + `text` — đọc/ghi clipboard',
4288
+ '- `windows` — liệt kê cửa sổ đang mở; `focus` + `title` — đưa cửa sổ lên trước',
4289
+ '',
4290
+ 'Nếu trả về lỗi "no desktop session available" thì installer đang chạy ngoài phiên desktop —',
4291
+ 'nói chủ mở lại installer trong máy, đừng thử cách khác.',
4292
+ 'Trên Linux, thiếu `xdotool`/`scrot` thì endpoint nói rõ cần cài gì — báo lại cho chủ.',
4293
+ ] : [];
3609
4294
  // Screen capture / recording — only advertised when the operator granted the matching tool, so
3610
4295
  // the bot never tries a binary that is not on this machine's allow-list.
3611
4296
  // Windows has no capture binary to allow-list (PowerShell does it inline), so the section shows
@@ -3712,6 +4397,7 @@ async function writeHostControlAccess(projectDir, cfg) {
3712
4397
  '',
3713
4398
  apps.length ? `App khả dụng trên máy này: ${apps.map((a) => `\`${a}\``).join(', ')}.` : 'Máy này chưa khai báo app nào — nhờ chủ thêm vào `.openclaw/host-control.json`.',
3714
4399
  ...execBlock,
4400
+ ...uiBlock,
3715
4401
  // Docker only: a screenshot taken on the host lands on the HOST filesystem, which this
3716
4402
  // container cannot read — say so instead of letting the bot hunt for a missing file.
3717
4403
  ...(hasCapture ? [
@@ -3817,7 +4503,8 @@ async function ensureChromeRelay() {
3817
4503
  // Launch real host Chrome in remote-debugging mode (port 9222) so the browser-automation plugin
3818
4504
  // can drive the user's actual Chrome (logged-in profile) instead of headless Chromium. The bot
3819
4505
  // reaches it via CDP (host.docker.internal:9222 from the container). Detached: keeps running after
3820
- // this request. `--remote-allow-origins=*` is required by modern Chrome for cross-origin CDP.
4506
+ // this request. The debug port stays on loopback and no origin wildcard is passed — a Node CDP
4507
+ // client sends no Origin header, so the wildcard only widened who could drive the browser.
3821
4508
  // On a headless VPS there is no Chrome to open here — instead we start the bridge relay and hand
3822
4509
  // back copy-paste commands so the user runs Chrome on THEIR machine + a reverse SSH tunnel.
3823
4510
  // Where Chrome keeps the operator's own profile, per OS. Chrome must not already be running
@@ -3902,8 +4589,8 @@ async function startChromeDebug() {
3902
4589
  port: 9222,
3903
4590
  // Same dedicated profile directories the local button and the generated scripts use, so
3904
4591
  // an operator who has already run one of those keeps the session they signed in with.
3905
- 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-allow-origins='*'`,
3906
- 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-allow-origins=*`,
4592
+ 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`,
4593
+ 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`,
3907
4594
  tunnelCmd: `ssh -N -R 9222:127.0.0.1:9222 ${user}@${ip}`,
3908
4595
  };
3909
4596
  }
@@ -3922,12 +4609,20 @@ async function startChromeDebug() {
3922
4609
  // --enable-automation, so navigator.webdriver stays false and there is no banner.
3923
4610
  // Set OPENCLAW_CHROME_PROFILE_DIR to point somewhere else (anything but the default profile).
3924
4611
  const userDataDir = process.env.OPENCLAW_CHROME_PROFILE_DIR || debugChromeProfileDir();
3925
- await seedDebugChromeProfile(defaultChromeProfileDir(), userDataDir, sendLog);
4612
+ // Only when the operator asked for their logins: the copy duplicates cookies and sessions into
4613
+ // a second profile directory, which is not something to do on a button press by default.
4614
+ if (process.env.OPENCLAW_CHROME_SEED_PROFILE === '1') {
4615
+ await seedDebugChromeProfile(defaultChromeProfileDir(), userDataDir, sendLog);
4616
+ } else if (!existsSync(join(userDataDir, 'Default'))) {
4617
+ 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 đó).');
4618
+ }
3926
4619
  const args = [
3927
4620
  `--remote-debugging-port=${port}`,
4621
+ // Loopback only, and no --remote-allow-origins=*: a Node CDP client sends no Origin header,
4622
+ // so the wildcard only widened who could drive this browser.
4623
+ '--remote-debugging-address=127.0.0.1',
3928
4624
  `--user-data-dir=${userDataDir}`,
3929
4625
  '--profile-directory=Default',
3930
- '--remote-allow-origins=*',
3931
4626
  '--no-first-run',
3932
4627
  '--no-default-browser-check',
3933
4628
  ];
@@ -4030,13 +4725,18 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
4030
4725
  state.os = osChoice;
4031
4726
  state.startedAt = new Date().toISOString();
4032
4727
  try {
4033
- // Native runs on the host's own ports, so it must not land on the docker defaults: a machine
4034
- // often has a docker project (or an SSH tunnel to a remote bot) already holding 18789/20128.
4728
+ // Native binds the host directly, so it needs ports nothing else holds but only when something
4729
+ // actually holds them. Ask the host rather than assuming: a fresh machine keeps openclaw's and
4730
+ // 9router's real defaults, and a machine that already runs a docker project (or an SSH tunnel to
4731
+ // a remote bot) steps to the next free pair instead.
4035
4732
  if (mode === 'native') {
4036
- if (gatewayPort === 18789) gatewayPort = NATIVE_DEFAULT_GATEWAY_PORT;
4037
- if (routerPort === 20128) routerPort = NATIVE_DEFAULT_ROUTER_PORT;
4733
+ gatewayPort = await findFreeHostPort(gatewayPort, { reserveNext: true });
4734
+ routerPort = await findFreeHostPort(routerPort);
4038
4735
  state.gatewayPort = gatewayPort;
4039
4736
  state.routerPort = routerPort;
4737
+ state.gatewayUrl = `http://127.0.0.1:${gatewayPort}`;
4738
+ state.routerUrl = `http://127.0.0.1:${routerPort}`;
4739
+ sendLog(`[native] ports: gateway ${gatewayPort}, 9router ${routerPort}`);
4040
4740
  }
4041
4741
  sendLog('OpenClaw local installer started');
4042
4742
  sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
@@ -4145,6 +4845,9 @@ async function listMarkdownFiles(projectDir, agentId = '') {
4145
4845
  }
4146
4846
 
4147
4847
  async function saveState(rootProjectDir) {
4848
+ // Selecting, adding or removing a project all end up here, and all of them make the cached
4849
+ // project list wrong — drop it so the next request rebuilds instead of showing the old set.
4850
+ probeCacheClear('projects:');
4148
4851
  const file = join(rootProjectDir, STATE_FILE);
4149
4852
  await fsp.writeFile(file, JSON.stringify({
4150
4853
  projectDir: state.projectDir,
@@ -4226,6 +4929,9 @@ function isRestrictedSystemDir(dirPath) {
4226
4929
  // so a fresh `npx github:…` run (e.g. on a VPS where bots are already running) targets the
4227
4930
  // live project instead of defaulting to an empty ~/openclaw-setup folder.
4228
4931
  async function discoverDockerBotProjectRoots() {
4932
+ const ck = 'dockerroots';
4933
+ const cached = probeCacheGet(ck);
4934
+ if (cached) return cached;
4229
4935
  const roots = [];
4230
4936
  try {
4231
4937
  const r = await runCapture(
@@ -4244,7 +4950,9 @@ async function discoverDockerBotProjectRoots() {
4244
4950
  }
4245
4951
  }
4246
4952
  } catch {}
4247
- return [...new Set(roots)];
4953
+ const unique = [...new Set(roots)];
4954
+ probeCacheSet(ck, unique, 15000);
4955
+ return unique;
4248
4956
  }
4249
4957
 
4250
4958
  // Native installs have no container to inspect, so we can't detect them the way Docker bots are
@@ -4343,7 +5051,15 @@ async function ensureProjectsLoaded(rootProjectDir) {
4343
5051
  }
4344
5052
  }
4345
5053
 
4346
- async function discoverProjects(rootProjectDir) {
5054
+ // The project list costs docker/native probes per project. It changes when someone creates or
5055
+ // deletes a project — not between two page loads — so serve it from a short cache and refresh in
5056
+ // the background: the dashboard opens instantly and is at most a few seconds stale.
5057
+ const PROJECTS_TTL_MS = 10000;
5058
+ function discoverProjects(rootProjectDir) {
5059
+ return sharedProbe(`projects:${rootProjectDir || ''}`, PROJECTS_TTL_MS, () => computeDiscoverProjects(rootProjectDir));
5060
+ }
5061
+
5062
+ async function computeDiscoverProjects(rootProjectDir) {
4347
5063
  await ensureProjectsLoaded(rootProjectDir);
4348
5064
 
4349
5065
  // Surface projects whose bot is running in Docker even if this install has no saved
@@ -4371,16 +5087,14 @@ async function discoverProjects(rootProjectDir) {
4371
5087
  }
4372
5088
  }
4373
5089
 
4374
- const updatedProjects = [];
4375
- for (const p of state.projects) {
4376
- if (existsSync(join(p.projectDir, '.openclaw', 'openclaw.json'))) {
4377
- const meta = await buildProjectMeta(p.projectDir).catch(() => null);
4378
- if (meta) {
4379
- updatedProjects.push(meta);
4380
- }
4381
- }
4382
- }
4383
- state.projects = updatedProjects;
5090
+ // In parallel: each buildProjectMeta runs runtime detection (docker calls, port probes), so a
5091
+ // handful of projects turned into seconds of dashboard load when this was a sequential loop.
5092
+ const metas = await Promise.all(
5093
+ state.projects
5094
+ .filter((p) => existsSync(join(p.projectDir, '.openclaw', 'openclaw.json')))
5095
+ .map((p) => buildProjectMeta(p.projectDir).catch(() => null)),
5096
+ );
5097
+ state.projects = metas.filter(Boolean);
4384
5098
 
4385
5099
  state.projects.sort((a, b) => {
4386
5100
  const aActive = state.projectDir && resolve(state.projectDir) === resolve(a.projectDir);
@@ -4969,6 +5683,11 @@ async function installFeature(projectDir, agentId, kind, id) {
4969
5683
  if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
4970
5684
  cfg.plugins.entries[existingKey].config = Object.assign({}, cfg.plugins.entries[existingKey].config, {
4971
5685
  hostOs: await resolveProjectHostOs(projectDir),
5686
+ // The plugin ships these off: editing the Docker build files, running page JavaScript
5687
+ // and uploading local files are things it will not do until an operator says so.
5688
+ // Installing it from this dashboard IS that operator saying so — otherwise browsing
5689
+ // would need a hand-edited config right after a one-click install.
5690
+ ...browserAutomationOptIns(),
4972
5691
  });
4973
5692
  cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
4974
5693
  cfg.tools.alsoAllow = Array.from(new Set([...(cfg.tools.alsoAllow || []), 'group:web']));
@@ -5068,6 +5787,11 @@ async function installFeature(projectDir, agentId, kind, id) {
5068
5787
  if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
5069
5788
  cfg.plugins.entries[existingKey].config = Object.assign({}, cfg.plugins.entries[existingKey].config, {
5070
5789
  hostOs: await resolveProjectHostOs(projectDir),
5790
+ // The plugin ships these off: editing the Docker build files, running page JavaScript
5791
+ // and uploading local files are things it will not do until an operator says so.
5792
+ // Installing it from this dashboard IS that operator saying so — otherwise browsing
5793
+ // would need a hand-edited config right after a one-click install.
5794
+ ...browserAutomationOptIns(),
5071
5795
  });
5072
5796
  cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
5073
5797
  cfg.tools.alsoAllow = Array.from(new Set([...(cfg.tools.alsoAllow || []), 'group:web']));
@@ -5923,6 +6647,15 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
5923
6647
  // Bring the host-control service back up when the operator left it enabled, so the bot's
5924
6648
  // saved instructions keep working across installer restarts.
5925
6649
  ensureHostControl(projectDir).catch(() => {});
5926
- }
5927
-
5928
- 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 };
6650
+ // Warm the probes the first page load would otherwise wait on (project list, runtime versions,
6651
+ // public IP, Zalo status). They run while the browser is still starting, so the dashboard opens
6652
+ // against a warm cache instead of paying for docker and CLI round-trips on first paint.
6653
+ Promise.all([
6654
+ discoverProjects(projectDir).catch(() => {}),
6655
+ getCurrentRuntimeVersions().catch(() => {}),
6656
+ getPublicIp().catch(() => {}),
6657
+ existsSync(join(projectDir, '.openclaw', 'openclaw.json')) ? getZaloHealth(projectDir).catch(() => {}) : null,
6658
+ ]).catch(() => {});
6659
+ }
6660
+
6661
+ 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 };