create-openclaw-bot 5.15.2 → 5.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,215 @@ 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);
2770
+ // Every restart is a chance to repair a project installed before these two fixes existed — both
2771
+ // calls are no-ops once the service env is complete and the stray files have been adopted.
2772
+ await adoptStrayNativeHome(projectDir).catch(() => {});
2773
+ await syncNativeServiceEnv(projectDir).catch(() => {});
2652
2774
  const stopStart = async () => {
2653
- await run('openclaw', ['daemon', 'stop'], { cwd: projectDir, env }).catch(() => {});
2654
- await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
2775
+ await ocDaemon(projectDir, 'stop');
2776
+ return ocDaemon(projectDir, 'start');
2655
2777
  };
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();
2778
+ let res;
2779
+ if (process.platform === 'win32') {
2780
+ res = await stopStart();
2781
+ } else {
2782
+ res = await ocDaemon(projectDir, 'restart');
2783
+ // A lease collision is a "come back in a moment", not a broken service: stop+start would only
2784
+ // collide again, so fall through to the wait below instead.
2785
+ if (res.code !== 0 && !migrationLeaseDeadline(res.text)) {
2786
+ sendLog(`[native] daemon restart exited ${res.code}; falling back to stop+start`);
2787
+ res = await stopStart();
2788
+ }
2789
+ }
2790
+ const deadline = migrationLeaseDeadline(res.text);
2791
+ if (deadline) {
2792
+ const waitMs = Math.max(0, Math.min(deadline - Date.now(), 300000)) + 3000;
2793
+ sendLog(`[native] Startup migrations hold the state lease — waiting ${Math.ceil(waitMs / 1000)}s before retrying.`);
2794
+ await new Promise((r) => setTimeout(r, waitMs));
2795
+ res = await ocDaemon(projectDir, 'restart');
2796
+ if (res.code !== 0) res = await stopStart();
2797
+ }
2798
+ // systemd keeps restarting a crash-looping unit every RestartSec, so a gateway blocked by a lease
2799
+ // we never saw still comes up on its own — give it room before calling the restart a failure.
2800
+ if (!(await waitForNativeGatewayHealthy(projectDir, 180000))) {
2801
+ throw new Error('gateway did not answer /health after restart');
2802
+ }
2803
+ return true;
2804
+ }
2805
+
2806
+ /**
2807
+ * Make the generated service carry everything nativeEnv() promises.
2808
+ *
2809
+ * `openclaw daemon install` propagates only a fixed allow-list into the service it writes:
2810
+ * OPENCLAW_STATE_DIR survives, but OPENCLAW_HOME does NOT — verified on both a systemd user unit and
2811
+ * a launchd env-wrapper. Anything resolving paths from OPENCLAW_HOME then falls back to `~/.openclaw`
2812
+ * and writes OUTSIDE the project. zalo-connect is the visible casualty: it stages inbound files and
2813
+ * its Zalo session credentials under the wrong home, so a PDF sent to the bot lands somewhere the
2814
+ * agent's workspace cannot reach ("em chưa trích xuất được nội dung từ PDF") and the session sits in
2815
+ * a different home from the config that describes it.
2816
+ *
2817
+ * Idempotent: keys already present are left alone, so this is safe to run on every restart and it
2818
+ * self-heals projects created before the fix.
2819
+ */
2820
+ async function syncNativeServiceEnv(projectDir) {
2821
+ if (!isNativeProject(projectDir)) return [];
2822
+ const want = nativeEnv(projectDir);
2823
+ const label = nativeServiceLabel(projectDir);
2824
+
2825
+ if (process.platform === 'linux') {
2826
+ const unit = `${label}.service`;
2827
+ const shown = await runCapture('systemctl', ['--user', 'show', '-p', 'FragmentPath', '--value', unit], { shell: false, timeout: 10000 });
2828
+ const path = String(shown.stdout || '').trim() || join(os.homedir(), '.config', 'systemd', 'user', unit);
2829
+ if (!existsSync(path)) return [];
2830
+ const lines = (await fsp.readFile(path, 'utf8')).split('\n');
2831
+ const have = new Set();
2832
+ for (const line of lines) {
2833
+ const m = line.trim().match(/^Environment="?([A-Z_0-9]+)=/);
2834
+ if (m) have.add(m[1]);
2835
+ }
2836
+ const missing = Object.entries(want).filter(([k, v]) => !have.has(k) && v !== '');
2837
+ if (!missing.length) return [];
2838
+ // Insert after the last existing Environment= line so the additions stay inside [Service].
2839
+ let at = -1;
2840
+ lines.forEach((line, i) => { if (line.startsWith('Environment=')) at = i; });
2841
+ if (at < 0) at = lines.findIndex((line) => line.trim() === '[Service]');
2842
+ if (at < 0) return [];
2843
+ lines.splice(at + 1, 0, ...missing.map(([k, v]) => `Environment=${k}=${v}`));
2844
+ await fsp.copyFile(path, `${path}.bak`).catch(() => {});
2845
+ await fsp.writeFile(path, lines.join('\n'), 'utf8');
2846
+ await run('systemctl', ['--user', 'daemon-reload'], {}).catch(() => {});
2847
+ sendLog(`[native] service env completed: ${missing.map(([k]) => k).join(', ')}`);
2848
+ return missing.map(([k]) => k);
2662
2849
  }
2850
+
2851
+ if (process.platform === 'darwin') {
2852
+ // launchd runs the gateway through an env-wrapper that sources this file, so patching it is the
2853
+ // launchd equivalent of adding Environment= lines to a unit.
2854
+ const envFile = join(projectDir, '.openclaw', 'service-env', `${label}.env`);
2855
+ if (!existsSync(envFile)) return [];
2856
+ const body = await fsp.readFile(envFile, 'utf8');
2857
+ const have = new Set([...body.matchAll(/^\s*export\s+([A-Z_0-9]+)=/gm)].map((m) => m[1]));
2858
+ const missing = Object.entries(want).filter(([k, v]) => !have.has(k) && v !== '');
2859
+ if (!missing.length) return [];
2860
+ const added = missing.map(([k, v]) => `export ${k}='${String(v).replace(/'/g, "'\\''")}'`).join('\n');
2861
+ await fsp.copyFile(envFile, `${envFile}.bak`).catch(() => {});
2862
+ await fsp.writeFile(envFile, `${body.replace(/\n*$/, '')}\n${added}\n`, 'utf8');
2863
+ sendLog(`[native] service env completed: ${missing.map(([k]) => k).join(', ')}`);
2864
+ return missing.map(([k]) => k);
2865
+ }
2866
+
2867
+ return [];
2868
+ }
2869
+
2870
+ /**
2871
+ * Reunite a native project with the files an unset OPENCLAW_HOME scattered into `~/.openclaw`.
2872
+ *
2873
+ * This MUST run before syncNativeServiceEnv takes effect: once OPENCLAW_HOME is finally correct, the
2874
+ * plugin looks for its Zalo session inside the project — and if the credentials are still sitting in
2875
+ * the home directory it finds nothing and demands a fresh QR login. Copy (never move) so a failed
2876
+ * run leaves the working original in place; skip anything the project already has.
2877
+ */
2878
+ async function adoptStrayNativeHome(projectDir) {
2879
+ if (!isNativeProject(projectDir)) return [];
2880
+ const projectHome = join(projectDir, '.openclaw');
2881
+ const strayHome = join(os.homedir(), '.openclaw');
2882
+ if (resolve(strayHome) === resolve(projectHome) || !existsSync(strayHome)) return [];
2883
+ const moved = [];
2884
+ const entries = await fsp.readdir(strayHome, { withFileTypes: true }).catch(() => []);
2885
+ for (const entry of entries) {
2886
+ // `state` is deliberately excluded: the project has its own live database and merging two
2887
+ // sqlite files is not something a copy can do correctly.
2888
+ const isCreds = /^zalo-connect-credentials.*\.json$/.test(entry.name);
2889
+ if (!isCreds && entry.name !== 'media') continue;
2890
+ const from = join(strayHome, entry.name);
2891
+ const to = join(projectHome, entry.name);
2892
+ if (existsSync(to)) continue;
2893
+ await fsp.cp(from, to, { recursive: true }).catch(() => {});
2894
+ if (existsSync(to)) moved.push(entry.name);
2895
+ }
2896
+ if (moved.length) {
2897
+ sendLog(`[migrate] Native: adopted ${moved.join(', ')} from ${strayHome} (written there while OPENCLAW_HOME was unset).`);
2898
+ }
2899
+ return moved;
2900
+ }
2901
+
2902
+ /**
2903
+ * `openclaw daemon install` has no `--system` flag, so on Linux the gateway becomes a systemd USER
2904
+ * unit — and a user manager without linger is torn down when that user's last session exits. On a
2905
+ * desktop the graphical session holds it open, which is why this never showed up on macOS or a
2906
+ * Linux desktop; on a VPS the bot dies the moment the operator closes SSH and never comes back
2907
+ * after a reboot. Linger is what makes a user unit behave like the `restart: always` container it
2908
+ * replaces. Best-effort: a box without loginctl just keeps the old behaviour, loudly.
2909
+ */
2910
+ async function ensureSystemdLinger() {
2911
+ if (process.platform !== 'linux') return false;
2912
+ let user = '';
2913
+ try { user = process.env.SUDO_USER || os.userInfo().username; } catch { return false; }
2914
+ if (!user) return false;
2915
+ const cur = await runCapture('loginctl', ['show-user', user, '-p', 'Linger'], { shell: false, timeout: 10000 });
2916
+ if (/Linger=yes/i.test(cur.stdout || '')) return true;
2917
+ const out = await runCapture('loginctl', ['enable-linger', user], { shell: false, timeout: 20000 });
2918
+ if (out.code === 0) {
2919
+ sendLog(`[native] systemd linger enabled for "${user}" — the gateway now survives logout and reboot.`);
2920
+ return true;
2921
+ }
2922
+ sendLog(`[native] WARNING: could not enable systemd linger for "${user}" (${(out.stderr || out.stdout || '').trim() || `exit ${out.code}`}).`);
2923
+ sendLog(`[native] The gateway will stop when this user's last session ends. Fix it with: sudo loginctl enable-linger ${user}`);
2924
+ return false;
2925
+ }
2926
+
2927
+ /**
2928
+ * Native counterpart of the docker entrypoint's `ensure_plugin` (docker-gen.js).
2929
+ *
2930
+ * A container reinstalls its missing plugins on every boot; a native project has no entrypoint, so
2931
+ * nothing ever put zalo-connect or learning-memory on disk. The generated config declares both
2932
+ * anyway (bot-config-gen writes plugins.entries + allow + slots.contextEngine), so without this the
2933
+ * gateway boots with "plugin not found" warnings, `channels.zalo-connect` has no owner — Zalo login
2934
+ * fails with `Unsupported channel "zalo-connect"` — and the bot silently runs with no context
2935
+ * engine at all. Same set and same skip-if-present cheapness as ensure_plugin.
2936
+ */
2937
+ async function ensureNativePlugins(projectDir, { restart = false } = {}) {
2938
+ if (!isNativeProject(projectDir)) return [];
2939
+ // Same cleanup the container entrypoint does (docker-gen.js): an interrupted `plugins install`
2940
+ // leaves extensions/.openclaw-install-stage-XXXXXX behind, and it still carries a plugin manifest —
2941
+ // so the gateway logs "duplicate plugin id detected" every boot and a stale build competes with the
2942
+ // real one for the same id. Native has no entrypoint, so it has to happen here.
2943
+ const extRoot = join(projectDir, '.openclaw', 'extensions');
2944
+ for (const entry of await fsp.readdir(extRoot, { withFileTypes: true }).catch(() => [])) {
2945
+ if (!entry.isDirectory() || !entry.name.startsWith('.openclaw-install-stage-')) continue;
2946
+ await fsp.rm(join(extRoot, entry.name), { recursive: true, force: true }).catch(() => {});
2947
+ sendLog(`[native] removed abandoned plugin staging dir ${entry.name}`);
2948
+ }
2949
+ let cfg = {};
2950
+ try { cfg = JSON.parse(await fsp.readFile(join(projectDir, '.openclaw', 'openclaw.json'), 'utf8')); } catch {}
2951
+ // learning-memory backs plugins.slots.contextEngine for every bot; zalo-connect only when a bot
2952
+ // actually declares the channel (mirrors docker-gen's `if (zaloBackend === 'zalo-connect')`).
2953
+ const wanted = new Set(['learning-memory']);
2954
+ if (cfg?.channels?.[ZALO_CHANNEL_ID] || cfg?.plugins?.entries?.[ZALO_PLUGIN_ID]) wanted.add(ZALO_PLUGIN_ID);
2955
+ const installed = [];
2956
+ for (const id of wanted) {
2957
+ const dir = join(projectDir, '.openclaw', 'extensions', id);
2958
+ if (existsSync(dir)) continue;
2959
+ const spec = id === ZALO_PLUGIN_ID ? ZALO_CONNECT_PLUGIN_SPEC : pluginInstallSpec(id);
2960
+ sendLog(`[native] plugin ${id} missing; installing ${spec}`);
2961
+ const out = await ocCapture(projectDir, ['plugins', 'install', spec, '--force', '--acknowledge-clawhub-risk'], { timeout: 300000 });
2962
+ const text = `${out.stdout || ''}\n${out.stderr || ''}`;
2963
+ for (const line of text.split(/\r?\n/).map((l) => l.trimEnd()).filter(Boolean)) sendLog(`[native] ${line}`);
2964
+ if (existsSync(dir) || /installed plugin/i.test(text)) installed.push(id);
2965
+ else sendLog(`[native] WARNING: could not install plugin ${id} — the bot will run without it.`);
2966
+ }
2967
+ if (installed.length && restart) {
2968
+ sendLog(`[native] Restarting gateway to load: ${installed.join(', ')}`);
2969
+ await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart after plugin install: ${e.message}`));
2970
+ }
2971
+ return installed;
2663
2972
  }
2664
2973
 
2665
2974
  /** Fire-and-forget background process (9router has no service wrapper of its own). */
@@ -2763,11 +3072,25 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
2763
3072
  await new Promise((r) => setTimeout(r, 8000));
2764
3073
  await applyResolved9RouterApiKey(projectDir).catch(() => {});
2765
3074
 
3075
+ // Plugins BEFORE the gateway's first boot — the container entrypoint installs them ahead of the
3076
+ // gateway for the same reason: a gateway that boots with its plugins already on disk loads them
3077
+ // straight away, needs no follow-up restart, and prints no "plugin not found" warnings.
3078
+ await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin bootstrap skipped: ${e.message}`));
3079
+
2766
3080
  // Managed service = auto-restart (KeepAlive/Restart=always) and start-at-login, the native
2767
3081
  // equivalent of docker's `restart: always`. --force so re-running install updates the port.
2768
3082
  const env = nativeEnv(projectDir);
3083
+ await ensureSystemdLinger();
2769
3084
  await run('openclaw', ['daemon', 'install', '--force', '--port', String(gwPort)], { cwd: projectDir, env });
3085
+ // Order is load-bearing: adopt the stray files FIRST, then complete the service env. The other way
3086
+ // round, the gateway boots with a corrected OPENCLAW_HOME, finds no Zalo session there, and asks
3087
+ // for a new QR scan even though a perfectly good session exists in the home directory.
3088
+ await adoptStrayNativeHome(projectDir).catch((e) => sendLog(`[migrate] stray home skipped: ${e.message}`));
3089
+ await syncNativeServiceEnv(projectDir).catch((e) => sendLog(`[native] service env sync skipped: ${e.message}`));
2770
3090
  await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
3091
+ // Let the first boot finish its state migrations here, while nothing else is competing for the
3092
+ // lease. Every later action (create bot, install plugin) then restarts a settled gateway.
3093
+ await waitForNativeGatewayHealthy(projectDir, 180000);
2771
3094
  sendLog(`[native] gateway service "${label}" running on 127.0.0.1:${gwPort}, 9router on 127.0.0.1:${rtPort}`);
2772
3095
  return { gatewayPort: gwPort, routerPort: rtPort, label };
2773
3096
  }
@@ -2916,6 +3239,14 @@ async function recreateDockerBot(projectDir) {
2916
3239
  // Native: there is no image to rebuild — the gateway reads openclaw.json from disk on boot, so
2917
3240
  // reloading config after a bot/plugin change is just a service restart. Callers stay unchanged.
2918
3241
  if (isNativeProject(projectDir)) {
3242
+ // Never restart a gateway that is still on its first boot: OpenClaw runs startup migrations
3243
+ // under a state lease, a restart mid-migration exits 1, and systemd's start limit can then
3244
+ // abandon the unit. This is the same trap the docker path avoids by waiting for the container
3245
+ // before touching it (see startZaloConnectLogin) — wait for /health first.
3246
+ await waitForNativeGatewayHealthy(projectDir, 180000);
3247
+ // The bot that was just created/edited may have added the Zalo channel or the context engine to
3248
+ // openclaw.json; put those plugins on disk now so this one reload loads them too.
3249
+ await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin ensure skipped: ${e.message}`));
2919
3250
  sendLog('[native] Reloading gateway to pick up openclaw.json changes...');
2920
3251
  await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart failed: ${e.message}`));
2921
3252
  probeCacheClear();
@@ -3277,13 +3608,58 @@ function whichSync(name) {
3277
3608
  try {
3278
3609
  const finder = process.platform === 'win32' ? 'where' : 'which';
3279
3610
  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 || '';
3611
+ const hits = String(out).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
3612
+ if (process.platform !== 'win32') return hits[0] || '';
3613
+ // `where claude` lists the extensionless npm shim FIRST — a shell script Windows cannot spawn
3614
+ // ("spawn ...\\npm\\claude ENOENT"), which is how an allow-listed CLI ended up unusable for the
3615
+ // bot. Prefer something Windows can actually execute.
3616
+ const rank = (f) => {
3617
+ const ext = extname(f).toLowerCase();
3618
+ const order = ['.exe', '.cmd', '.bat', '.com', '.ps1'];
3619
+ const idx = order.indexOf(ext);
3620
+ return idx === -1 ? order.length : idx;
3621
+ };
3622
+ return [...hits].sort((a, b) => rank(a) - rank(b))[0] || '';
3282
3623
  } catch (_) {
3283
3624
  return '';
3284
3625
  }
3285
3626
  }
3286
3627
 
3628
+ /**
3629
+ * What to actually spawn for an allow-listed command. Windows needs the indirection:
3630
+ * - the path may be the extensionless npm shim (a shell script) — try the real siblings;
3631
+ * - a `.cmd`/`.bat` shim cannot be spawned without a shell on current Node, so read it and run
3632
+ * what it points at (`…\pkg\bin\x.exe`, or node + a cli.js) directly.
3633
+ * Keeping shell:false matters: the bot supplies the arguments, and a shell would let one of them
3634
+ * become a second command.
3635
+ */
3636
+ function resolveHostExecutable(bin) {
3637
+ if (process.platform !== 'win32') return { file: bin, prefixArgs: [] };
3638
+ let target = bin;
3639
+ if (!extname(target)) {
3640
+ const candidate = ['.exe', '.cmd', '.bat'].map((ext) => target + ext).find((f) => existsSync(f));
3641
+ if (candidate) target = candidate;
3642
+ }
3643
+ const ext = extname(target).toLowerCase();
3644
+ if (ext !== '.cmd' && ext !== '.bat') return { file: target, prefixArgs: [] };
3645
+ try {
3646
+ const shim = readFileSync(target, 'utf8');
3647
+ const dir = dirname(target);
3648
+ const expand = (p) => resolve(dir, p.replace(/%~?dp0%\\?/gi, '').replace(/^\\+/, ''));
3649
+ const exeRef = shim.match(/"([^"\n]*?\.exe)"/i);
3650
+ if (exeRef) {
3651
+ const exe = expand(exeRef[1]);
3652
+ if (existsSync(exe)) return { file: exe, prefixArgs: [] };
3653
+ }
3654
+ const jsRef = shim.match(/"([^"\n]*?\.js)"/i);
3655
+ if (jsRef) {
3656
+ const js = expand(jsRef[1]);
3657
+ if (existsSync(js)) return { file: process.execPath, prefixArgs: [js] };
3658
+ }
3659
+ } catch (_) {}
3660
+ return { file: target, prefixArgs: [] };
3661
+ }
3662
+
3287
3663
  /**
3288
3664
  * CLI tools the bot may RUN (not just open) via /api/host/exec — output is captured and
3289
3665
  * returned. Kept as a name→path allow-list, mirroring detectHostApps: the executable is fixed,
@@ -3348,6 +3724,13 @@ function grantHostCapabilities(cfg) {
3348
3724
  added.push(name);
3349
3725
  }
3350
3726
  }
3727
+ // Desktop actions (/api/host/ui) come with the same grant: screenshot, pointer, keyboard,
3728
+ // clipboard, windows. Built in, so they work on a machine with no Codex and no extra tools —
3729
+ // on Linux they lean on xdotool/scrot, which the endpoint reports if missing.
3730
+ if (cfg.ui !== true) {
3731
+ cfg.ui = true;
3732
+ added.push('desktop actions (screenshot/click/type)');
3733
+ }
3351
3734
  return added;
3352
3735
  }
3353
3736
 
@@ -3619,7 +4002,8 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
3619
4002
  };
3620
4003
  let child;
3621
4004
  try {
3622
- child = spawn(bin, args, { shell: false, windowsHide: true });
4005
+ const target = resolveHostExecutable(bin);
4006
+ child = spawn(target.file, [...target.prefixArgs, ...args], { shell: false, windowsHide: true });
3623
4007
  } catch (e) {
3624
4008
  return finish({ ok: false, error: e.message }, 500);
3625
4009
  }
@@ -3639,6 +4023,262 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
3639
4023
  });
3640
4024
  }
3641
4025
 
4026
+ /**
4027
+ * Desktop actions for the bot: see the screen, move and click, type, read the clipboard, list and
4028
+ * focus windows. The bot runs in a container with no desktop of its own, so the installer — which
4029
+ * already runs on the operator's machine and already opens apps for it — performs them.
4030
+ *
4031
+ * No native modules: the approach follows the dependency-free tools (and Anthropic's own
4032
+ * computer-use reference, which drives xdotool + a screenshot binary):
4033
+ * Windows a version-stamped PowerShell helper (user32 P/Invoke, SendKeys, System.Drawing)
4034
+ * macOS screencapture + osascript/System Events + pbcopy/pbpaste
4035
+ * Linux xdotool + scrot|import|gnome-screenshot|spectacle + xclip|wl-copy
4036
+ * Whatever the OS, the bot sends the same JSON and gets the same shape back, so its instructions
4037
+ * do not fork per platform.
4038
+ *
4039
+ * Windows note: input injection and screen capture need a real desktop session. When the installer
4040
+ * itself was started over SSH there is none, and the capture fails — the error says so instead of
4041
+ * leaking a raw Win32Exception.
4042
+ */
4043
+ const HOST_UI_ACTIONS = new Set([
4044
+ 'screenshot', 'screen_size', 'mouse_move', 'click', 'drag', 'scroll',
4045
+ 'type', 'key', 'clipboard_get', 'clipboard_set', 'windows', 'focus',
4046
+ ]);
4047
+
4048
+ function hostUiScriptPath(projectDir) {
4049
+ return join(projectDir, '.openclaw', 'host-ui.ps1');
4050
+ }
4051
+
4052
+ async function ensureHostUiScript(projectDir) {
4053
+ const path = hostUiScriptPath(projectDir);
4054
+ const stamp = `# OpenClaw host UI helper — version ${HOST_UI_PS1_VERSION}`;
4055
+ try {
4056
+ if (existsSync(path) && (await fsp.readFile(path, 'utf8')).startsWith(stamp)) return path;
4057
+ } catch (_) {}
4058
+ await fsp.mkdir(dirname(path), { recursive: true }).catch(() => {});
4059
+ await fsp.writeFile(path, HOST_UI_PS1, 'utf8');
4060
+ return path;
4061
+ }
4062
+
4063
+ function firstExistingCommand(names) {
4064
+ for (const name of names) {
4065
+ const bin = whichSync(name);
4066
+ if (bin) return { name, bin };
4067
+ }
4068
+ return null;
4069
+ }
4070
+
4071
+ async function hostUiScreenshotTarget(projectDir) {
4072
+ const dir = join(projectDir, '.openclaw', 'media', 'host-ui');
4073
+ await fsp.mkdir(dir, { recursive: true }).catch(() => {});
4074
+ // Keep the folder from growing forever: the bot takes a lot of these.
4075
+ try {
4076
+ const files = (await fsp.readdir(dir)).filter((f) => f.endsWith('.png')).sort();
4077
+ for (const stale of files.slice(0, Math.max(0, files.length - 20))) {
4078
+ await fsp.rm(join(dir, stale), { force: true }).catch(() => {});
4079
+ }
4080
+ } catch (_) {}
4081
+ const name = `shot-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
4082
+ return { hostPath: join(dir, name), containerPath: `/home/node/project/.openclaw/media/host-ui/${name}` };
4083
+ }
4084
+
4085
+ async function runHostUiWindows(projectDir, action, body, shot) {
4086
+ const script = await ensureHostUiScript(projectDir);
4087
+ const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Action', action];
4088
+ const push = (flag, value) => { if (value !== undefined && value !== null && value !== '') args.push(flag, String(value)); };
4089
+ push('-X', body.x);
4090
+ push('-Y', body.y);
4091
+ push('-ToX', body.toX);
4092
+ push('-ToY', body.toY);
4093
+ push('-Amount', body.amount);
4094
+ push('-Text', body.text);
4095
+ push('-Button', body.button);
4096
+ push('-Clicks', body.clicks);
4097
+ push('-Title', body.title);
4098
+ if (shot) push('-Path', shot.hostPath);
4099
+ const r = await runCapture('powershell', args, { shell: false, timeout: 30000 });
4100
+ const parsed = parseJsonText(String(r.stdout || '').trim(), null);
4101
+ if (parsed) return parsed;
4102
+ const err = String(r.stderr || r.stdout || '').trim();
4103
+ if (/Win32Exception|CopyFromScreen|handle is invalid/i.test(err)) {
4104
+ 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.' };
4105
+ }
4106
+ return { ok: false, error: err.split('\n')[0] || `powershell exited ${r.code}` };
4107
+ }
4108
+
4109
+ async function runHostUiMac(action, body, shot) {
4110
+ const osa = (script) => runCapture('osascript', ['-e', script], { shell: false, timeout: 20000 });
4111
+ const point = () => `{${Number(body.x) || 0}, ${Number(body.y) || 0}}`;
4112
+ switch (action) {
4113
+ case 'screenshot': {
4114
+ const r = await runCapture('screencapture', ['-x', shot.hostPath], { shell: false, timeout: 20000 });
4115
+ return r.code === 0 ? { ok: true, path: shot.hostPath } : { ok: false, error: String(r.stderr || 'screencapture failed').trim() };
4116
+ }
4117
+ case 'screen_size': {
4118
+ const r = await osa('tell application "Finder" to get bounds of window of desktop');
4119
+ const nums = String(r.stdout || '').trim().split(/\s*,\s*/).map(Number);
4120
+ return nums.length === 4 ? { ok: true, width: nums[2], height: nums[3] } : { ok: false, error: 'could not read screen bounds' };
4121
+ }
4122
+ case 'mouse_move':
4123
+ case 'click': {
4124
+ // System Events can click at a point; a plain move has no equivalent, so a move is a click
4125
+ // target set-up only. Accessibility permission is required (System Settings → Privacy).
4126
+ const clicks = Math.max(1, Number(body.clicks) || 1);
4127
+ 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 };
4128
+ for (let i = 0; i < clicks; i++) {
4129
+ const r = await osa(`tell application "System Events" to click at ${point()}`);
4130
+ if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() || 'click failed (grant Accessibility permission)' };
4131
+ }
4132
+ return { ok: true, button: 'left', clicks };
4133
+ }
4134
+ case 'type': {
4135
+ const text = String(body.text || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
4136
+ const r = await osa(`tell application "System Events" to keystroke "${text}"`);
4137
+ return r.code === 0 ? { ok: true, typed: String(body.text || '').length } : { ok: false, error: String(r.stderr || '').trim() };
4138
+ }
4139
+ case 'key': {
4140
+ const map = { enter: 'return', esc: 'escape', pageup: 'page up', pagedown: 'page down' };
4141
+ for (const combo of String(body.text || '').split(/\s+/).filter(Boolean)) {
4142
+ const parts = combo.toLowerCase().split('+').map((p) => p.trim()).filter(Boolean);
4143
+ const key = map[parts[parts.length - 1]] || parts[parts.length - 1];
4144
+ 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);
4145
+ const using = mods.length ? ` using {${mods.join(', ')}}` : '';
4146
+ const named = ['return', 'escape', 'tab', 'space', 'delete', 'up', 'down', 'left', 'right', 'home', 'end', 'page up', 'page down'];
4147
+ const script = named.includes(key)
4148
+ ? `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}`
4149
+ : `tell application "System Events" to keystroke "${key}"${using}`;
4150
+ const r = await osa(script);
4151
+ if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() };
4152
+ }
4153
+ return { ok: true, keys: body.text };
4154
+ }
4155
+ case 'scroll': {
4156
+ const amount = Number(body.amount) || 3;
4157
+ const dir = amount < 0 ? 121 : 116; // page down / page up
4158
+ for (let i = 0; i < Math.abs(amount); i++) await osa(`tell application "System Events" to key code ${dir}`);
4159
+ return { ok: true, amount };
4160
+ }
4161
+ case 'clipboard_get': {
4162
+ const r = await runCapture('pbpaste', [], { shell: false, timeout: 10000 });
4163
+ return { ok: true, text: String(r.stdout || '') };
4164
+ }
4165
+ case 'clipboard_set': {
4166
+ const r = await runCapture('sh', ['-c', 'pbcopy'], { shell: false, timeout: 10000, input: String(body.text || '') });
4167
+ return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: 'pbcopy failed' };
4168
+ }
4169
+ case 'windows': {
4170
+ const r = await osa('tell application "System Events" to get name of every process whose background only is false');
4171
+ const list = String(r.stdout || '').trim().split(/\s*,\s*/).filter(Boolean).map((title) => ({ title, process: title }));
4172
+ return { ok: true, windows: list };
4173
+ }
4174
+ case 'focus': {
4175
+ const title = String(body.title || '').replace(/"/g, '');
4176
+ if (!title) return { ok: false, error: 'focus needs a title' };
4177
+ const r = await osa(`tell application "${title}" to activate`);
4178
+ return r.code === 0 ? { ok: true, focused: title } : { ok: false, error: String(r.stderr || '').trim() || `no app named ${title}` };
4179
+ }
4180
+ default:
4181
+ return { ok: false, error: `unsupported on macOS: ${action}` };
4182
+ }
4183
+ }
4184
+
4185
+ async function runHostUiLinux(action, body, shot) {
4186
+ const xdo = whichSync('xdotool');
4187
+ const need = (bin, hint) => ({ ok: false, error: `${hint} needs ${bin}; install it (e.g. apt install ${bin})` });
4188
+ switch (action) {
4189
+ case 'screenshot': {
4190
+ const tool = firstExistingCommand(['gnome-screenshot', 'scrot', 'spectacle', 'import']);
4191
+ if (!tool) return need('scrot', 'screenshot');
4192
+ const argv = tool.name === 'gnome-screenshot' ? ['-f', shot.hostPath]
4193
+ : tool.name === 'spectacle' ? ['-b', '-n', '-o', shot.hostPath]
4194
+ : tool.name === 'import' ? ['-window', 'root', shot.hostPath]
4195
+ : [shot.hostPath];
4196
+ const r = await runCapture(tool.bin, argv, { shell: false, timeout: 20000 });
4197
+ return r.code === 0 ? { ok: true, path: shot.hostPath, tool: tool.name } : { ok: false, error: String(r.stderr || 'capture failed').trim() };
4198
+ }
4199
+ case 'screen_size': {
4200
+ if (!xdo) return need('xdotool', 'screen_size');
4201
+ const r = await runCapture(xdo, ['getdisplaygeometry'], { shell: false, timeout: 10000 });
4202
+ const [w, h] = String(r.stdout || '').trim().split(/\s+/).map(Number);
4203
+ return w && h ? { ok: true, width: w, height: h } : { ok: false, error: 'could not read display geometry' };
4204
+ }
4205
+ case 'mouse_move':
4206
+ case 'click':
4207
+ case 'drag':
4208
+ case 'scroll':
4209
+ case 'type':
4210
+ case 'key':
4211
+ case 'windows':
4212
+ case 'focus': {
4213
+ if (!xdo) return need('xdotool', action);
4214
+ const button = { left: 1, middle: 2, right: 3 }[String(body.button || 'left')] || 1;
4215
+ const argvFor = {
4216
+ mouse_move: ['mousemove', String(body.x ?? 0), String(body.y ?? 0)],
4217
+ click: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'click', '--repeat', String(Math.max(1, Number(body.clicks) || 1)), String(button)],
4218
+ drag: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'mousedown', '1', 'mousemove', String(body.toX ?? 0), String(body.toY ?? 0), 'mouseup', '1'],
4219
+ scroll: ['click', '--repeat', String(Math.max(1, Math.abs(Number(body.amount) || 3))), (Number(body.amount) || 3) < 0 ? '5' : '4'],
4220
+ type: ['type', '--delay', '12', '--', String(body.text || '')],
4221
+ key: ['key', ...String(body.text || '').split(/\s+/).filter(Boolean)],
4222
+ windows: ['search', '--onlyvisible', '--name', '.'],
4223
+ focus: ['search', '--onlyvisible', '--name', String(body.title || ''), 'windowactivate'],
4224
+ }[action];
4225
+ const r = await runCapture(xdo, argvFor, { shell: false, timeout: 20000 });
4226
+ if (action === 'windows') {
4227
+ const ids = String(r.stdout || '').trim().split(/\s+/).filter(Boolean).slice(0, 40);
4228
+ const titles = [];
4229
+ for (const id of ids) {
4230
+ const t = await runCapture(xdo, ['getwindowname', id], { shell: false, timeout: 5000 });
4231
+ const title = String(t.stdout || '').trim();
4232
+ if (title) titles.push({ title, id });
4233
+ }
4234
+ return { ok: true, windows: titles };
4235
+ }
4236
+ return r.code === 0 ? { ok: true, action } : { ok: false, error: String(r.stderr || '').trim() || `xdotool exited ${r.code}` };
4237
+ }
4238
+ case 'clipboard_get': {
4239
+ const tool = firstExistingCommand(['wl-paste', 'xclip', 'xsel']);
4240
+ if (!tool) return need('xclip', 'clipboard_get');
4241
+ const argv = tool.name === 'xclip' ? ['-o', '-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-o'] : [];
4242
+ const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000 });
4243
+ return { ok: true, text: String(r.stdout || '') };
4244
+ }
4245
+ case 'clipboard_set': {
4246
+ const tool = firstExistingCommand(['wl-copy', 'xclip', 'xsel']);
4247
+ if (!tool) return need('xclip', 'clipboard_set');
4248
+ const argv = tool.name === 'xclip' ? ['-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-i'] : [];
4249
+ const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000, input: String(body.text || '') });
4250
+ return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: `${tool.name} failed` };
4251
+ }
4252
+ default:
4253
+ return { ok: false, error: `unsupported on Linux: ${action}` };
4254
+ }
4255
+ }
4256
+
4257
+ async function runHostUi(projectDir, body = {}) {
4258
+ const action = String(body.action || '').trim();
4259
+ if (!HOST_UI_ACTIONS.has(action)) {
4260
+ return { status: 400, payload: { ok: false, error: `unknown action: ${action || '(none)'}`, actions: [...HOST_UI_ACTIONS] } };
4261
+ }
4262
+ const shot = action === 'screenshot' ? await hostUiScreenshotTarget(projectDir) : null;
4263
+ let result;
4264
+ try {
4265
+ if (process.platform === 'win32') result = await runHostUiWindows(projectDir, action, body, shot);
4266
+ else if (process.platform === 'darwin') result = await runHostUiMac(action, body, shot);
4267
+ else result = await runHostUiLinux(action, body, shot);
4268
+ } catch (err) {
4269
+ result = { ok: false, error: err.message };
4270
+ }
4271
+ if (shot && result?.ok) {
4272
+ // The project folder is bind-mounted into the container, so hand back the path the bot can
4273
+ // actually open — otherwise it gets a Windows path it cannot read and reports failure.
4274
+ result.path = shot.hostPath;
4275
+ result.containerPath = shot.containerPath;
4276
+ result.bytes = existsSync(shot.hostPath) ? (await fsp.stat(shot.hostPath)).size : 0;
4277
+ }
4278
+ sendLog(`[host-control] UI "${action}" → ${result?.ok ? 'ok' : `lỗi: ${result?.error || 'unknown'}`}`);
4279
+ return { status: result?.ok ? 200 : 500, payload: result };
4280
+ }
4281
+
3642
4282
  async function handleHostControl(req, res, projectDir) {
3643
4283
  const cfg = await readHostControlConfig(projectDir);
3644
4284
  const url = new URL(req.url, 'http://localhost');
@@ -3656,6 +4296,16 @@ async function handleHostControl(req, res, projectDir) {
3656
4296
  if (url.pathname === '/api/host/apps' && req.method === 'GET') {
3657
4297
  return json(res, { ok: true, apps: Object.keys(cfg.apps || {}), commands: Object.keys(cfg.commands || {}), platform: process.platform });
3658
4298
  }
4299
+ if (url.pathname === '/api/host/ui' && req.method === 'POST') {
4300
+ // Part of PC control, but its own switch: seeing the screen and moving the pointer is a bigger
4301
+ // step than opening an app, so it only answers once the operator has granted capabilities.
4302
+ if (cfg.ui !== true) {
4303
+ 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);
4304
+ }
4305
+ const body = await readJson(req).catch(() => ({}));
4306
+ const { status, payload } = await runHostUi(projectDir, body || {});
4307
+ return json(res, payload, status);
4308
+ }
3659
4309
  if (url.pathname === '/api/host/exec' && req.method === 'POST') {
3660
4310
  const body = await readJson(req).catch(() => ({}));
3661
4311
  const name = String(body.command || '').trim().toLowerCase();
@@ -3723,6 +4373,39 @@ async function writeHostControlAccess(projectDir, cfg) {
3723
4373
  '',
3724
4374
  `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
4375
  ] : [];
4376
+ // Desktop actions: one endpoint, same JSON on every OS, so the bot does not need per-platform
4377
+ // instructions. Screenshots land in the project folder, which the container already sees.
4378
+ const uiBlock = cfg.ui === true ? [
4379
+ '',
4380
+ '### Thao tác trên màn hình chủ',
4381
+ '',
4382
+ '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** —',
4383
+ 'đừng đoán vị trí. Toạ độ tính bằng pixel màn hình, gốc ở góc trên-trái.',
4384
+ '',
4385
+ '```sh',
4386
+ `curl -s -X POST ${base}/api/host/ui -H "x-openclaw-token: ${cfg.token}" \\`,
4387
+ ' -H "content-type: application/json" -d \'{"action":"screenshot"}\'',
4388
+ '```',
4389
+ '',
4390
+ 'Trả về `containerPath` — **đọc/gửi ảnh bằng đường dẫn đó** (nằm trong project nên bạn thấy được),',
4391
+ 'kèm `width`/`height` để biết màn hình bao lớn.',
4392
+ '',
4393
+ 'Các action khác (cùng dạng `{"action":...}`):',
4394
+ '',
4395
+ '- `screen_size` — kích thước màn hình',
4396
+ '- `mouse_move` + `x`,`y` — di chuột',
4397
+ '- `click` + `x`,`y`, tuỳ chọn `button` (`left`/`right`/`middle`) và `clicks` (2 = double-click)',
4398
+ '- `drag` + `x`,`y`,`toX`,`toY` — kéo thả',
4399
+ '- `scroll` + `amount` (âm = xuống), tuỳ chọn `x`,`y`',
4400
+ '- `type` + `text` — gõ chữ vào cửa sổ đang focus',
4401
+ '- `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',
4402
+ '- `clipboard_get` / `clipboard_set` + `text` — đọc/ghi clipboard',
4403
+ '- `windows` — liệt kê cửa sổ đang mở; `focus` + `title` — đưa cửa sổ lên trước',
4404
+ '',
4405
+ 'Nếu trả về lỗi "no desktop session available" thì installer đang chạy ngoài phiên desktop —',
4406
+ 'nói chủ mở lại installer trong máy, đừng thử cách khác.',
4407
+ 'Trên Linux, thiếu `xdotool`/`scrot` thì endpoint nói rõ cần cài gì — báo lại cho chủ.',
4408
+ ] : [];
3726
4409
  // Screen capture / recording — only advertised when the operator granted the matching tool, so
3727
4410
  // the bot never tries a binary that is not on this machine's allow-list.
3728
4411
  // Windows has no capture binary to allow-list (PowerShell does it inline), so the section shows
@@ -3829,6 +4512,7 @@ async function writeHostControlAccess(projectDir, cfg) {
3829
4512
  '',
3830
4513
  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
4514
  ...execBlock,
4515
+ ...uiBlock,
3832
4516
  // Docker only: a screenshot taken on the host lands on the HOST filesystem, which this
3833
4517
  // container cannot read — say so instead of letting the bot hunt for a missing file.
3834
4518
  ...(hasCapture ? [
@@ -4156,13 +4840,18 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
4156
4840
  state.os = osChoice;
4157
4841
  state.startedAt = new Date().toISOString();
4158
4842
  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.
4843
+ // Native binds the host directly, so it needs ports nothing else holds but only when something
4844
+ // actually holds them. Ask the host rather than assuming: a fresh machine keeps openclaw's and
4845
+ // 9router's real defaults, and a machine that already runs a docker project (or an SSH tunnel to
4846
+ // a remote bot) steps to the next free pair instead.
4161
4847
  if (mode === 'native') {
4162
- if (gatewayPort === 18789) gatewayPort = NATIVE_DEFAULT_GATEWAY_PORT;
4163
- if (routerPort === 20128) routerPort = NATIVE_DEFAULT_ROUTER_PORT;
4848
+ gatewayPort = await findFreeHostPort(gatewayPort, { reserveNext: true });
4849
+ routerPort = await findFreeHostPort(routerPort);
4164
4850
  state.gatewayPort = gatewayPort;
4165
4851
  state.routerPort = routerPort;
4852
+ state.gatewayUrl = `http://127.0.0.1:${gatewayPort}`;
4853
+ state.routerUrl = `http://127.0.0.1:${routerPort}`;
4854
+ sendLog(`[native] ports: gateway ${gatewayPort}, 9router ${routerPort}`);
4166
4855
  }
4167
4856
  sendLog('OpenClaw local installer started');
4168
4857
  sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
@@ -6084,4 +6773,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
6084
6773
  ]).catch(() => {});
6085
6774
  }
6086
6775
 
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 };
6776
+ export { patchBrowserAutomationHostPreference, debugChromeProfileDir, defaultChromeProfileDir, createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations, runHostCommand, detectHostCommands, detectHostCapabilityCommands, grantHostCapabilities, detectCodexApp, detectCodexMarketplace, resolveCodexCli, openPrivacyPane, projectDeployMode, isNativeProject, nativeServiceLabel, nativeEnv, ocArgv, migrateNativePaths, discoverNativeProjectRoots, detectOs, stripCliWarnings, migrationLeaseDeadline, ensureNativePlugins, findFreeHostPort, syncNativeServiceEnv, adoptStrayNativeHome };