create-openclaw-bot 5.15.2 → 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.
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.2-0EA5E9?style=for-the-badge" alt="Version 5.15.2" /></a>
10
+ <a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.15.4-0EA5E9?style=for-the-badge" alt="Version 5.15.4" /></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.2
40
+ ## 🆕 What's New in v5.15.4
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.2-0EA5E9?style=for-the-badge" alt="Version 5.15.2" /></a>
10
+ <a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.15.4-0EA5E9?style=for-the-badge" alt="Version 5.15.4" /></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.2
40
+ ## 🆕 Có gì mới trong v5.15.4
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.
@@ -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
 
@@ -591,7 +592,18 @@ function detectOs() {
591
592
  const platform = process.platform;
592
593
  if (platform === 'win32') return 'win';
593
594
  if (platform === 'darwin') return 'macos';
594
- 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
+ }
595
607
  return 'linux-desktop';
596
608
  }
597
609
 
@@ -913,6 +925,11 @@ function runCapture(cmd, args, opts = {}) {
913
925
  windowsHide: opts.windowsHide ?? true,
914
926
  env: { ...process.env, ...(opts.env || {}) },
915
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
+ }
916
933
  let timedOut = false;
917
934
  const timer = Number.isFinite(opts.timeout) && opts.timeout > 0
918
935
  ? setTimeout(() => {
@@ -1755,6 +1772,25 @@ function portStatus(port) {
1755
1772
  });
1756
1773
  }
1757
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
+
1758
1794
  async function buildBotStatus() {
1759
1795
  if (state.projectDir) await syncRuntimeState(state.projectDir).catch(() => {});
1760
1796
  const [gatewayStatus, routerStatus, bots, runtimeVersions] = await Promise.all([
@@ -2104,6 +2140,35 @@ async function waitForDockerContainer(name, timeoutMs = 30000) {
2104
2140
  return false;
2105
2141
  }
2106
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.
2107
2172
  async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 90000, channelKeywords = ['zalo-connect', 'openclaw zalo connect']) {
2108
2173
  const started = Date.now();
2109
2174
  // Use dynamic port from env: OPENCLAW_GATEWAY_PORT → OPENCLAW_PORT → fallback 18789
@@ -2118,7 +2183,7 @@ async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 900
2118
2183
  const status = String(out.stdout || '').trim();
2119
2184
  if (status === 'READY') {
2120
2185
  const pluginCheck = await runCapture('docker', ['exec', botContainer, 'sh', '-c', 'openclaw channels status 2>&1 || true'], { cwd: projectDir, shell: false });
2121
- const output = ((pluginCheck.stdout || '') + ' ' + (pluginCheck.stderr || '')).toLowerCase();
2186
+ const output = stripCliWarnings((pluginCheck.stdout || '') + '\n' + (pluginCheck.stderr || '')).toLowerCase();
2122
2187
  if (channelKeywords.some((kw) => output.includes(kw))) {
2123
2188
  ready = true;
2124
2189
  break;
@@ -2142,13 +2207,21 @@ async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, chan
2142
2207
  const started = Date.now();
2143
2208
  const meta = readNativeMeta(projectDir) || {};
2144
2209
  const port = String(meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
2210
+ const extDir = join(projectDir, '.openclaw', 'extensions', 'zalo-connect');
2145
2211
  let ready = false;
2146
2212
  let attempts = 0;
2147
2213
  while (Date.now() - started < timeoutMs) {
2148
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
+ }
2149
2222
  if (await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500)) {
2150
2223
  const st = await ocCapture(projectDir, ['channels', 'status']).catch(() => ({ stdout: '', stderr: '' }));
2151
- const output = ((st.stdout || '') + ' ' + (st.stderr || '')).toLowerCase();
2224
+ const output = stripCliWarnings((st.stdout || '') + '\n' + (st.stderr || '')).toLowerCase();
2152
2225
  if (channelKeywords.some((kw) => output.includes(kw))) { ready = true; break; }
2153
2226
  if (attempts > 2) sendLog('[zalo-connect] Gateway healthy but Zalo Connect is not loaded yet (' + Math.round((Date.now() - started) / 1000) + 's)...');
2154
2227
  } else if (attempts > 2 && attempts % 3 === 0) {
@@ -2198,20 +2271,17 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2198
2271
  // No container: the gateway runs as a managed service on the host. Wait for it to
2199
2272
  // report the zalo-connect channel; if it never does and the plugin folder is absent,
2200
2273
  // install it on the host (into this project's .openclaw/extensions) and reload.
2201
- const gatewayReady = await waitForNativeGatewayZaloReady(projectDir, 180000, ['zalo-connect']);
2274
+ const gatewayReady = await waitForNativeGatewayZaloReady(projectDir, 180000);
2202
2275
  if (!gatewayReady) {
2203
- const extDir = join(projectDir, '.openclaw', 'extensions', 'zalo-connect');
2204
- if (!existsSync(extDir)) {
2205
- sendLog(`[zalo-connect] Plugin missing installing ${ZALO_CONNECT_PLUGIN_SPEC} natively...`);
2206
- const inst = await ocCapture(projectDir, ['plugins', 'install', ZALO_CONNECT_PLUGIN_SPEC, '--force', '--acknowledge-clawhub-risk']);
2207
- const instOut = `${inst.stdout}\n${inst.stderr}`;
2208
- for (const line of instOut.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
2209
- if (/installed plugin/i.test(instOut) || existsSync(extDir)) {
2210
- await restartNativeRuntime(projectDir).catch((err) => sendLog(`[native] restart skipped/failed: ${err.message}`));
2211
- await waitForNativeGatewayZaloReady(projectDir, 180000, ['zalo-connect']);
2212
- } else {
2213
- sendLog('[zalo-connect] Cài plugin không thành công — thử lại bằng nút "Đăng nhập Zalo".');
2214
- }
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".');
2215
2285
  }
2216
2286
  }
2217
2287
  } else {
@@ -2224,7 +2294,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2224
2294
  // backend-aware entrypoint existed).
2225
2295
  const containerUp = await waitForDockerContainer(botContainer, 90000);
2226
2296
  if (!containerUp) sendLog(`[zalo-connect] ${botContainer} chưa chạy sau 90s — vẫn thử tiếp...`);
2227
- const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
2297
+ const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000);
2228
2298
  if (!gatewayReady) {
2229
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' }));
2230
2300
  if (String(check.stdout || '').trim() === 'MISSING') {
@@ -2237,7 +2307,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2237
2307
  // Gateway must reload to pick the plugin up — safe here: the gateway is past
2238
2308
  // its boot (we only reach this branch when it answered the exec above).
2239
2309
  await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
2240
- await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
2310
+ await waitForGatewayZaloReady(botContainer, projectDir, 180000);
2241
2311
  } else {
2242
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.');
2243
2313
  }
@@ -2563,10 +2633,13 @@ function getBotServiceName(projectDir) {
2563
2633
  // single fixed one, so without this a second native project would take over the first's service.
2564
2634
 
2565
2635
  const NATIVE_MARKER = 'native.json';
2566
- // Native ports sit one hundred above the docker ones (18789/20128) so a native project can run
2567
- // next to a docker project or next to an SSH tunnel forwarding a remote bot's ports untouched.
2568
- const NATIVE_DEFAULT_GATEWAY_PORT = 18889;
2569
- 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;
2570
2643
 
2571
2644
  function nativeMarkerPath(projectDir) {
2572
2645
  return join(projectDir || state.projectDir || '', '.openclaw', NATIVE_MARKER);
@@ -2638,6 +2711,47 @@ function ocCapture(projectDir, args, opts = {}) {
2638
2711
  return runCapture(a.cmd, a.args, { shell: false, ...a.opts, ...opts, env: { ...(a.opts.env || {}), ...(opts.env || {}) } });
2639
2712
  }
2640
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
+
2641
2755
  /**
2642
2756
  * Restart the native gateway service.
2643
2757
  *
@@ -2646,20 +2760,105 @@ function ocCapture(projectDir, args, opts = {}) {
2646
2760
  * newly installed plugins never load, silently). stop+start is what actually works there, and it
2647
2761
  * works everywhere else too, so Windows takes that path and other systems keep `restart` with
2648
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.
2649
2768
  */
2650
2769
  async function restartNativeRuntime(projectDir) {
2651
- const env = nativeEnv(projectDir);
2652
2770
  const stopStart = async () => {
2653
- await run('openclaw', ['daemon', 'stop'], { cwd: projectDir, env }).catch(() => {});
2654
- await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
2771
+ await ocDaemon(projectDir, 'stop');
2772
+ return ocDaemon(projectDir, 'start');
2655
2773
  };
2656
- if (process.platform === 'win32') return stopStart();
2657
- try {
2658
- await run('openclaw', ['daemon', 'restart'], { cwd: projectDir, env });
2659
- } catch (e) {
2660
- sendLog(`[native] daemon restart failed (${e.message}); falling back to stop+start`);
2661
- 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();
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;
2662
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;
2663
2862
  }
2664
2863
 
2665
2864
  /** Fire-and-forget background process (9router has no service wrapper of its own). */
@@ -2763,11 +2962,20 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
2763
2962
  await new Promise((r) => setTimeout(r, 8000));
2764
2963
  await applyResolved9RouterApiKey(projectDir).catch(() => {});
2765
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
+
2766
2970
  // Managed service = auto-restart (KeepAlive/Restart=always) and start-at-login, the native
2767
2971
  // equivalent of docker's `restart: always`. --force so re-running install updates the port.
2768
2972
  const env = nativeEnv(projectDir);
2973
+ await ensureSystemdLinger();
2769
2974
  await run('openclaw', ['daemon', 'install', '--force', '--port', String(gwPort)], { cwd: projectDir, env });
2770
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);
2771
2979
  sendLog(`[native] gateway service "${label}" running on 127.0.0.1:${gwPort}, 9router on 127.0.0.1:${rtPort}`);
2772
2980
  return { gatewayPort: gwPort, routerPort: rtPort, label };
2773
2981
  }
@@ -2916,6 +3124,14 @@ async function recreateDockerBot(projectDir) {
2916
3124
  // Native: there is no image to rebuild — the gateway reads openclaw.json from disk on boot, so
2917
3125
  // reloading config after a bot/plugin change is just a service restart. Callers stay unchanged.
2918
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}`));
2919
3135
  sendLog('[native] Reloading gateway to pick up openclaw.json changes...');
2920
3136
  await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart failed: ${e.message}`));
2921
3137
  probeCacheClear();
@@ -3277,13 +3493,58 @@ function whichSync(name) {
3277
3493
  try {
3278
3494
  const finder = process.platform === 'win32' ? 'where' : 'which';
3279
3495
  const out = execFileSync(finder, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
3280
- const first = String(out).split(/\r?\n/).map((s) => s.trim()).find(Boolean);
3281
- 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] || '';
3282
3508
  } catch (_) {
3283
3509
  return '';
3284
3510
  }
3285
3511
  }
3286
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
+
3287
3548
  /**
3288
3549
  * CLI tools the bot may RUN (not just open) via /api/host/exec — output is captured and
3289
3550
  * returned. Kept as a name→path allow-list, mirroring detectHostApps: the executable is fixed,
@@ -3348,6 +3609,13 @@ function grantHostCapabilities(cfg) {
3348
3609
  added.push(name);
3349
3610
  }
3350
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
+ }
3351
3619
  return added;
3352
3620
  }
3353
3621
 
@@ -3619,7 +3887,8 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
3619
3887
  };
3620
3888
  let child;
3621
3889
  try {
3622
- 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 });
3623
3892
  } catch (e) {
3624
3893
  return finish({ ok: false, error: e.message }, 500);
3625
3894
  }
@@ -3639,6 +3908,262 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
3639
3908
  });
3640
3909
  }
3641
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
+
3642
4167
  async function handleHostControl(req, res, projectDir) {
3643
4168
  const cfg = await readHostControlConfig(projectDir);
3644
4169
  const url = new URL(req.url, 'http://localhost');
@@ -3656,6 +4181,16 @@ async function handleHostControl(req, res, projectDir) {
3656
4181
  if (url.pathname === '/api/host/apps' && req.method === 'GET') {
3657
4182
  return json(res, { ok: true, apps: Object.keys(cfg.apps || {}), commands: Object.keys(cfg.commands || {}), platform: process.platform });
3658
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
+ }
3659
4194
  if (url.pathname === '/api/host/exec' && req.method === 'POST') {
3660
4195
  const body = await readJson(req).catch(() => ({}));
3661
4196
  const name = String(body.command || '').trim().toLowerCase();
@@ -3723,6 +4258,39 @@ async function writeHostControlAccess(projectDir, cfg) {
3723
4258
  '',
3724
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.`,
3725
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
+ ] : [];
3726
4294
  // Screen capture / recording — only advertised when the operator granted the matching tool, so
3727
4295
  // the bot never tries a binary that is not on this machine's allow-list.
3728
4296
  // Windows has no capture binary to allow-list (PowerShell does it inline), so the section shows
@@ -3829,6 +4397,7 @@ async function writeHostControlAccess(projectDir, cfg) {
3829
4397
  '',
3830
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`.',
3831
4399
  ...execBlock,
4400
+ ...uiBlock,
3832
4401
  // Docker only: a screenshot taken on the host lands on the HOST filesystem, which this
3833
4402
  // container cannot read — say so instead of letting the bot hunt for a missing file.
3834
4403
  ...(hasCapture ? [
@@ -4156,13 +4725,18 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
4156
4725
  state.os = osChoice;
4157
4726
  state.startedAt = new Date().toISOString();
4158
4727
  try {
4159
- // Native runs on the host's own ports, so it must not land on the docker defaults: a machine
4160
- // 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.
4161
4732
  if (mode === 'native') {
4162
- if (gatewayPort === 18789) gatewayPort = NATIVE_DEFAULT_GATEWAY_PORT;
4163
- if (routerPort === 20128) routerPort = NATIVE_DEFAULT_ROUTER_PORT;
4733
+ gatewayPort = await findFreeHostPort(gatewayPort, { reserveNext: true });
4734
+ routerPort = await findFreeHostPort(routerPort);
4164
4735
  state.gatewayPort = gatewayPort;
4165
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}`);
4166
4740
  }
4167
4741
  sendLog('OpenClaw local installer started');
4168
4742
  sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
@@ -6084,4 +6658,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
6084
6658
  ]).catch(() => {});
6085
6659
  }
6086
6660
 
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 };
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 };
@@ -277,11 +277,19 @@
277
277
  }
278
278
 
279
279
  // ── gateway ──────────────────────────────────────────────────────────────
280
+ // Docker MUST bind 0.0.0.0 inside the container to be reachable at all, but that is contained:
281
+ // compose publishes the port as `127.0.0.1:<port>:<port>` (docker-gen), so the host still only
282
+ // answers on loopback. A NATIVE gateway has no port mapping to hide behind — 0.0.0.0 there puts
283
+ // it straight on the VPS's public interface, with the auth token crossing the wire in plaintext
284
+ // (the gateway speaks plain HTTP/WS) and, on a fresh VPS, no firewall in front of it. The
285
+ // `osChoice === 'vps'` clause predates native mode; keep native on loopback and reach it through
286
+ // an SSH tunnel, which is what docker-on-a-VPS effectively does too.
287
+ const openGatewayBind = deployMode === 'docker' || (osChoice === 'vps' && deployMode !== 'native');
280
288
  cfg.gateway = {
281
289
  port: gatewayPort,
282
290
  mode: 'local',
283
- bind: (deployMode === 'docker' || osChoice === 'vps') ? 'custom' : 'loopback',
284
- ...(deployMode === 'docker' || osChoice === 'vps' ? { customBindHost: '0.0.0.0' } : {}),
291
+ bind: openGatewayBind ? 'custom' : 'loopback',
292
+ ...(openGatewayBind ? { customBindHost: '0.0.0.0' } : {}),
285
293
  controlUi: {
286
294
  allowedOrigins: gatewayAllowedOrigins.length > 0
287
295
  ? gatewayAllowedOrigins
@@ -0,0 +1,179 @@
1
+ // @ts-nocheck
2
+ // PowerShell backend for host UI automation on Windows.
3
+ //
4
+ // Windows has no xdotool and no bundled CLI for input, so the actions are done the way the
5
+ // no-dependency tools do it: P/Invoke into user32.dll for the pointer and keys (SetCursorPos /
6
+ // mouse_event / keybd_event), System.Windows.Forms.SendKeys for text, System.Drawing's
7
+ // CopyFromScreen for captures, and Get/Set-Clipboard for the clipboard. Shipping this as one
8
+ // version-stamped .ps1 keeps the quoting sane: the server passes typed parameters, never a
9
+ // composed command line.
10
+ (function (root) {
11
+ const HOST_UI_PS1_VERSION = '1';
12
+
13
+ const HOST_UI_PS1 = String.raw`# OpenClaw host UI helper — version __VERSION__
14
+ # Generated by openclaw-setup. Edits are overwritten when the version changes.
15
+ param(
16
+ [Parameter(Mandatory = $true)][string]$Action,
17
+ [int]$X = -1,
18
+ [int]$Y = -1,
19
+ [int]$ToX = -1,
20
+ [int]$ToY = -1,
21
+ [int]$Amount = 3,
22
+ [string]$Text = '',
23
+ [string]$Button = 'left',
24
+ [int]$Clicks = 1,
25
+ [string]$Path = '',
26
+ [string]$Title = ''
27
+ )
28
+
29
+ $ErrorActionPreference = 'Stop'
30
+ Add-Type -AssemblyName System.Windows.Forms, System.Drawing
31
+
32
+ if (-not ('OpenClawUi' -as [type])) {
33
+ Add-Type -Namespace '' -Name OpenClawUi -MemberDefinition @'
34
+ [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
35
+ [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, int extra);
36
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr hWnd);
37
+ [DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int cmd);
38
+ '@
39
+ }
40
+
41
+ $MOUSEEVENTF = @{
42
+ leftdown = 0x0002; leftup = 0x0004;
43
+ rightdown = 0x0008; rightup = 0x0010;
44
+ middledown = 0x0020; middleup = 0x0040;
45
+ wheel = 0x0800; hwheel = 0x01000;
46
+ }
47
+
48
+ function Out-Json($obj) { $obj | ConvertTo-Json -Compress -Depth 6 }
49
+
50
+ function Move-Pointer([int]$px, [int]$py) {
51
+ if ($px -ge 0 -and $py -ge 0) { [OpenClawUi]::SetCursorPos($px, $py) | Out-Null; Start-Sleep -Milliseconds 40 }
52
+ }
53
+
54
+ function Invoke-Click([string]$btn, [int]$times) {
55
+ $down = $MOUSEEVENTF[($btn + 'down')]
56
+ $up = $MOUSEEVENTF[($btn + 'up')]
57
+ if (-not $down) { throw "unknown button: $btn" }
58
+ for ($i = 0; $i -lt [Math]::Max(1, $times); $i++) {
59
+ [OpenClawUi]::mouse_event($down, 0, 0, 0, 0)
60
+ [OpenClawUi]::mouse_event($up, 0, 0, 0, 0)
61
+ Start-Sleep -Milliseconds 60
62
+ }
63
+ }
64
+
65
+ # SendKeys needs its own escaping: + ^ % ~ ( ) { } [ ] are modifiers there, so literal text has
66
+ # to be wrapped in braces or the bot's message silently turns into shortcuts.
67
+ function ConvertTo-SendKeysLiteral([string]$s) {
68
+ $sb = New-Object System.Text.StringBuilder
69
+ foreach ($ch in $s.ToCharArray()) {
70
+ if ('+^%~(){}[]'.Contains($ch)) { [void]$sb.Append('{' + $ch + '}') }
71
+ elseif ($ch -eq [char]10) { [void]$sb.Append('{ENTER}') }
72
+ elseif ($ch -eq [char]9) { [void]$sb.Append('{TAB}') }
73
+ elseif ($ch -eq [char]13) { }
74
+ else { [void]$sb.Append($ch) }
75
+ }
76
+ $sb.ToString()
77
+ }
78
+
79
+ # "ctrl+shift+t" / "enter" / "cmd+a" (cmd is mapped to ctrl on Windows) → SendKeys syntax.
80
+ function ConvertTo-SendKeysCombo([string]$combo) {
81
+ $named = @{
82
+ enter = '{ENTER}'; return = '{ENTER}'; tab = '{TAB}'; esc = '{ESC}'; escape = '{ESC}';
83
+ backspace = '{BACKSPACE}'; delete = '{DELETE}'; del = '{DELETE}'; home = '{HOME}'; end = '{END}';
84
+ pageup = '{PGUP}'; pagedown = '{PGDN}'; up = '{UP}'; down = '{DOWN}'; left = '{LEFT}'; right = '{RIGHT}';
85
+ space = ' '; f1 = '{F1}'; f2 = '{F2}'; f3 = '{F3}'; f4 = '{F4}'; f5 = '{F5}'; f6 = '{F6}';
86
+ f7 = '{F7}'; f8 = '{F8}'; f9 = '{F9}'; f10 = '{F10}'; f11 = '{F11}'; f12 = '{F12}';
87
+ printscreen = '{PRTSC}'; insert = '{INSERT}';
88
+ }
89
+ $prefix = ''
90
+ $parts = $combo.Split('+') | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ }
91
+ $keyPart = ''
92
+ foreach ($p in $parts) {
93
+ switch ($p) {
94
+ 'ctrl' { $prefix += '^' }
95
+ 'control' { $prefix += '^' }
96
+ 'cmd' { $prefix += '^' } # macOS habit; Windows equivalent is Ctrl
97
+ 'meta' { $prefix += '^' }
98
+ 'alt' { $prefix += '%' }
99
+ 'shift' { $prefix += '+' }
100
+ 'win' { $prefix += '' } # SendKeys cannot press Win; use the Windows key combos below
101
+ default { $keyPart = $p }
102
+ }
103
+ }
104
+ if (-not $keyPart) { throw "no key in combo: $combo" }
105
+ $mapped = if ($named.ContainsKey($keyPart)) { $named[$keyPart] } else { $keyPart }
106
+ $prefix + $mapped
107
+ }
108
+
109
+ switch ($Action) {
110
+ 'screen_size' {
111
+ $b = [System.Windows.Forms.SystemInformation]::VirtualScreen
112
+ Out-Json @{ ok = $true; width = $b.Width; height = $b.Height; left = $b.Left; top = $b.Top }
113
+ }
114
+ 'screenshot' {
115
+ if (-not $Path) { throw 'screenshot needs -Path' }
116
+ $b = [System.Windows.Forms.SystemInformation]::VirtualScreen
117
+ $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height
118
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
119
+ $g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)
120
+ $dir = Split-Path -Parent $Path
121
+ if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
122
+ $bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
123
+ $g.Dispose(); $bmp.Dispose()
124
+ Out-Json @{ ok = $true; path = $Path; width = $b.Width; height = $b.Height }
125
+ }
126
+ 'mouse_move' { Move-Pointer $X $Y; Out-Json @{ ok = $true; x = $X; y = $Y } }
127
+ 'click' { Move-Pointer $X $Y; Invoke-Click $Button $Clicks; Out-Json @{ ok = $true; button = $Button; clicks = $Clicks } }
128
+ 'drag' {
129
+ Move-Pointer $X $Y
130
+ [OpenClawUi]::mouse_event($MOUSEEVENTF['leftdown'], 0, 0, 0, 0)
131
+ Start-Sleep -Milliseconds 80
132
+ Move-Pointer $ToX $ToY
133
+ [OpenClawUi]::mouse_event($MOUSEEVENTF['leftup'], 0, 0, 0, 0)
134
+ Out-Json @{ ok = $true; from = @($X, $Y); to = @($ToX, $ToY) }
135
+ }
136
+ 'scroll' {
137
+ Move-Pointer $X $Y
138
+ $ticks = [Math]::Max(1, [Math]::Abs($Amount))
139
+ $delta = if ($Amount -lt 0) { -120 } else { 120 }
140
+ for ($i = 0; $i -lt $ticks; $i++) {
141
+ [OpenClawUi]::mouse_event($MOUSEEVENTF['wheel'], 0, 0, [uint32]([int]$delta -band 0xFFFFFFFF), 0)
142
+ Start-Sleep -Milliseconds 40
143
+ }
144
+ Out-Json @{ ok = $true; amount = $Amount }
145
+ }
146
+ 'type' {
147
+ [System.Windows.Forms.SendKeys]::SendWait((ConvertTo-SendKeysLiteral $Text))
148
+ Out-Json @{ ok = $true; typed = $Text.Length }
149
+ }
150
+ 'key' {
151
+ foreach ($combo in ($Text -split '\s+' | Where-Object { $_ })) {
152
+ [System.Windows.Forms.SendKeys]::SendWait((ConvertTo-SendKeysCombo $combo))
153
+ Start-Sleep -Milliseconds 60
154
+ }
155
+ Out-Json @{ ok = $true; keys = $Text }
156
+ }
157
+ 'clipboard_get' { Out-Json @{ ok = $true; text = (Get-Clipboard -Raw) } }
158
+ 'clipboard_set' { Set-Clipboard -Value $Text; Out-Json @{ ok = $true; length = $Text.Length } }
159
+ 'windows' {
160
+ $list = Get-Process | Where-Object { $_.MainWindowTitle } |
161
+ Select-Object -First 40 @{n = 'title'; e = { $_.MainWindowTitle } }, @{n = 'process'; e = { $_.ProcessName } }, Id
162
+ Out-Json @{ ok = $true; windows = @($list) }
163
+ }
164
+ 'focus' {
165
+ if (-not $Title) { throw 'focus needs -Title' }
166
+ $proc = Get-Process | Where-Object { $_.MainWindowTitle -like "*$Title*" } | Select-Object -First 1
167
+ if (-not $proc) { Out-Json @{ ok = $false; error = "no window matching: $Title" }; break }
168
+ [OpenClawUi]::ShowWindow($proc.MainWindowHandle, 9) | Out-Null # SW_RESTORE
169
+ [OpenClawUi]::SetForegroundWindow($proc.MainWindowHandle) | Out-Null
170
+ Out-Json @{ ok = $true; focused = $proc.MainWindowTitle }
171
+ }
172
+ default { Out-Json @{ ok = $false; error = "unknown action: $Action" } }
173
+ }
174
+ `.replace('__VERSION__', HOST_UI_PS1_VERSION);
175
+
176
+ const api = { HOST_UI_PS1, HOST_UI_PS1_VERSION };
177
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
178
+ root.__openclawHostUiPs1 = api;
179
+ })(typeof globalThis !== 'undefined' ? globalThis : this);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-openclaw-bot",
3
- "version": "5.15.2",
3
+ "version": "5.15.4",
4
4
  "description": "Interactive CLI installer for OpenClaw Bot",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {