create-openclaw-bot 5.16.9 → 5.17.1

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.
@@ -17,6 +17,7 @@ const { buildWorkspaceFileMap, buildCronjobSkillMd, buildInfographicGeneratorSki
17
17
  const { buildOpenclawJson, buildEnvFileContent, buildExecApprovalsJson, buildZaloConnectChannelConfig } = loadSharedModule('../setup/shared/bot-config-gen.js', '__openclawBotConfig');
18
18
  const { buildDockerArtifacts, contextDefaultsScript } = loadSharedModule('../setup/shared/docker-gen.js', '__openclawDockerGen');
19
19
  const { HOST_UI_PS1, HOST_UI_PS1_VERSION } = loadSharedModule('../setup/shared/host-ui-ps1.js', '__openclawHostUiPs1');
20
+ const { buildWindowsLaunchers, WINDOWS_DESKTOP_LAUNCHERS } = loadSharedModule('../setup/shared/windows-launcher-gen.js', '__openclawWindowsLaunchers');
20
21
  const { OPENCLAW_NPM_SPEC, NINE_ROUTER_NPM_SPEC, ZALO_CHANNEL_ID, ZALO_PLUGIN_ID, ZALO_CONNECT_VERSION, ZALO_CONNECT_PLUGIN_SPEC, build9RouterProviderConfig, get9RouterBaseUrl } = loadSharedModule('../setup/shared/common-gen.js', '__openclawCommon');
21
22
  const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
22
23
 
@@ -726,6 +727,19 @@ function extraBinDirs() {
726
727
  return dirs;
727
728
  }
728
729
 
730
+ /**
731
+ * Windows refuses to spawn a .cmd/.bat shim without a shell.
732
+ *
733
+ * Node has thrown a bare `spawn EINVAL` for that since the 2024 argument-injection fix, with no
734
+ * hint about which command or why. Every `openclaw` call here goes through `openclaw.cmd`, and
735
+ * ocCapture asks for `shell: false`, so on Windows those calls died on arrival: measured on a
736
+ * customer machine, the node host started and connected fine and then `nodes approve` failed with
737
+ * nothing but "spawn EINVAL" in the log. Force the shell for these, whatever the caller asked.
738
+ */
739
+ function needsWindowsShell(bin) {
740
+ return process.platform === 'win32' && /\.(cmd|bat)"?$/i.test(String(bin || ''));
741
+ }
742
+
729
743
  function resolveBinPath(cmd) {
730
744
  if (!cmd || cmd.includes('/') || cmd.includes('\\')) return cmd;
731
745
  const names = process.platform === 'win32' ? [`${cmd}.cmd`, `${cmd}.exe`, cmd] : [cmd];
@@ -1021,8 +1035,8 @@ function runCapture(cmd, args, opts = {}) {
1021
1035
  return new Promise((resolve) => {
1022
1036
  let stdout = '';
1023
1037
  let stderr = '';
1024
- const shell = opts.shell ?? process.platform === 'win32';
1025
1038
  const rawBin = resolveBinPath(cmd);
1039
+ const shell = needsWindowsShell(rawBin) || (opts.shell ?? process.platform === 'win32');
1026
1040
  const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
1027
1041
  const child = spawn(bin, args, {
1028
1042
  cwd: opts.cwd,
@@ -1213,7 +1227,12 @@ async function detectRuntime(projectDir) {
1213
1227
  routerPort,
1214
1228
  gatewayUrl: `http://127.0.0.1:${gatewayPort}`,
1215
1229
  routerUrl: `http://127.0.0.1:${routerPort}`,
1216
- mode: existsSync(join(projectDir || '', 'docker', 'openclaw', 'docker-compose.yml')) ? 'docker' : 'native',
1230
+ // Ask projectDeployMode, don't re-derive from the compose file: a project migrated to native
1231
+ // KEEPS its docker/ folder on purpose (that is the way back), so "compose file exists" stops
1232
+ // meaning "runs on docker" the moment migration lands. Getting this wrong made the dashboard
1233
+ // report OpenClaw and 9Router OFFLINE on a machine whose native gateway was answering 200 —
1234
+ // it was probing the stopped containers. Seen on win_kha, 10/09/2026.
1235
+ mode: projectDeployMode(projectDir),
1217
1236
  cliGatewayStatus,
1218
1237
  syncSource,
1219
1238
  };
@@ -1305,7 +1324,16 @@ async function migrateNativePaths(projectDir) {
1305
1324
  const abs = join(wsRoot, base);
1306
1325
  if (obj.workspace !== abs) { obj.workspace = abs; changed = true; }
1307
1326
  };
1327
+ // Walk BOTH shapes. This reads the file with a raw JSON.parse (no ensureConfigShape), so on an
1328
+ // openclaw >=2026.8 config - which keys agents by `agents.entries`, not `agents.list` - `.list`
1329
+ // is simply undefined and the loop used to run over an empty array and fix nothing. A bot added
1330
+ // to such a project kept whatever workspace path it was written with; when that was a container
1331
+ // path the gateway refused to start at all and every bot in the project went down with it.
1308
1332
  for (const a of (cfg.agents?.list || [])) fix(a);
1333
+ const entries = cfg.agents?.entries;
1334
+ if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
1335
+ for (const a of Object.values(entries)) fix(a);
1336
+ }
1309
1337
  fix(cfg.agents?.defaults);
1310
1338
  if (changed) {
1311
1339
  await fsp.copyFile(cfgPath, `${cfgPath}.bak`).catch(() => {});
@@ -1735,6 +1763,52 @@ async function appendEnvValue(projectDir, key, value) {
1735
1763
  await fsp.writeFile(envPath, env, 'utf8');
1736
1764
  }
1737
1765
 
1766
+ /**
1767
+ * Ask openclaw itself whether the config we just wrote is schema-valid, and roll back if not.
1768
+ *
1769
+ * validateOpenclawConfig() below only checks the shapes THIS file cares about. openclaw's own
1770
+ * schema is strict and rejects unknown keys, and it does so at BOOT: one bad key anywhere means
1771
+ * `Gateway failed to start: Invalid config ...` and every bot in the project goes dark at once.
1772
+ * The operator sees bots "not logged in" and reasonably concludes their sessions are gone, when
1773
+ * nothing is wrong with the sessions at all.
1774
+ *
1775
+ * So: write, ask openclaw, and put the backup back the moment it complains. Restoring is always
1776
+ * better than leaving a config in place that we already know will not boot.
1777
+ * Returns null when the config is fine (or when we could not run the check), a message otherwise.
1778
+ */
1779
+ async function verifyConfigOrRollback(projectDir) {
1780
+ if (!isNativeProject(projectDir)) return null; // Docker is retired; only check what we run.
1781
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
1782
+ const backup = `${cfgPath}.bak`;
1783
+ const r = await ocCapture(projectDir, ['config', 'validate'], { timeout: 30000 }).catch(() => null);
1784
+ if (!r) return null; // Could not run the CLI at all - do not guess.
1785
+ const out = `${r.stdout || ''}${r.stderr || ''}`;
1786
+ if (r.code === 0 || /Config valid/i.test(out)) return null;
1787
+ // Fail SAFE: roll back only when openclaw actually says the config is bad. A non-zero exit can
1788
+ // also mean it looked in the wrong place ("Config file not found") or that the CLI itself broke,
1789
+ // and restoring a backup over a perfectly good config would be worse than the bug this guards.
1790
+ // Wording measured on 2026.9.2: "OpenClaw config is invalid: ..." followed by "× openclaw.json:38
1791
+ // — agents.entries.<id>: Unrecognized key: "role"". Match the shapes openclaw actually prints.
1792
+ if (!/config is invalid|invalid config|unrecognized key|invalid input|invalid option|expected/i.test(out)) {
1793
+ sendLog(`[config] Bỏ qua kiểm tra cấu hình (openclaw không kết luận được): ${out.trim().slice(0, 160)}`);
1794
+ return null;
1795
+ }
1796
+ // Keep the rejected file next to the good one: it is the only evidence of what went wrong.
1797
+ const rejected = `${cfgPath}.rejected-${Date.now()}`;
1798
+ await fsp.copyFile(cfgPath, rejected).catch(() => {});
1799
+ let restored = false;
1800
+ if (existsSync(backup)) {
1801
+ await fsp.copyFile(backup, cfgPath).catch(() => {});
1802
+ restored = true;
1803
+ }
1804
+ const detail = out.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 4).join(' · ');
1805
+ sendLog(`[config] openclaw từ chối cấu hình vừa ghi: ${detail}`);
1806
+ sendLog(`[config] Bản bị từ chối giữ ở ${rejected}${restored ? '; đã khôi phục bản trước đó.' : '.'}`);
1807
+ return restored
1808
+ ? `Cấu hình vừa ghi bị openclaw từ chối nên đã khôi phục bản cũ (bot vẫn chạy bình thường). Lý do: ${detail}`
1809
+ : `Cấu hình vừa ghi bị openclaw từ chối và không có bản sao lưu để khôi phục. Lý do: ${detail}`;
1810
+ }
1811
+
1738
1812
  function validateOpenclawConfig(cfg) {
1739
1813
  if (!Array.isArray(cfg.agents?.list)) throw httpError(500, 'openclaw.json missing agents.list');
1740
1814
  for (const a of cfg.agents.list) {
@@ -2127,6 +2201,9 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
2127
2201
  // so the generator's ".openclaw/workspace-x" would double. Rewrite to an absolute path now so
2128
2202
  // the bot reads its persona on the very first turn (not only after the next runtime sync).
2129
2203
  if (isNativeProject(projectDir)) await migrateNativePaths(projectDir).catch(() => {});
2204
+ // Check AFTER the path normalisation above, so we validate exactly what the gateway will read.
2205
+ const rejected = await verifyConfigOrRollback(projectDir).catch(() => null);
2206
+ if (rejected) throw httpError(500, rejected);
2130
2207
  await syncExecApprovals(projectDir, cfg);
2131
2208
 
2132
2209
  const hasScheduler = !!(cfg.tools?.alsoAllow || []).includes('group:automation');
@@ -2238,10 +2315,21 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
2238
2315
  }
2239
2316
 
2240
2317
  agent.name = botName;
2241
- agent.role = botDesc;
2318
+ // NEVER put `role` (or any free-form field) on the agent entry. openclaw's schema is strict and
2319
+ // rejects unknown keys outright: `agents.entries.<id>: Unrecognized key: "role"` makes the
2320
+ // gateway refuse to boot, which takes down EVERY bot in the project, not just the edited one.
2321
+ // Measured on a customer host: editing one bot silently killed all of them, and the dashboard
2322
+ // then showed "chưa đăng nhập" for sessions that were perfectly intact.
2323
+ // The description already has a home - bot-meta.json, written a few lines below - and that is
2324
+ // what the UI reads back (readBotIdentity prefers meta.role). ensureConfigShape deletes this key
2325
+ // on load precisely because it does not belong here; re-adding it on save just undid that.
2242
2326
  validateOpenclawConfig(cfg);
2243
2327
  if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
2244
2328
  await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
2329
+ if (isNativeProject(projectDir)) await migrateNativePaths(projectDir).catch(() => {});
2330
+ // Editing one bot must never be able to take the whole project down.
2331
+ const rejectedEdit = await verifyConfigOrRollback(projectDir).catch(() => null);
2332
+ if (rejectedEdit) throw httpError(500, rejectedEdit);
2245
2333
  await syncExecApprovals(projectDir, cfg);
2246
2334
 
2247
2335
  // Synchronize the token to .env files for the primary bot to ensure Docker picks it up
@@ -2770,15 +2858,34 @@ async function computeZaloHealth(projectDir) {
2770
2858
  textStatus = `${r.stdout || ''}\n${r.stderr || ''}`;
2771
2859
  } catch {}
2772
2860
  }
2773
- // zalo-connect writes credentials under the project's .openclaw (OPENCLAW_HOME); fall
2774
- // back to the real home dir in case the plugin used os.homedir() instead.
2861
+ // Where the Zalo session file lives depends on the plugin version, and getting this wrong
2862
+ // makes a perfectly logged-in bot show as "chưa đăng nhập":
2863
+ // <=3.1.4 <home>/.openclaw/zalo-connect-credentials[-<account>].json
2864
+ // >=3.1.5 <state>/credentials/zalo-connect/credentials[-<account>].json
2865
+ // 3.1.5 MOVES the old file on first run and leaves a *.migrated stub behind, so a scan that
2866
+ // only knows the old shape finds nothing at all. Look in the new layout first, then fall
2867
+ // back, and normalise the new names to the old ones so the caller's lookup keeps working.
2868
+ // Measured on win_kha after the zalo-connect upgrade, 10/09/2026.
2775
2869
  const credRe = /^zalo-connect-credentials(?:-[^.]+)?\.json$/i;
2776
- for (const dir of [join(projectDir, '.openclaw'), join(os.homedir(), '.openclaw')]) {
2870
+ const newCredRe = /^credentials(?:-[^.]+)?\.json$/i;
2871
+ const toLegacyName = (n) => n.replace(/^credentials/, 'zalo-connect-credentials');
2872
+ for (const dir of [
2873
+ join(projectDir, '.openclaw', 'credentials', 'zalo-connect'),
2874
+ join(os.homedir(), '.openclaw', 'credentials', 'zalo-connect'),
2875
+ ]) {
2777
2876
  try {
2778
- const names = (await fsp.readdir(dir)).filter((n) => credRe.test(n));
2779
- if (names.length) { credentialNames = names; break; }
2877
+ const names = (await fsp.readdir(dir)).filter((n) => newCredRe.test(n));
2878
+ if (names.length) { credentialNames = names.map(toLegacyName); break; }
2780
2879
  } catch {}
2781
2880
  }
2881
+ if (!credentialNames || !credentialNames.length) {
2882
+ for (const dir of [join(projectDir, '.openclaw'), join(os.homedir(), '.openclaw')]) {
2883
+ try {
2884
+ const names = (await fsp.readdir(dir)).filter((n) => credRe.test(n));
2885
+ if (names.length) { credentialNames = names; break; }
2886
+ } catch {}
2887
+ }
2888
+ }
2782
2889
  }
2783
2890
  } else {
2784
2891
  try {
@@ -2914,9 +3021,31 @@ function nativeEnv(projectDir, extra = {}) {
2914
3021
  };
2915
3022
  }
2916
3023
 
3024
+ /**
3025
+ * Path to openclaw's own entry script, so we can run it with THIS node instead of its .cmd shim.
3026
+ *
3027
+ * The shim is a liability on Windows twice over: Node refuses to spawn a .cmd without a shell
3028
+ * (`spawn EINVAL`), and the shim itself then re-resolves `node` from PATH - which fails with
3029
+ * `'"node"' is not recognized as an internal or external command` whenever the installer's own
3030
+ * environment does not carry node's directory. Both were measured on a customer machine, and both
3031
+ * surfaced as "computer use does not work" rather than as anything to do with PATH. Calling the
3032
+ * script directly with process.execPath sidesteps the shim entirely; the launchers already do it.
3033
+ */
3034
+ function openclawEntryScript() {
3035
+ for (const dir of globalNodeModulesDirs()) {
3036
+ const entry = join(dir, 'openclaw', 'dist', 'index.js');
3037
+ try { if (existsSync(entry)) return entry; } catch {}
3038
+ }
3039
+ return '';
3040
+ }
3041
+
2917
3042
  /** Resolve `openclaw <args>` for whichever runtime this project uses. */
2918
3043
  function ocArgv(projectDir, args) {
2919
3044
  if (isNativeProject(projectDir)) {
3045
+ const entry = openclawEntryScript();
3046
+ // Prefer the script over the shim; fall back to the shim only when the global install is
3047
+ // somewhere we did not expect, so an unusual layout still works as before.
3048
+ if (entry) return { cmd: process.execPath, args: [entry, ...args], opts: { cwd: projectDir, env: nativeEnv(projectDir) } };
2920
3049
  return { cmd: 'openclaw', args, opts: { cwd: projectDir, env: nativeEnv(projectDir) } };
2921
3050
  }
2922
3051
  return { cmd: 'docker', args: ['exec', getBotContainerName(projectDir), 'openclaw', ...args], opts: { cwd: projectDir } };
@@ -3069,6 +3198,50 @@ async function runOpenclawDoctorFixIfNeeded(projectDir) {
3069
3198
  }
3070
3199
  }
3071
3200
 
3201
+ /**
3202
+ * Restart the gateway on Windows the way the double-click launcher starts it.
3203
+ *
3204
+ * Stops the running gateway process, then relaunches `gateway-start.cmd` through `run-hidden.vbs`
3205
+ * - the exact pair "1 - KHOI DONG BOT" uses, so the gateway lands in the operator's own desktop
3206
+ * session with no console window. Returns true only once the port answers again: a restart that
3207
+ * silently left the bot down is the failure this whole function exists to avoid.
3208
+ */
3209
+ async function restartWindowsGateway(projectDir) {
3210
+ const vbs = join(projectDir, 'run-hidden.vbs');
3211
+ const cmd = join(projectDir, 'gateway-start.cmd');
3212
+ // The launchers are rewritten on every start, but a project from an older build may not have
3213
+ // them yet. Write them now rather than failing - they are the supported way in on Windows.
3214
+ if (!existsSync(vbs) || !existsSync(cmd)) {
3215
+ const meta = readNativeMeta(projectDir) || {};
3216
+ await writeWindowsLaunchers(
3217
+ projectDir,
3218
+ meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT,
3219
+ meta.routerPort || state.routerPort || NATIVE_DEFAULT_ROUTER_PORT,
3220
+ ).catch((e) => sendLog(`[native] không tạo được launcher: ${e.message}`));
3221
+ }
3222
+ if (!existsSync(vbs) || !existsSync(cmd)) return false;
3223
+
3224
+ const meta = readNativeMeta(projectDir) || {};
3225
+ const port = meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT;
3226
+ await runCapture('powershell', ['-NoProfile', '-Command',
3227
+ "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -like '*openclaw*gateway --port*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -EA SilentlyContinue }"],
3228
+ { shell: false, timeout: 20000 }).catch(() => {});
3229
+ // Give the socket time to be released, or the new gateway loses the port to its own corpse.
3230
+ for (let i = 0; i < 10 && (await portStatus(port)) === 'online'; i++) {
3231
+ await new Promise((r) => setTimeout(r, 1000));
3232
+ }
3233
+ startDetached('wscript.exe', [vbs, cmd], { cwd: projectDir });
3234
+ for (let i = 0; i < 45; i++) {
3235
+ await new Promise((r) => setTimeout(r, 2000));
3236
+ if ((await portStatus(port)) === 'online') {
3237
+ sendLog(`[native] Bot đã khởi động lại (cổng ${port}).`);
3238
+ return true;
3239
+ }
3240
+ }
3241
+ sendLog(`[native] Bot chưa lên lại sau 90s (cổng ${port}).`);
3242
+ return false;
3243
+ }
3244
+
3072
3245
  async function restartNativeRuntime(projectDir) {
3073
3246
  // Every restart is a chance to repair a project installed before these fixes existed — the
3074
3247
  // calls are no-ops once the service env is complete, stray files are adopted, and the config
@@ -3084,6 +3257,18 @@ async function restartNativeRuntime(projectDir) {
3084
3257
  };
3085
3258
  let res;
3086
3259
  if (process.platform === 'win32') {
3260
+ // `openclaw daemon` cannot touch the gateway on Windows. It manages a Scheduled Task, and it
3261
+ // refuses to create one for our layout at all: "service management skipped: non-default state
3262
+ // dir or config path" (the project keeps its state in <project>\.openclaw, not the account
3263
+ // home). So `daemon stop` + `daemon start` both report success against a service that does not
3264
+ // exist, while the real gateway - started by "1 - KHOI DONG BOT" as a plain hidden process -
3265
+ // keeps running untouched. Measured on a customer machine: `daemon status` said
3266
+ // "Runtime: stopped · Service unit not found" while the port was demonstrably listening, and
3267
+ // the dashboard's Restart button silently did nothing, so a config change never took effect.
3268
+ // Restart it the same way the launcher starts it instead.
3269
+ const restarted = await restartWindowsGateway(projectDir);
3270
+ if (restarted) return;
3271
+ sendLog('[native] Không khởi động lại được bằng launcher, thử qua daemon.');
3087
3272
  res = await stopStart();
3088
3273
  } else {
3089
3274
  res = await ocDaemon(projectDir, 'restart');
@@ -3545,6 +3730,520 @@ async function prepareNativeStateHome(projectDir) {
3545
3730
  return installEnv;
3546
3731
  }
3547
3732
 
3733
+ /**
3734
+ * Turn OpenClaw's real computer-use on or off for a project.
3735
+ *
3736
+ * Three pieces must line up, and a bot with only two of them reports "I don't have permission"
3737
+ * without saying which one is missing — that ambiguity cost a whole debugging session:
3738
+ * 1. `computer` (+ `screen`) allowed in tools.alsoAllow — otherwise the agent never receives
3739
+ * the tool at all, no matter what else is configured;
3740
+ * 2. the `cua-computer` plugin, which is MANDATORY on Windows and ships disabled. It also has
3741
+ * to be on plugins.allow, or enabling is refused with "blocked by allowlist";
3742
+ * 3. a node host process advertising `computer.act` + `screen.snapshot`. The tool is only the
3743
+ * model-facing surface; the node is what actually touches the screen, which is why a
3744
+ * correctly configured tool still does nothing on its own.
3745
+ *
3746
+ * The node host must run inside a real interactive desktop session — started from a service or
3747
+ * an SSH session it either dies with the session or cannot see a desktop at all. Launching it
3748
+ * from here works because the operator pressing the button is sitting at that desktop.
3749
+ */
3750
+ async function setComputerUse(projectDir, enable) {
3751
+ // Docker is retired, and the node host has to touch a real desktop, so this only makes sense
3752
+ // on a native project. Say so plainly instead of half-applying and leaving the operator to
3753
+ // wonder why the bot still refuses.
3754
+ if (!isNativeProject(projectDir)) {
3755
+ return { ok: false, error: 'Chỉ dùng được với bot chạy native (không phải Docker).' };
3756
+ }
3757
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
3758
+ if (!existsSync(cfgPath)) return { ok: false, error: 'openclaw.json not found' };
3759
+ const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
3760
+
3761
+ cfg.tools = (cfg.tools && typeof cfg.tools === 'object') ? cfg.tools : {};
3762
+ const allow = new Set(Array.isArray(cfg.tools.alsoAllow) ? cfg.tools.alsoAllow : []);
3763
+ cfg.plugins = (cfg.plugins && typeof cfg.plugins === 'object') ? cfg.plugins : {};
3764
+ cfg.plugins.allow = Array.isArray(cfg.plugins.allow) ? cfg.plugins.allow : [];
3765
+ cfg.plugins.entries = (cfg.plugins.entries && typeof cfg.plugins.entries === 'object') ? cfg.plugins.entries : {};
3766
+
3767
+ if (enable) {
3768
+ allow.add('computer');
3769
+ allow.add('screen');
3770
+ // Without this, `plugins enable` is refused outright with "blocked by allowlist".
3771
+ if (!cfg.plugins.allow.includes('cua-computer')) cfg.plugins.allow.push('cua-computer');
3772
+ cfg.plugins.entries['cua-computer'] = { ...(cfg.plugins.entries['cua-computer'] || {}), enabled: true };
3773
+ // A fourth gate nobody sees until they hit it: the gateway keeps a per-platform allowlist of
3774
+ // node commands. `computer.act` counts as a dangerous default and `screen.snapshot` as a
3775
+ // desktop-host command, so BOTH are stripped from the defaults and the invoke is refused with
3776
+ // `"screen.snapshot" is not in the allowlist for platform "windows"` — even though the plugin
3777
+ // is enabled and the node is paired and approved. Only gateway.nodes.commands.allow puts them
3778
+ // back (it is applied after the dangerous-command filter).
3779
+ cfg.gateway = (cfg.gateway && typeof cfg.gateway === 'object') ? cfg.gateway : {};
3780
+ cfg.gateway.nodes = (cfg.gateway.nodes && typeof cfg.gateway.nodes === 'object') ? cfg.gateway.nodes : {};
3781
+ cfg.gateway.nodes.commands = (cfg.gateway.nodes.commands && typeof cfg.gateway.nodes.commands === 'object')
3782
+ ? cfg.gateway.nodes.commands : {};
3783
+ const nodeAllow = new Set(Array.isArray(cfg.gateway.nodes.commands.allow) ? cfg.gateway.nodes.commands.allow : []);
3784
+ nodeAllow.add('screen.snapshot');
3785
+ nodeAllow.add('computer.act');
3786
+ cfg.gateway.nodes.commands.allow = [...nodeAllow];
3787
+ } else {
3788
+ allow.delete('computer');
3789
+ allow.delete('screen');
3790
+ if (cfg.plugins.entries['cua-computer']) cfg.plugins.entries['cua-computer'].enabled = false;
3791
+ const nodeAllow = cfg.gateway?.nodes?.commands?.allow;
3792
+ if (Array.isArray(nodeAllow)) {
3793
+ cfg.gateway.nodes.commands.allow = nodeAllow.filter((c) => c !== 'screen.snapshot' && c !== 'computer.act');
3794
+ }
3795
+ // Leave the allowlist clean too, so a later re-enable is a deliberate act rather than a
3796
+ // leftover permission nobody remembers granting.
3797
+ cfg.plugins.allow = cfg.plugins.allow.filter((x) => x !== 'cua-computer');
3798
+ }
3799
+ cfg.tools.alsoAllow = [...allow];
3800
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
3801
+
3802
+ if (!enable) {
3803
+ await stopNodeHost().catch(() => {});
3804
+ await restartNativeRuntime(projectDir).catch(() => {});
3805
+ sendLog('[computer-use] Đã tắt: gỡ tool computer/screen, tắt plugin và dừng node điều khiển.');
3806
+ return { ok: true, enabled: false };
3807
+ }
3808
+
3809
+ // The gateway reads plugins and the tool allowlist at boot. openclaw itself says "Restart the
3810
+ // gateway to apply" when a plugin is enabled — skip this and the switch reports success while
3811
+ // the bot still has no computer tool, which is exactly the kind of silent half-success that
3812
+ // sends the owner back to us.
3813
+ sendLog('[computer-use] Đã bật tool computer + plugin cua-computer. Đang khởi động lại bot để nạp...');
3814
+ // Stop the node host FIRST. Restarting the gateway drops its socket, and on some closes the node
3815
+ // gives up with "reconnect paused ... exiting for supervisor restart" - there is no supervisor
3816
+ // here, so it would sit dead while everything else looked fine. Start it fresh afterwards.
3817
+ await stopNodeHost().catch(() => {});
3818
+ await restartNativeRuntime(projectDir).catch((e) => sendLog(`[computer-use] restart: ${e.message}`));
3819
+
3820
+ const meta = readNativeMeta(projectDir) || {};
3821
+ const port = meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT;
3822
+ // The node host cannot connect until the gateway is listening again.
3823
+ let up = false;
3824
+ for (let i = 0; i < 30; i++) {
3825
+ if ((await portStatus(port)) === 'online') { up = true; break; }
3826
+ await new Promise((r) => setTimeout(r, 2000));
3827
+ }
3828
+ if (!up) return { ok: false, error: `Bot chưa khởi động lại xong (cổng ${port}). Thử lại sau ít phút.` };
3829
+
3830
+ await startNodeHost(projectDir, port);
3831
+ const running = await nodeHostRunning();
3832
+ if (!running) return { ok: false, error: 'Đã bật quyền nhưng node điều khiển chưa chạy. Xem nhật ký để biết vì sao.' };
3833
+
3834
+ // The node registers its capability surface a moment after the socket opens, so the pending
3835
+ // request is not there instantly. Give it a few rounds rather than approving once and hoping.
3836
+ // The node publishes its capability surface a little after the socket opens, and the CUA driver
3837
+ // is imported asynchronously on top of that, so the first look is expected to come up empty.
3838
+ // Measured on a customer machine: connected and approved within seconds, capabilities visible
3839
+ // roughly half a minute later. Checking a couple of times and giving up reports a working setup
3840
+ // as broken, so wait properly.
3841
+ let caps = await nodeHasComputerCaps(projectDir);
3842
+ for (let i = 0; i < 20 && !caps; i++) {
3843
+ await approvePendingNodes(projectDir).catch(() => {});
3844
+ await new Promise((r) => setTimeout(r, 3000));
3845
+ caps = await nodeHasComputerCaps(projectDir);
3846
+ }
3847
+ if (!caps) {
3848
+ // Half-success, reported as such. Mở app và chạy lệnh đi đường `exec` nên vẫn dùng được bình
3849
+ // thường; chỉ phần chụp/điều khiển màn hình là chưa. Trả ok:true để phần đã chạy được không bị
3850
+ // báo thành hỏng — báo hỏng toàn bộ khiến người dùng tưởng mất luôn thứ đang chạy.
3851
+ sendLog(`[computer-use] Node đã chạy nhưng chưa khai báo được khả năng màn hình. ${nodeCapsLastReason}`);
3852
+ return {
3853
+ ok: true,
3854
+ enabled: true,
3855
+ gatewayPort: port,
3856
+ screenControl: false,
3857
+ note: 'Bot mở ứng dụng và chạy lệnh trên máy được. Riêng chụp/điều khiển màn hình thì máy chưa '
3858
+ + 'khai báo được khả năng này, nên tạm thời chưa dùng được.',
3859
+ };
3860
+ }
3861
+ sendLog('[computer-use] Máy đã sẵn sàng: bot chụp màn hình, bấm chuột và gõ phím được.');
3862
+ return { ok: true, enabled: true, gatewayPort: port, screenControl: true };
3863
+ }
3864
+
3865
+ /**
3866
+ * Read the gateway auth token out of the project config.
3867
+ *
3868
+ * The node host authenticates to the gateway over the same WebSocket everything else uses, and
3869
+ * with `gateway.auth.mode: "token"` it is rejected before it can advertise anything:
3870
+ * `unauthorized: gateway token missing (provide gateway auth token)` -> exit code 1.
3871
+ * Started detached, that failure is invisible: the process is simply gone a second later and the
3872
+ * switch looks like it worked. Pass the token explicitly.
3873
+ */
3874
+ function gatewayAuthToken(projectDir) {
3875
+ try {
3876
+ const cfg = JSON.parse(fs.readFileSync(join(projectDir, '.openclaw', 'openclaw.json'), 'utf8'));
3877
+ const t = cfg?.gateway?.auth?.token;
3878
+ return typeof t === 'string' && t ? t : '';
3879
+ } catch { return ''; }
3880
+ }
3881
+
3882
+ /**
3883
+ * Give the node host a state dir of its own, holding nothing but what it needs.
3884
+ *
3885
+ * `openclaw node run` loads the plugins of whatever state dir it is pointed at. Point it at the
3886
+ * bot's and it loads the bot's plugins too - including zalo-mod, which opens its dashboard port.
3887
+ * The gateway already holds that port, so the node host dies on startup with
3888
+ * `listen EADDRINUSE: address already in use 127.0.0.1:18790`, before it ever publishes
3889
+ * `computer.act` / `screen.snapshot`. From the outside that is indistinguishable from "this
3890
+ * machine cannot do computer use": the node appears paired and approved, yet advertises only the
3891
+ * core capabilities. Measured on a customer machine, and it cost most of a day to see.
3892
+ *
3893
+ * A separate dir with only cua-computer enabled has no such plugin to collide with. The node still
3894
+ * reaches the same gateway over loopback with the same token, so nothing else changes.
3895
+ */
3896
+ async function prepareNodeHostHome(projectDir, gatewayPort) {
3897
+ const home = join(projectDir, '.openclaw-node');
3898
+ await fsp.mkdir(home, { recursive: true });
3899
+ const cfg = {
3900
+ gateway: {
3901
+ port: Number(gatewayPort),
3902
+ mode: 'local',
3903
+ bind: 'loopback',
3904
+ ...(gatewayAuthToken(projectDir) ? { auth: { mode: 'token', token: gatewayAuthToken(projectDir) } } : {}),
3905
+ },
3906
+ // Only the driver. Anything else here would be a plugin running twice on one machine.
3907
+ plugins: { allow: ['cua-computer'], entries: { 'cua-computer': { enabled: true } } },
3908
+ };
3909
+ await fsp.writeFile(join(home, 'openclaw.json'), JSON.stringify(cfg, null, 2) + '\n', 'utf8');
3910
+ return home;
3911
+ }
3912
+
3913
+ /** Run `openclaw node run` detached so it outlives this request but stays in this desktop session. */
3914
+ async function startNodeHost(projectDir, gatewayPort) {
3915
+ if (await nodeHostRunning()) { sendLog('[computer-use] Node điều khiển đã chạy sẵn.'); return; }
3916
+ const token = gatewayAuthToken(projectDir);
3917
+ if (!token) sendLog('[computer-use] Không đọc được gateway token — node có thể bị từ chối kết nối.');
3918
+ // `--no-tls`: the gateway here is plain ws:// on loopback. Without it the node tries TLS and
3919
+ // the handshake never completes.
3920
+ const a = ocArgv(projectDir, ['node', 'run', '--host', '127.0.0.1', '--port', String(gatewayPort), '--no-tls']);
3921
+ // Go through the same bin resolution + env merge as run()/runCapture(). Spawning `a.cmd` raw
3922
+ // with only nativeEnv() drops PATH entirely, so on Windows `openclaw` does not even resolve.
3923
+ const rawBin = resolveBinPath(a.cmd);
3924
+ const shell = process.platform === 'win32';
3925
+ const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
3926
+ const nodeHome = await prepareNodeHostHome(projectDir, gatewayPort).catch((e) => {
3927
+ sendLog(`[computer-use] không tạo được state riêng cho node: ${e.message}`);
3928
+ return null;
3929
+ });
3930
+ if (process.platform === 'win32') {
3931
+ // Node refuses to spawn the `openclaw.cmd` shim detached: `spawn EINVAL`, with nothing else
3932
+ // logged. Go through the generated launcher and wscript, the same pair that starts the gateway
3933
+ // here - it is the one shape proven to work on Windows, and it keeps the process in the
3934
+ // operator's desktop session, which the screen driver requires.
3935
+ await writeWindowsLaunchers(projectDir, gatewayPort,
3936
+ (readNativeMeta(projectDir) || {}).routerPort || state.routerPort || NATIVE_DEFAULT_ROUTER_PORT)
3937
+ .catch((e) => sendLog(`[computer-use] không ghi được launcher: ${e.message}`));
3938
+ const vbs = join(projectDir, 'run-hidden.vbs');
3939
+ const cmd = join(projectDir, 'node-host.cmd');
3940
+ if (!existsSync(vbs) || !existsSync(cmd)) {
3941
+ sendLog('[computer-use] thiếu node-host.cmd — không khởi động được node điều khiển.');
3942
+ return;
3943
+ }
3944
+ startDetached('wscript.exe', [vbs, cmd], { cwd: projectDir });
3945
+ } else {
3946
+ const env = binEnv(rawBin, {
3947
+ ...(a.opts.env || {}),
3948
+ // Override the project's state dir: see prepareNodeHostHome for why sharing it kills the node.
3949
+ ...(nodeHome ? { OPENCLAW_HOME: nodeHome, OPENCLAW_STATE_DIR: nodeHome } : {}),
3950
+ ...(token ? { OPENCLAW_GATEWAY_TOKEN: token } : {}),
3951
+ });
3952
+ const child = spawn(bin, a.args, { cwd: a.opts.cwd, shell, env, detached: true, stdio: 'ignore', windowsHide: true });
3953
+ child.on('error', (err) => sendLog(`[computer-use] không chạy được node host: ${err.message}`));
3954
+ child.unref();
3955
+ }
3956
+ // Confirm instead of assuming: a node host that failed to start looks exactly like one that
3957
+ // started, until the bot says it has no permission.
3958
+ for (let i = 0; i < 15; i++) {
3959
+ await new Promise((r) => setTimeout(r, 2000));
3960
+ if (await nodeHostRunning()) { sendLog('[computer-use] Node điều khiển đã kết nối.'); return; }
3961
+ }
3962
+ sendLog('[computer-use] Node điều khiển chưa lên sau 30s — kiểm tra lại bằng `openclaw node status`.');
3963
+ }
3964
+
3965
+ /**
3966
+ * Approve the node's capability surface.
3967
+ *
3968
+ * Connecting is not enough: the gateway parks the node's capability list as a pending pairing
3969
+ * request and the node logs `node capability surface is awaiting operator approval` on a loop.
3970
+ * Until someone approves it the node advertises nothing, so the bot answers "I don't have
3971
+ * permission" even though every config key is right. The operator already consented by pressing
3972
+ * the button, so approve it here instead of making them find a CLI id in a log file.
3973
+ */
3974
+ async function approvePendingNodes(projectDir) {
3975
+ const pending = await ocCapture(projectDir, ['nodes', 'pending'], { timeout: 20000 });
3976
+ const text = `${pending.stdout || ''}\n${pending.stderr || ''}`;
3977
+ const ids = [...new Set((text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi) || []))];
3978
+ if (!ids.length) return 0;
3979
+ let done = 0;
3980
+ for (const id of ids) {
3981
+ const r = await ocCapture(projectDir, ['nodes', 'approve', id], { timeout: 20000 });
3982
+ if (r.code === 0) { done++; sendLog(`[computer-use] Đã duyệt node ${id}.`); }
3983
+ else sendLog(`[computer-use] Duyệt node ${id} không được: ${String(r.stderr || r.stdout || '').trim().slice(0, 200)}`);
3984
+ }
3985
+ return done;
3986
+ }
3987
+
3988
+ /**
3989
+ * Does the connected node actually offer screen control?
3990
+ *
3991
+ * A node can be connected and approved and still advertise only the core caps (file, system,
3992
+ * browser...) when the CUA driver refuses to load - most often because the host was started
3993
+ * outside an interactive desktop session. Checking the caps is the only way to tell a working
3994
+ * setup from one that will fail on the first screenshot the owner asks for.
3995
+ */
3996
+ let nodeCapsLastReason = '';
3997
+
3998
+ async function nodeHasComputerCaps(projectDir) {
3999
+ // Read JSON, never the table. `nodes status` renders a fixed-width table and wraps the Caps
4000
+ // column, so a node that genuinely advertises "computer" and "screen" prints them as "comp" and
4001
+ // "scre" across two rows - a text match on the table reports a working setup as broken, which is
4002
+ // exactly what it did on a customer machine.
4003
+ const r = await ocCapture(projectDir, ['nodes', 'status', '--json'], { timeout: 30000 });
4004
+ const text = `${r.stdout || ''}`;
4005
+ const start = text.indexOf('{');
4006
+ if (start === -1) {
4007
+ // Say what actually came back. "Not ready yet" and "the command failed" look identical from
4008
+ // the outside, and guessing between them is what turns a five-minute fix into a long day.
4009
+ nodeCapsLastReason = `không đọc được nodes status (exit ${r.code}): ${String(r.stderr || r.stdout || '').trim().slice(0, 160)}`;
4010
+ return false;
4011
+ }
4012
+ let parsed;
4013
+ try { parsed = JSON.parse(text.slice(start)); } catch (e) {
4014
+ nodeCapsLastReason = `nodes status trả về dữ liệu không đọc được: ${e.message}`;
4015
+ return false;
4016
+ }
4017
+ const nodes = Array.isArray(parsed?.nodes) ? parsed.nodes : [];
4018
+ const ok = nodes.some((n) => {
4019
+ const commands = Array.isArray(n?.commands) ? n.commands : [];
4020
+ // Both halves or neither: the gateway only exposes Computer Use when the pair is effective.
4021
+ return commands.includes('computer.act') && commands.includes('screen.snapshot');
4022
+ });
4023
+ if (!ok) {
4024
+ nodeCapsLastReason = nodes.length
4025
+ ? `node đã nối nhưng mới khai báo: ${(nodes[0].commands || []).join(', ') || '(chưa có lệnh nào)'}`
4026
+ : 'gateway chưa thấy node nào';
4027
+ }
4028
+ return ok;
4029
+ }
4030
+
4031
+ async function nodeHostRunning() {
4032
+ if (process.platform === 'win32') {
4033
+ const r = await runCapture('powershell', ['-NoProfile', '-Command',
4034
+ "(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -like '*node*run*--host*' } | Measure-Object).Count"], { shell: false, timeout: 8000 });
4035
+ return Number(String(r.stdout || '0').trim()) > 0;
4036
+ }
4037
+ const r = await runCapture('sh', ['-c', "ps ax | grep -c '[n]ode run --host'"], { shell: false, timeout: 8000 });
4038
+ return Number(String(r.stdout || '0').trim()) > 0;
4039
+ }
4040
+
4041
+ async function stopNodeHost() {
4042
+ if (process.platform === 'win32') {
4043
+ await runCapture('powershell', ['-NoProfile', '-Command',
4044
+ "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | Where-Object { $_.CommandLine -like '*node*run*--host*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -EA SilentlyContinue }"], { shell: false, timeout: 10000 });
4045
+ return;
4046
+ }
4047
+ await runCapture('sh', ['-c', "pkill -f 'node run --host' || true"], { shell: false, timeout: 10000 });
4048
+ }
4049
+
4050
+ /**
4051
+ * Write the double-click launchers into the project and copy the operator-facing ones to the
4052
+ * Desktop. Safe to re-run: it overwrites, so a fixed launcher reaches machines on the next
4053
+ * install or update instead of leaving the customer pressing a stale copy.
4054
+ */
4055
+ async function writeWindowsLaunchers(projectDir, gatewayPort, routerPort) {
4056
+ // node-host.cmd needs the gateway token baked in: the node authenticates with it, and without
4057
+ // one it exits within a second with `unauthorized: gateway token missing`.
4058
+ const files = buildWindowsLaunchers({
4059
+ projectDir, gatewayPort, routerPort,
4060
+ setupPort: activeUiPort || 51789,
4061
+ gatewayToken: gatewayAuthToken(projectDir),
4062
+ });
4063
+ for (const [name, content] of Object.entries(files)) {
4064
+ await fsp.writeFile(join(projectDir, name), content, 'utf8');
4065
+ }
4066
+ const desktop = join(os.homedir(), 'Desktop');
4067
+ if (existsSync(desktop)) {
4068
+ for (const name of WINDOWS_DESKTOP_LAUNCHERS) {
4069
+ await fsp.writeFile(join(desktop, name), files[name], 'utf8').catch(() => {});
4070
+ }
4071
+ sendLog(`[native] Đã đặt ${WINDOWS_DESKTOP_LAUNCHERS.length} tệp bấm-đúp lên Desktop.`);
4072
+ }
4073
+ }
4074
+
4075
+ /**
4076
+ * Move a running docker project onto native, keeping every bot and every workspace file.
4077
+ *
4078
+ * Why this exists: openclaw >=2026.9 writes files through @openclaw/fs-safe, which fstat()s the
4079
+ * file after an atomic rename. That check cannot pass through a Docker Desktop bind mount on
4080
+ * Windows, so the moment a docker image is rebuilt onto 2026.9.x the gateway can no longer write
4081
+ * its own config: every boot is unclean, the restart-loop breaker trips, and the bot is down with
4082
+ * a misleading "ENOENT: fstat". Measured end to end on win_kha, 09/09/2026 — native on the very
4083
+ * same NTFS path works, so the fault is the bind-mount layer, not the disk.
4084
+ *
4085
+ * The data lives in two different places and only one of them is visible on the host:
4086
+ * bind-mounted paths are already on disk, but named volumes are not — state (the sqlite DB with
4087
+ * every session), the plugin tree, and 9router's login live inside volumes. Copy those out FIRST;
4088
+ * everything after this point is reversible, the containers and image are left untouched.
4089
+ */
4090
+ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {}) {
4091
+ const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
4092
+ if (!existsSync(composeFile)) return false;
4093
+ sendLog('[migrate] Chuyển project từ Docker sang native...');
4094
+
4095
+ // 1. Stop the containers so nothing writes while we copy. Never `down -v` — that deletes the
4096
+ // very volumes holding the customer's sessions.
4097
+ await run('docker', ['compose', '-f', composeFile, 'stop']).catch(() => {});
4098
+ // Stopping is not enough: the containers carry a restart policy, so the next reboot brings them
4099
+ // back and they grab 18789/18790 before the native gateway can bind — the native process then
4100
+ // dies with EADDRINUSE seconds after reporting "ready", which reads like a random crash.
4101
+ // Measured on win_kha after its first reboot, 10/09/2026. Clear the policy, keep the containers.
4102
+ const psAll = await runCapture('docker', ['compose', '-f', composeFile, 'ps', '-a', '--format', '{{.Name}}'], { shell: false });
4103
+ const names = String(psAll.stdout || '').split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
4104
+ if (names.length) await run('docker', ['update', '--restart=no', ...names]).catch(() => {});
4105
+
4106
+ // 2. Copy every named volume out to the host path it maps to. Read the mapping from the
4107
+ // containers themselves rather than guessing volume names: compose prefixes them with the
4108
+ // project name, which differs per machine.
4109
+ const psOut = await runCapture('docker', ['compose', '-f', composeFile, 'ps', '-a', '--format', '{{.Name}}'], { shell: false });
4110
+ const containers = String(psOut.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
4111
+ const CONTAINER_HOME = '/home/node/project';
4112
+ let copied = 0;
4113
+ for (const name of containers) {
4114
+ const insp = await runCapture('docker', ['inspect', name, '--format', '{{range .Mounts}}{{.Type}}|{{.Name}}|{{.Destination}}\n{{end}}'], { shell: false });
4115
+ for (const line of String(insp.stdout || '').split(/\r?\n/)) {
4116
+ const [type, volName, dest] = line.split('|').map((s) => (s || '').trim());
4117
+ if (type !== 'volume' || !volName || !dest) continue;
4118
+ // Map the in-container path back onto the project on disk.
4119
+ let hostDir = null;
4120
+ if (dest.startsWith(`${CONTAINER_HOME}/`)) hostDir = join(projectDir, dest.slice(CONTAINER_HOME.length + 1));
4121
+ else if (dest === '/root/.9router' || dest.endsWith('/.9router')) hostDir = join(projectDir, '.9router');
4122
+ if (!hostDir) { sendLog(`[migrate] bỏ qua volume ${volName} (${dest}) — không map được về project`); continue; }
4123
+ await fsp.mkdir(hostDir, { recursive: true });
4124
+ // Copy with a throwaway container: the volume has no host path we can read directly.
4125
+ const r = await runCapture('docker', [
4126
+ 'run', '--rm', '-v', `${volName}:/__src:ro`, '-v', `${hostDir}:/__dst`,
4127
+ 'node:22-slim', 'sh', '-c', 'cp -a /__src/. /__dst/ 2>/dev/null; ls -A /__dst | head -1',
4128
+ ], { shell: false });
4129
+ if (r.stdout && r.stdout.trim()) { copied++; sendLog(`[migrate] ${volName} → ${hostDir}`); }
4130
+ else sendLog(`[migrate] ${volName} rỗng hoặc không chép được — kiểm tra tay: ${hostDir}`);
4131
+ }
4132
+ }
4133
+ sendLog(`[migrate] đã đưa ${copied} volume xuống đĩa.`);
4134
+
4135
+ // 3. Rewrite what only made sense inside the container.
4136
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
4137
+ if (existsSync(cfgPath)) {
4138
+ const before = await fsp.readFile(cfgPath, 'utf8');
4139
+ await fsp.writeFile(`${cfgPath}.bak-truoc-native`, before, 'utf8');
4140
+ let cfg = null;
4141
+ try { cfg = JSON.parse(before); } catch { cfg = null; }
4142
+ if (cfg) {
4143
+ // 3a. Container-absolute workspace paths. On Windows these become "C:\home\node\..." and
4144
+ // every plugin fails its install-dir check.
4145
+ const projPosix = join(projectDir, '.openclaw').replace(/\\/g, '/');
4146
+ let text = JSON.stringify(cfg, null, 2).split(`${CONTAINER_HOME}/.openclaw`).join(projPosix);
4147
+ cfg = JSON.parse(text);
4148
+ // 3a-ter. RELATIVE workspace paths. Inside the container the process always ran from
4149
+ // /home/node/project so ".openclaw/workspace-x" resolved correctly. Native has no such
4150
+ // guarantee: the customer double-clicks a launcher from wherever they filed it, and the
4151
+ // agent then looks for its workspace under THAT folder and dies with WORKSPACE_VANISHED
4152
+ // — while agents that happen to carry an absolute path keep working, so only some bots
4153
+ // break. Exactly what happened when the launchers were moved into a Desktop subfolder
4154
+ // (win_kha 10/09/2026). Pin every workspace to the project.
4155
+ {
4156
+ const ag = cfg.agents || {};
4157
+ const list = ag.entries && typeof ag.entries === 'object' ? Object.entries(ag.entries)
4158
+ : Array.isArray(ag.list) ? ag.list.map((a) => [a && a.id, a]) : [];
4159
+ for (const [aid, meta] of list) {
4160
+ if (!meta || typeof meta.workspace !== 'string') continue;
4161
+ const w = meta.workspace;
4162
+ if (/^[a-zA-Z]:[\\/]/.test(w) || w.startsWith('/')) continue; // already absolute
4163
+ meta.workspace = join(projectDir, w.replace(/^\.[\\/]/, '')).replace(/\\/g, '/');
4164
+ sendLog(`[migrate] workspace ${aid}: "${w}" → "${meta.workspace}" (đường tương đối phụ thuộc chỗ bấm tệp)`);
4165
+ }
4166
+ }
4167
+ text = JSON.stringify(cfg, null, 2);
4168
+
4169
+ // 3a-bis. Docker-only HOSTNAMES. `http://9router:20128` resolves through compose's internal
4170
+ // DNS and nowhere else, so on native every model call dies with
4171
+ // `getaddrinfo ENOTFOUND 9router` — the bot still reacts (that is the channel, no model
4172
+ // needed) but can never reply, which reads as "bot ignores me". Same story for
4173
+ // host.docker.internal and the 192.168.65.x Desktop bridge. Measured win_kha 10/09/2026.
4174
+ text = text
4175
+ .split('http://9router:20128').join('http://127.0.0.1:20128')
4176
+ .split('http://host.docker.internal:20128').join('http://127.0.0.1:20128')
4177
+ .replace(/http:\/\/192\.168\.65\.\d+:(\d+)/g, 'http://127.0.0.1:$1');
4178
+ cfg = JSON.parse(text);
4179
+ // 3b. bind. Inside docker, 0.0.0.0 was safe because the port was published to loopback only.
4180
+ // Native binds the host directly, so the same value would expose the gateway to the LAN.
4181
+ cfg.gateway = (cfg.gateway && typeof cfg.gateway === 'object') ? cfg.gateway : {};
4182
+ if (cfg.gateway.bind !== 'loopback') {
4183
+ cfg.gateway.bind = 'loopback';
4184
+ delete cfg.gateway.customBindHost;
4185
+ if (cfg.gateway.controlUi) {
4186
+ cfg.gateway.controlUi.allowedOrigins = ['http://localhost:' + (cfg.gateway.port || NATIVE_DEFAULT_GATEWAY_PORT), 'http://127.0.0.1:' + (cfg.gateway.port || NATIVE_DEFAULT_GATEWAY_PORT)];
4187
+ }
4188
+ sendLog('[migrate] gateway.bind → loopback (native bind thẳng lên host, 0.0.0.0 là mở ra mạng)');
4189
+ }
4190
+ // 3c. The browser-automation plugin rewrites the browser block on every boot, so simply
4191
+ // deleting the bad key below is not enough — it grows back and the config is invalid
4192
+ // again by the next restart (same shape as the old toolResultMaxChars reinfection).
4193
+ // Take the pen away from it; patchDocker is pointless here now too.
4194
+ const baEntry = cfg.plugins && cfg.plugins.entries && cfg.plugins.entries['browser-automation'];
4195
+ if (baEntry && typeof baEntry === 'object') {
4196
+ baEntry.config = (baEntry.config && typeof baEntry.config === 'object') ? baEntry.config : {};
4197
+ baEntry.config.manageBrowserConfig = false;
4198
+ baEntry.config.patchDocker = false;
4199
+ sendLog('[migrate] tắt browser-automation.manageBrowserConfig (nó ghi lại khoá 2026.9 từ chối mỗi lần boot)');
4200
+ }
4201
+ // 3d. browser profile colour: required up to 2026.8, rejected outright by 2026.9.
4202
+ const profiles = (cfg.browser && cfg.browser.profiles) || null;
4203
+ if (profiles && typeof profiles === 'object') {
4204
+ for (const key of Object.keys(profiles)) {
4205
+ if (profiles[key] && typeof profiles[key] === 'object' && profiles[key].color !== undefined) {
4206
+ delete profiles[key].color;
4207
+ sendLog(`[migrate] gỡ browser.profiles.${key}.color (2026.9 không nhận)`);
4208
+ }
4209
+ }
4210
+ }
4211
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
4212
+ }
4213
+ }
4214
+
4215
+ // 4. Backup folders sitting inside extensions/ are still scanned as real plugins — that is where
4216
+ // the long-standing "duplicate plugin id" warnings came from. A leading dot does NOT hide them
4217
+ // on Windows, so move them out of the tree entirely.
4218
+ const extDir = join(projectDir, '.openclaw', 'extensions');
4219
+ if (existsSync(extDir)) {
4220
+ const parked = join(projectDir, '_plugin-backups');
4221
+ for (const entry of await fsp.readdir(extDir).catch(() => [])) {
4222
+ if (!/\.(bak|broken|old)[-.]|^\.openclaw-install-backups$/i.test(entry)) continue;
4223
+ await fsp.mkdir(parked, { recursive: true });
4224
+ await fsp.rename(join(extDir, entry), join(parked, entry.replace(/^\./, ''))).catch(() => {});
4225
+ sendLog(`[migrate] dời bản sao lưu plugin ra khỏi extensions/: ${entry}`);
4226
+ }
4227
+ }
4228
+
4229
+ // 5. zalo-connect wrote its Zalo sessions next to the container's HOME, which was itself
4230
+ // <project>/.openclaw — leaving them one level too deep. Lift them so 3.1.5's one-time
4231
+ // migration finds them; without this every account comes back "not authenticated".
4232
+ const nested = join(projectDir, '.openclaw', '.openclaw');
4233
+ if (existsSync(nested)) {
4234
+ for (const entry of await fsp.readdir(nested).catch(() => [])) {
4235
+ if (!/^zalo-connect-credentials.*\.json$/.test(entry)) continue;
4236
+ const target = join(projectDir, '.openclaw', entry);
4237
+ if (existsSync(target)) continue;
4238
+ await fsp.copyFile(join(nested, entry), target).catch(() => {});
4239
+ sendLog(`[migrate] nâng phiên Zalo lên đúng chỗ: ${entry}`);
4240
+ }
4241
+ }
4242
+
4243
+ sendLog('[migrate] Xong phần dữ liệu. Container và image giữ nguyên để còn quay lui.');
4244
+ return true;
4245
+ }
4246
+
3548
4247
  /**
3549
4248
  * Bring a native project up end to end: 9router → smart-route sync → resolve its API key into
3550
4249
  * openclaw.json → install the gateway as a managed service. Order matters: the gateway must boot
@@ -3562,6 +4261,15 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
3562
4261
  'utf8',
3563
4262
  );
3564
4263
 
4264
+ // Windows has no usable service story for this (see windows-launcher-gen.js): the Scheduled
4265
+ // Task openclaw installs is bound to a login session, so it dies with the SSH session and is
4266
+ // refused outright when set to run at boot. Give the owner double-click launchers instead —
4267
+ // the same shape as the .command files on macOS — and put the three they actually press on
4268
+ // the Desktop where they can find them.
4269
+ if (process.platform === 'win32') {
4270
+ await writeWindowsLaunchers(projectDir, gwPort, rtPort).catch((e) => sendLog(`[native] launcher: ${e.message}`));
4271
+ }
4272
+
3565
4273
  await startNative9Router(projectDir);
3566
4274
 
3567
4275
  // Same smart-route sync the docker sidecar runs, pointed at the native DB path. Without it
@@ -3830,6 +4538,30 @@ async function updateRuntime(target, projectDir) {
3830
4538
  probeCacheClear();
3831
4539
  return { ok: true, target, spec, mode: 'native' };
3832
4540
  }
4541
+ // A docker project asked to update is a docker project about to rebuild its image — which is
4542
+ // exactly the step that lands it on openclaw >=2026.9 and breaks writing through the bind mount
4543
+ // (see migrateDockerProjectToNative). So the update IS the migration: move the data onto native
4544
+ // first, then update the native way. Doing it here rather than on every page load keeps it a
4545
+ // deliberate act by the customer, not something that reshapes their machine behind their back.
4546
+ if (!isNativeProject(projectDir) && projectDir && existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'))) {
4547
+ sendLog('[update] Project đang chạy Docker — chuyển sang native trước khi cập nhật.');
4548
+ await ensureNodeInstalled();
4549
+ await migrateDockerProjectToNative(projectDir, { osChoice: state.os || '' });
4550
+ await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
4551
+ invalidateHostOpenclawVersion();
4552
+ await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
4553
+ const meta = readNativeMeta(projectDir) || {};
4554
+ await startNativeRuntime({
4555
+ projectDir,
4556
+ osChoice: state.os || '',
4557
+ gatewayPort: meta.gatewayPort || state.gatewayPort,
4558
+ routerPort: meta.routerPort || state.routerPort,
4559
+ });
4560
+ await syncRuntimeState(projectDir, { full: true }).catch(() => {});
4561
+ probeCacheClear();
4562
+ sendLog('[update] Đã chuyển sang native. Container Docker được giữ nguyên (đã dừng) để còn quay lui.');
4563
+ return { ok: true, target, spec, mode: 'native', migratedFromDocker: true };
4564
+ }
3833
4565
  if (state.mode === 'docker' && projectDir) {
3834
4566
  const dockerDir = join(projectDir, 'docker', 'openclaw');
3835
4567
  if (isRouter) {
@@ -4098,59 +4830,22 @@ async function getDockerBridgeIp() {
4098
4830
  } catch {}
4099
4831
  return '172.17.0.1';
4100
4832
  }
4101
- // ── Host control ────────────────────────────────────────────────────────────────
4102
- // The bot runs inside a container: it has no view of the host desktop and cannot start a
4103
- // program there, which is why asking it to open TeamViewer gets a refusal. The installer,
4104
- // though, already runs ON the host and already spawns processes (it launches Chrome). This
4105
- // exposes that ability to the bot over a small HTTP service.
4833
+ // ── PC control ──────────────────────────────────────────────────────────────────
4834
+ // Letting the bot drive this machine is OpenClaw's `computer` + `screen` tools, nothing else.
4835
+ // Up to 5.17.1 the installer also ran a small HTTP service on 18795 that opened allow-listed apps
4836
+ // for the bot. It was removed: the tools do the job properly, and having a second, weaker path
4837
+ // beside them actively hurt. On a customer machine the bot kept answering "chưa kết nối Host
4838
+ // Control" and never reached for the tools it already had.
4106
4839
  //
4107
- // Reachability: the dashboard itself binds to 127.0.0.1, which a container cannot reach, so
4108
- // this listens on the Docker bridge address as well — the same approach the Chrome relay
4109
- // uses, private to this machine and not routable from outside.
4110
- //
4111
- // Everything is gated: the service only starts when hostControl.enabled is true, every
4112
- // request needs the per-project token, and `open` accepts a key from the operator's own app
4113
- // list rather than an arbitrary command line. Opening apps on the host is a real capability,
4114
- // so it stays opt-in and enumerable instead of a general shell.
4115
- const HOST_CONTROL_PORT = 18795;
4116
- let _hostControlServer = null;
4117
- // The project the running host-control service serves. Tracked separately from the server
4118
- // singleton so enabling from a different (connected) project re-points the service without a
4119
- // restart — the request handler reads config from THIS dir, not a value captured at first-start.
4120
- let _hostControlProjectDir = null;
4840
+ // What is left is the switch itself, recorded per project in .openclaw/host-control.json.
4121
4841
 
4122
4842
  function hostControlConfigPath(projectDir) {
4123
4843
  return join(projectDir, '.openclaw', 'host-control.json');
4124
4844
  }
4125
4845
 
4126
- /** Common install locations, so the app list is useful before anyone edits it. */
4127
- function detectHostApps() {
4128
- const apps = {};
4129
- const add = (key, candidates) => {
4130
- for (const candidate of candidates) {
4131
- if (candidate && existsSync(candidate)) {
4132
- apps[key] = candidate;
4133
- return;
4134
- }
4135
- }
4136
- };
4137
- if (process.platform === 'win32') {
4138
- const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
4139
- const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
4140
- const local = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
4141
- add('teamviewer', [join(pf, 'TeamViewer', 'TeamViewer.exe'), join(pf86, 'TeamViewer', 'TeamViewer.exe')]);
4142
- add('chrome', [join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'), join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe')]);
4143
- add('zalo', [join(local, 'Programs', 'Zalo', 'Zalo.exe'), join(local, 'Zalo', 'Zalo.exe')]);
4144
- add('explorer', ['C:\\Windows\\explorer.exe']);
4145
- add('notepad', ['C:\\Windows\\System32\\notepad.exe']);
4146
- } else if (process.platform === 'darwin') {
4147
- add('teamviewer', ['/Applications/TeamViewer.app']);
4148
- add('chrome', ['/Applications/Google Chrome.app']);
4149
- add('zalo', ['/Applications/Zalo.app']);
4150
- add('finder', ['/System/Library/CoreServices/Finder.app']);
4151
- }
4152
- return apps;
4153
- }
4846
+
4847
+
4848
+
4154
4849
 
4155
4850
  /** Resolve an executable on PATH synchronously (returns absolute path or ''). */
4156
4851
  function whichSync(name) {
@@ -4209,221 +4904,13 @@ function resolveHostExecutable(bin) {
4209
4904
  return { file: target, prefixArgs: [] };
4210
4905
  }
4211
4906
 
4212
- /**
4213
- * CLI tools the bot may RUN (not just open) via /api/host/exec — output is captured and
4214
- * returned. Kept as a name→path allow-list, mirroring detectHostApps: the executable is fixed,
4215
- * only allow-listed names run. Auto-detects Claude Code CLI; add more by editing
4216
- * `.openclaw/host-control.json` → `commands`.
4217
- */
4218
- function detectHostCommands() {
4219
- const commands = {};
4220
- const claude = whichSync('claude');
4221
- if (claude) commands.claude = claude;
4222
- return commands;
4223
- }
4224
4907
 
4225
- /**
4226
- * Extra capabilities the operator grants together with PC control: seeing the screen
4227
- * (screenshot / screen recording) and running scripts through node or the Codex CLI.
4228
- *
4229
- * Kept out of detectHostCommands() on purpose. That one is the default list every project gets
4230
- * as soon as the dashboard reads host-control state; these are only merged in when the operator
4231
- * actually flips PC control on, so nothing is granted before they ask for it. `node` in
4232
- * particular runs arbitrary code, which is why it takes an explicit act.
4233
- */
4234
- function detectHostCapabilityCommands() {
4235
- const commands = {};
4236
- // The installer is itself node, so this path is guaranteed to exist and to be the same
4237
- // interpreter the native bot runs under (the one macOS will attach the screen permission to).
4238
- commands.node = process.execPath;
4239
- for (const name of ['npx', 'codex', 'claude', 'ffmpeg']) {
4240
- const bin = whichSync(name);
4241
- if (bin) commands[name] = bin; // ffmpeg = screen recording on Linux/macOS
4242
- }
4243
- // The Codex CLI usually is not on PATH — it ships inside the desktop app. With it allow-listed
4244
- // the bot can hand a job to Codex headlessly (`codex exec "…"`) and read the answer back.
4245
- if (!commands.codex) {
4246
- const bundledCodex = resolveCodexCli(detectCodexApp());
4247
- if (bundledCodex) commands.codex = bundledCodex;
4248
- }
4249
- if (process.platform === 'darwin') {
4250
- // Both a screenshot (`-x`) and a screen recording (`-v -V <secs>`) tool.
4251
- if (existsSync('/usr/sbin/screencapture')) commands.screencapture = '/usr/sbin/screencapture';
4252
- } else if (process.platform === 'linux') {
4253
- for (const name of ['gnome-screenshot', 'spectacle', 'scrot', 'import']) {
4254
- const bin = whichSync(name);
4255
- if (bin) { commands.screenshot = bin; break; }
4256
- }
4257
- }
4258
- return commands;
4259
- }
4260
4908
 
4261
- /**
4262
- * Merge the capability commands into the project's allow-list, and report what was added so the
4263
- * dashboard can name it. Existing entries are left alone: an operator who pointed `node` at a
4264
- * specific interpreter keeps that path.
4265
- */
4266
- function grantHostCapabilities(cfg) {
4267
- const detected = detectHostCapabilityCommands();
4268
- const added = [];
4269
- cfg.commands = cfg.commands || {};
4270
- for (const [name, bin] of Object.entries(detected)) {
4271
- if (!cfg.commands[name]) {
4272
- cfg.commands[name] = bin;
4273
- added.push(name);
4274
- }
4275
- }
4276
- // Desktop actions (/api/host/ui) come with the same grant: screenshot, pointer, keyboard,
4277
- // clipboard, windows. Built in, so they work on a machine with no Codex and no extra tools —
4278
- // on Linux they lean on xdotool/scrot, which the endpoint reports if missing.
4279
- if (cfg.ui !== true) {
4280
- cfg.ui = true;
4281
- added.push('desktop actions (screenshot/click/type)');
4282
- }
4283
- return added;
4284
- }
4285
-
4286
- // Mouse/keyboard/screen control comes from the Codex desktop app's own `computer-use` plugin.
4287
- // The bot reaches it by running `codex exec "<task>"`, which is a normal allow-listed command —
4288
- // no OpenClaw-side harness, no second agent, no gateway restart. All this code has to do is make
4289
- // sure the desktop app itself has computer-use installed and wired.
4290
- //
4291
- /** Where the desktop app that ships the Codex CLI + computer-use bundle lives. */
4292
- function detectCodexApp() {
4293
- const candidates = process.platform === 'darwin'
4294
- ? [
4295
- { app: '/Applications/Codex.app', bundle: '/Applications/Codex.app/Contents/Resources/plugins/openai-bundled' },
4296
- { app: '/Applications/ChatGPT.app', bundle: '/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled' },
4297
- ]
4298
- : process.platform === 'win32'
4299
- ? [
4300
- { app: join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'Programs', 'Codex'), bundle: '' },
4301
- { app: join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'Programs', 'ChatGPT'), bundle: '' },
4302
- ]
4303
- : [];
4304
- for (const candidate of candidates) {
4305
- if (existsSync(candidate.app)) {
4306
- return { present: true, app: candidate.app, bundle: candidate.bundle && existsSync(candidate.bundle) ? candidate.bundle : '' };
4307
- }
4308
- }
4309
- return { present: false, app: '', bundle: '' };
4310
- }
4311
4909
 
4312
- /**
4313
- * Find a marketplace the Codex app-server has ALREADY registered that carries the computer-use
4314
- * plugin, by reading its own `~/.codex/config.toml`.
4315
- *
4316
- * This matters because auto-install refuses to add new sources: pointing the plugin at a
4317
- * marketplace directory it has not discovered fails with "auto-install only uses marketplaces
4318
- * Codex app-server has already discovered … run /codex computer-use install". Naming a discovered
4319
- * marketplace instead keeps provisioning fully automatic.
4320
- */
4321
- function detectCodexMarketplace() {
4322
- const codexHome = process.env.CODEX_HOME || join(getRealHomedir(), '.codex');
4323
- const configPath = join(codexHome, 'config.toml');
4324
- if (!existsSync(configPath)) return null;
4325
- let toml = '';
4326
- try {
4327
- toml = fs.readFileSync(configPath, 'utf8');
4328
- } catch (_) {
4329
- return null;
4330
- }
4331
- // Minimal line-based TOML read: [marketplaces.<name>] headers and their `source = "..."`. A full
4332
- // TOML parser is not worth pulling in for two fields of someone else's config.
4333
- let name = '';
4334
- for (const rawLine of toml.split(/\r?\n/)) {
4335
- const line = rawLine.trim();
4336
- const header = line.match(/^\[([^\]]+)\]$/);
4337
- if (header) {
4338
- const section = header[1];
4339
- name = section.startsWith('marketplaces.') ? section.slice('marketplaces.'.length).replace(/^["']|["']$/g, '') : '';
4340
- continue;
4341
- }
4342
- if (!name) continue;
4343
- const source = (line.match(/^source\s*=\s*"([^"]+)"$/) || [])[1];
4344
- if (source && existsSync(join(source, 'plugins', 'computer-use'))) return { name, source };
4345
- }
4346
- return null;
4347
- }
4348
4910
 
4349
- /** The Codex CLI that ships inside the desktop app (or one on PATH). */
4350
- function resolveCodexCli(app) {
4351
- const bundled = app && app.app ? join(app.app, 'Contents', 'Resources', 'codex') : '';
4352
- if (bundled && existsSync(bundled)) return bundled;
4353
- return whichSync('codex');
4354
- }
4355
4911
 
4356
- /**
4357
- * Last mile on the Codex side: the OpenClaw plugin can only USE computer-use, it cannot install it
4358
- * into the desktop app. Two things have to be true there, and both are fixable with the app's own
4359
- * CLI (verified on a real machine):
4360
- * - the `computer-use` plugin is installed from a discovered marketplace, and
4361
- * - the `computer-use` MCP server points at that installed plugin. A stale global entry (left by
4362
- * an earlier manual attempt) shadows the plugin's own and exposes zero tools, which surfaces as
4363
- * the confusing "Computer Use is ready" with nothing behind it.
4364
- */
4365
- async function ensureCodexComputerUsePlugin(app, marketplace) {
4366
- const result = { cli: resolveCodexCli(app), pluginInstalled: false, installedNow: false, mcpRepaired: false };
4367
- if (!result.cli || !marketplace) return result;
4368
- const list = await runCapture(result.cli, ['plugin', 'list'], { shell: false }).catch(() => null);
4369
- if (!list) return result;
4370
- const ref = `computer-use@${marketplace.name}`;
4371
- const row = `${list.stdout || ''}\n${list.stderr || ''}`.split(/\r?\n/).find((line) => line.trim().startsWith(ref));
4372
- if (!row) return result;
4373
- result.pluginInstalled = /\binstalled\b/.test(row) && !/not installed/.test(row);
4374
- if (!result.pluginInstalled) {
4375
- sendLog(`[computer-use] Cài plugin ${ref} vào app Codex…`);
4376
- const add = await runCapture(result.cli, ['plugin', 'add', ref], { shell: false }).catch((err) => ({ code: 1, stderr: err.message }));
4377
- result.installedNow = add.code === 0;
4378
- if (!result.installedNow) result.error = (add.stderr || add.stdout || '').trim().split(/\r?\n/).slice(-2).join(' ');
4379
- else result.pluginInstalled = true;
4380
- }
4381
- // Repair the MCP registration only when it clearly is NOT the plugin's own (its cwd lives under
4382
- // the plugin cache). Removing the global entry lets the plugin-provided server take over.
4383
- const mcp = await runCapture(result.cli, ['mcp', 'get', 'computer-use'], { shell: false }).catch(() => null);
4384
- const mcpText = mcp ? `${mcp.stdout || ''}${mcp.stderr || ''}` : '';
4385
- if (mcpText && !/plugins\/cache\//.test(mcpText)) {
4386
- sendLog('[computer-use] Gỡ khai báo MCP computer-use cũ (trỏ sai chỗ) để dùng bản của plugin…');
4387
- const removed = await runCapture(result.cli, ['mcp', 'remove', 'computer-use'], { shell: false }).catch(() => ({ code: 1 }));
4388
- result.mcpRepaired = removed.code === 0;
4389
- }
4390
- return result;
4391
- }
4392
4912
 
4393
- /**
4394
- * Drop a tiny wrapper next to each workspace so GUI hand-off is one fixed command.
4395
- *
4396
- * Relying on the model to remember `--sandbox danger-full-access` does not work: a running session
4397
- * still holds the TOOLS.md it loaded at session start, so a bot mid-conversation keeps calling
4398
- * plain `codex exec`, gets "Computer Use was not approved to use <app>", and then invents a reason
4399
- * (observed twice: it told the operator to grant Screen Recording, which was already granted).
4400
- * With the wrapper the flags live on disk instead of in the prompt.
4401
- */
4402
- async function writeCodexTaskScript(projectDir, cliPath) {
4403
- const openclawDir = join(projectDir, '.openclaw');
4404
- if (!existsSync(openclawDir) || !cliPath) return '';
4405
- const body = [
4406
- '#!/bin/sh',
4407
- '# Managed by create-openclaw-bot — hand a desktop/GUI job to Codex and print its answer.',
4408
- '# Usage: pc-task.sh "mở TeamViewer và đọc ID trên màn hình"',
4409
- '# The sandbox flag is REQUIRED: the default read-only sandbox makes Codex refuse computer-use',
4410
- '# with "Computer Use was not approved to use <app>".',
4411
- 'if [ $# -eq 0 ]; then echo "usage: pc-task.sh \\"việc cần làm\\"" >&2; exit 2; fi',
4412
- `exec ${JSON.stringify(cliPath)} exec --skip-git-repo-check --sandbox danger-full-access "$@"`,
4413
- '',
4414
- ].join('\n');
4415
- let written = '';
4416
- for (const entry of await fsp.readdir(openclawDir).catch(() => [])) {
4417
- if (!entry.startsWith('workspace')) continue;
4418
- const binDir = join(openclawDir, entry, 'bin');
4419
- await fsp.mkdir(binDir, { recursive: true }).catch(() => {});
4420
- const path = join(binDir, 'pc-task.sh');
4421
- await fsp.writeFile(path, body, 'utf8').catch(() => {});
4422
- await fsp.chmod(path, 0o755).catch(() => {});
4423
- written = path;
4424
- }
4425
- return written;
4426
- }
4913
+
4427
4914
 
4428
4915
  /**
4429
4916
  * macOS/Windows privacy panes for the permissions PC control needs. The OS never lets an app
@@ -4479,6 +4966,13 @@ async function probeScreenPermission() {
4479
4966
  return { supported: true, granted };
4480
4967
  }
4481
4968
 
4969
+ /**
4970
+ * The on/off record for PC control. That is all it is now.
4971
+ *
4972
+ * It used to carry a token, an app allow-list and a command allow-list for a local HTTP service.
4973
+ * The service is gone (OpenClaw's own `computer`/`screen` tools replaced it), so those fields have
4974
+ * nothing left to gate. Old files keep them harmlessly; nothing reads them.
4975
+ */
4482
4976
  async function readHostControlConfig(projectDir) {
4483
4977
  const path = hostControlConfigPath(projectDir);
4484
4978
  let cfg = {};
@@ -4487,24 +4981,8 @@ async function readHostControlConfig(projectDir) {
4487
4981
  } catch (_) {
4488
4982
  cfg = {};
4489
4983
  }
4490
- let changed = false;
4491
4984
  if (typeof cfg.enabled !== 'boolean') {
4492
4985
  cfg.enabled = false;
4493
- changed = true;
4494
- }
4495
- if (!cfg.token) {
4496
- cfg.token = _require('crypto').randomBytes(24).toString('hex');
4497
- changed = true;
4498
- }
4499
- if (!cfg.apps || typeof cfg.apps !== 'object') {
4500
- cfg.apps = detectHostApps();
4501
- changed = true;
4502
- }
4503
- if (!cfg.commands || typeof cfg.commands !== 'object') {
4504
- cfg.commands = detectHostCommands();
4505
- changed = true;
4506
- }
4507
- if (changed) {
4508
4986
  await fsp.mkdir(dirname(path), { recursive: true }).catch(() => {});
4509
4987
  await fsp.writeFile(path, JSON.stringify(cfg, null, 2), 'utf8').catch(() => {});
4510
4988
  }
@@ -4518,59 +4996,7 @@ function spawnDetached(command, args) {
4518
4996
  child.unref();
4519
4997
  }
4520
4998
 
4521
- function openHostApp(target) {
4522
- if (process.platform === 'win32') {
4523
- // `start` needs a shell; the empty "" is the window title cmd expects before the path.
4524
- spawnDetached('cmd', ['/c', 'start', '', target]);
4525
- return;
4526
- }
4527
- if (process.platform === 'darwin') {
4528
- spawnDetached('open', [target]);
4529
- return;
4530
- }
4531
- spawnDetached('xdg-open', [target]);
4532
- }
4533
4999
 
4534
- /**
4535
- * Run an allow-listed CLI (e.g. Claude Code) and return its output. Unlike openHostApp this is
4536
- * NOT detached: we wait for it, capture stdout/stderr (capped), and enforce a timeout. No shell
4537
- * (shell:false) so args are literal — no injection; the executable is fixed by the allow-list.
4538
- */
4539
- function runHostCommand(res, name, bin, args, input, timeoutMs) {
4540
- const MAX_OUT = 200_000; // ~200 KB cap per stream, so a runaway process can't flood the reply
4541
- return new Promise((resolveP) => {
4542
- let out = '';
4543
- let err = '';
4544
- let settled = false;
4545
- const finish = (payload, status) => {
4546
- if (settled) return;
4547
- settled = true;
4548
- clearTimeout(timer);
4549
- json(res, payload, status);
4550
- resolveP();
4551
- };
4552
- let child;
4553
- try {
4554
- const target = resolveHostExecutable(bin);
4555
- child = spawn(target.file, [...target.prefixArgs, ...args], { shell: false, windowsHide: true });
4556
- } catch (e) {
4557
- return finish({ ok: false, error: e.message }, 500);
4558
- }
4559
- const timer = setTimeout(() => {
4560
- try { child.kill('SIGKILL'); } catch (_) {}
4561
- finish({ ok: false, error: `timeout after ${timeoutMs}ms`, timedOut: true, stdout: out.slice(0, MAX_OUT), stderr: err.slice(0, MAX_OUT) }, 504);
4562
- }, timeoutMs);
4563
- child.stdout?.on('data', (d) => { if (out.length < MAX_OUT) out += d.toString(); });
4564
- child.stderr?.on('data', (d) => { if (err.length < MAX_OUT) err += d.toString(); });
4565
- child.on('error', (e) => finish({ ok: false, error: e.message }, 500));
4566
- child.on('close', (code) => {
4567
- sendLog(`[host-control] Đã chạy "${name}" (exit ${code}).`);
4568
- finish({ ok: code === 0, command: name, code, stdout: out.slice(0, MAX_OUT), stderr: err.slice(0, MAX_OUT) }, 200);
4569
- });
4570
- if (input != null) { try { child.stdin.write(input); } catch (_) {} }
4571
- try { child.stdin.end(); } catch (_) {}
4572
- });
4573
- }
4574
5000
 
4575
5001
  /**
4576
5002
  * Desktop actions for the bot: see the screen, move and click, type, read the clipboard, list and
@@ -4631,451 +5057,56 @@ async function hostUiScreenshotTarget(projectDir) {
4631
5057
  return { hostPath: join(dir, name), containerPath: `/home/node/project/.openclaw/media/host-ui/${name}` };
4632
5058
  }
4633
5059
 
4634
- async function runHostUiWindows(projectDir, action, body, shot) {
4635
- const script = await ensureHostUiScript(projectDir);
4636
- const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Action', action];
4637
- const push = (flag, value) => { if (value !== undefined && value !== null && value !== '') args.push(flag, String(value)); };
4638
- push('-X', body.x);
4639
- push('-Y', body.y);
4640
- push('-ToX', body.toX);
4641
- push('-ToY', body.toY);
4642
- push('-Amount', body.amount);
4643
- push('-Text', body.text);
4644
- push('-Button', body.button);
4645
- push('-Clicks', body.clicks);
4646
- push('-Title', body.title);
4647
- if (shot) push('-Path', shot.hostPath);
4648
- const r = await runCapture('powershell', args, { shell: false, timeout: 30000 });
4649
- const parsed = parseJsonText(String(r.stdout || '').trim(), null);
4650
- if (parsed) return parsed;
4651
- const err = String(r.stderr || r.stdout || '').trim();
4652
- if (/Win32Exception|CopyFromScreen|handle is invalid/i.test(err)) {
4653
- 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.' };
4654
- }
4655
- return { ok: false, error: err.split('\n')[0] || `powershell exited ${r.code}` };
4656
- }
4657
-
4658
- async function runHostUiMac(action, body, shot) {
4659
- const osa = (script) => runCapture('osascript', ['-e', script], { shell: false, timeout: 20000 });
4660
- const point = () => `{${Number(body.x) || 0}, ${Number(body.y) || 0}}`;
4661
- switch (action) {
4662
- case 'screenshot': {
4663
- const r = await runCapture('screencapture', ['-x', shot.hostPath], { shell: false, timeout: 20000 });
4664
- return r.code === 0 ? { ok: true, path: shot.hostPath } : { ok: false, error: String(r.stderr || 'screencapture failed').trim() };
4665
- }
4666
- case 'screen_size': {
4667
- const r = await osa('tell application "Finder" to get bounds of window of desktop');
4668
- const nums = String(r.stdout || '').trim().split(/\s*,\s*/).map(Number);
4669
- return nums.length === 4 ? { ok: true, width: nums[2], height: nums[3] } : { ok: false, error: 'could not read screen bounds' };
4670
- }
4671
- case 'mouse_move':
4672
- case 'click': {
4673
- // System Events can click at a point; a plain move has no equivalent, so a move is a click
4674
- // target set-up only. Accessibility permission is required (System Settings → Privacy).
4675
- const clicks = Math.max(1, Number(body.clicks) || 1);
4676
- 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 };
4677
- for (let i = 0; i < clicks; i++) {
4678
- const r = await osa(`tell application "System Events" to click at ${point()}`);
4679
- if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() || 'click failed (grant Accessibility permission)' };
4680
- }
4681
- return { ok: true, button: 'left', clicks };
4682
- }
4683
- case 'type': {
4684
- const text = String(body.text || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
4685
- const r = await osa(`tell application "System Events" to keystroke "${text}"`);
4686
- return r.code === 0 ? { ok: true, typed: String(body.text || '').length } : { ok: false, error: String(r.stderr || '').trim() };
4687
- }
4688
- case 'key': {
4689
- const map = { enter: 'return', esc: 'escape', pageup: 'page up', pagedown: 'page down' };
4690
- for (const combo of String(body.text || '').split(/\s+/).filter(Boolean)) {
4691
- const parts = combo.toLowerCase().split('+').map((p) => p.trim()).filter(Boolean);
4692
- const key = map[parts[parts.length - 1]] || parts[parts.length - 1];
4693
- 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);
4694
- const using = mods.length ? ` using {${mods.join(', ')}}` : '';
4695
- const named = ['return', 'escape', 'tab', 'space', 'delete', 'up', 'down', 'left', 'right', 'home', 'end', 'page up', 'page down'];
4696
- const script = named.includes(key)
4697
- ? `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}`
4698
- : `tell application "System Events" to keystroke "${key}"${using}`;
4699
- const r = await osa(script);
4700
- if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() };
4701
- }
4702
- return { ok: true, keys: body.text };
4703
- }
4704
- case 'scroll': {
4705
- const amount = Number(body.amount) || 3;
4706
- const dir = amount < 0 ? 121 : 116; // page down / page up
4707
- for (let i = 0; i < Math.abs(amount); i++) await osa(`tell application "System Events" to key code ${dir}`);
4708
- return { ok: true, amount };
4709
- }
4710
- case 'clipboard_get': {
4711
- const r = await runCapture('pbpaste', [], { shell: false, timeout: 10000 });
4712
- return { ok: true, text: String(r.stdout || '') };
4713
- }
4714
- case 'clipboard_set': {
4715
- const r = await runCapture('sh', ['-c', 'pbcopy'], { shell: false, timeout: 10000, input: String(body.text || '') });
4716
- return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: 'pbcopy failed' };
4717
- }
4718
- case 'windows': {
4719
- const r = await osa('tell application "System Events" to get name of every process whose background only is false');
4720
- const list = String(r.stdout || '').trim().split(/\s*,\s*/).filter(Boolean).map((title) => ({ title, process: title }));
4721
- return { ok: true, windows: list };
4722
- }
4723
- case 'focus': {
4724
- const title = String(body.title || '').replace(/"/g, '');
4725
- if (!title) return { ok: false, error: 'focus needs a title' };
4726
- const r = await osa(`tell application "${title}" to activate`);
4727
- return r.code === 0 ? { ok: true, focused: title } : { ok: false, error: String(r.stderr || '').trim() || `no app named ${title}` };
4728
- }
4729
- default:
4730
- return { ok: false, error: `unsupported on macOS: ${action}` };
4731
- }
4732
- }
4733
-
4734
- async function runHostUiLinux(action, body, shot) {
4735
- const xdo = whichSync('xdotool');
4736
- const need = (bin, hint) => ({ ok: false, error: `${hint} needs ${bin}; install it (e.g. apt install ${bin})` });
4737
- switch (action) {
4738
- case 'screenshot': {
4739
- const tool = firstExistingCommand(['gnome-screenshot', 'scrot', 'spectacle', 'import']);
4740
- if (!tool) return need('scrot', 'screenshot');
4741
- const argv = tool.name === 'gnome-screenshot' ? ['-f', shot.hostPath]
4742
- : tool.name === 'spectacle' ? ['-b', '-n', '-o', shot.hostPath]
4743
- : tool.name === 'import' ? ['-window', 'root', shot.hostPath]
4744
- : [shot.hostPath];
4745
- const r = await runCapture(tool.bin, argv, { shell: false, timeout: 20000 });
4746
- return r.code === 0 ? { ok: true, path: shot.hostPath, tool: tool.name } : { ok: false, error: String(r.stderr || 'capture failed').trim() };
4747
- }
4748
- case 'screen_size': {
4749
- if (!xdo) return need('xdotool', 'screen_size');
4750
- const r = await runCapture(xdo, ['getdisplaygeometry'], { shell: false, timeout: 10000 });
4751
- const [w, h] = String(r.stdout || '').trim().split(/\s+/).map(Number);
4752
- return w && h ? { ok: true, width: w, height: h } : { ok: false, error: 'could not read display geometry' };
4753
- }
4754
- case 'mouse_move':
4755
- case 'click':
4756
- case 'drag':
4757
- case 'scroll':
4758
- case 'type':
4759
- case 'key':
4760
- case 'windows':
4761
- case 'focus': {
4762
- if (!xdo) return need('xdotool', action);
4763
- const button = { left: 1, middle: 2, right: 3 }[String(body.button || 'left')] || 1;
4764
- const argvFor = {
4765
- mouse_move: ['mousemove', String(body.x ?? 0), String(body.y ?? 0)],
4766
- click: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'click', '--repeat', String(Math.max(1, Number(body.clicks) || 1)), String(button)],
4767
- drag: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'mousedown', '1', 'mousemove', String(body.toX ?? 0), String(body.toY ?? 0), 'mouseup', '1'],
4768
- scroll: ['click', '--repeat', String(Math.max(1, Math.abs(Number(body.amount) || 3))), (Number(body.amount) || 3) < 0 ? '5' : '4'],
4769
- type: ['type', '--delay', '12', '--', String(body.text || '')],
4770
- key: ['key', ...String(body.text || '').split(/\s+/).filter(Boolean)],
4771
- windows: ['search', '--onlyvisible', '--name', '.'],
4772
- focus: ['search', '--onlyvisible', '--name', String(body.title || ''), 'windowactivate'],
4773
- }[action];
4774
- const r = await runCapture(xdo, argvFor, { shell: false, timeout: 20000 });
4775
- if (action === 'windows') {
4776
- const ids = String(r.stdout || '').trim().split(/\s+/).filter(Boolean).slice(0, 40);
4777
- const titles = [];
4778
- for (const id of ids) {
4779
- const t = await runCapture(xdo, ['getwindowname', id], { shell: false, timeout: 5000 });
4780
- const title = String(t.stdout || '').trim();
4781
- if (title) titles.push({ title, id });
4782
- }
4783
- return { ok: true, windows: titles };
4784
- }
4785
- return r.code === 0 ? { ok: true, action } : { ok: false, error: String(r.stderr || '').trim() || `xdotool exited ${r.code}` };
4786
- }
4787
- case 'clipboard_get': {
4788
- const tool = firstExistingCommand(['wl-paste', 'xclip', 'xsel']);
4789
- if (!tool) return need('xclip', 'clipboard_get');
4790
- const argv = tool.name === 'xclip' ? ['-o', '-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-o'] : [];
4791
- const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000 });
4792
- return { ok: true, text: String(r.stdout || '') };
4793
- }
4794
- case 'clipboard_set': {
4795
- const tool = firstExistingCommand(['wl-copy', 'xclip', 'xsel']);
4796
- if (!tool) return need('xclip', 'clipboard_set');
4797
- const argv = tool.name === 'xclip' ? ['-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-i'] : [];
4798
- const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000, input: String(body.text || '') });
4799
- return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: `${tool.name} failed` };
4800
- }
4801
- default:
4802
- return { ok: false, error: `unsupported on Linux: ${action}` };
4803
- }
4804
- }
4805
5060
 
4806
- async function runHostUi(projectDir, body = {}) {
4807
- const action = String(body.action || '').trim();
4808
- if (!HOST_UI_ACTIONS.has(action)) {
4809
- return { status: 400, payload: { ok: false, error: `unknown action: ${action || '(none)'}`, actions: [...HOST_UI_ACTIONS] } };
4810
- }
4811
- const shot = action === 'screenshot' ? await hostUiScreenshotTarget(projectDir) : null;
4812
- let result;
4813
- try {
4814
- if (process.platform === 'win32') result = await runHostUiWindows(projectDir, action, body, shot);
4815
- else if (process.platform === 'darwin') result = await runHostUiMac(action, body, shot);
4816
- else result = await runHostUiLinux(action, body, shot);
4817
- } catch (err) {
4818
- result = { ok: false, error: err.message };
4819
- }
4820
- if (shot && result?.ok) {
4821
- // The project folder is bind-mounted into the container, so hand back the path the bot can
4822
- // actually open — otherwise it gets a Windows path it cannot read and reports failure.
4823
- result.path = shot.hostPath;
4824
- result.containerPath = shot.containerPath;
4825
- result.bytes = existsSync(shot.hostPath) ? (await fsp.stat(shot.hostPath)).size : 0;
4826
- }
4827
- sendLog(`[host-control] UI "${action}" → ${result?.ok ? 'ok' : `lỗi: ${result?.error || 'unknown'}`}`);
4828
- return { status: result?.ok ? 200 : 500, payload: result };
4829
- }
4830
5061
 
4831
- async function handleHostControl(req, res, projectDir) {
4832
- const cfg = await readHostControlConfig(projectDir);
4833
- const url = new URL(req.url, 'http://localhost');
4834
- const presented = req.headers['x-openclaw-token'] || url.searchParams.get('token') || '';
4835
- if (!cfg.enabled) return json(res, { ok: false, error: 'host control is disabled' }, 403);
4836
- if (presented !== cfg.token) return json(res, { ok: false, error: 'invalid token' }, 401);
4837
5062
 
4838
- if (url.pathname === '/api/browser/start-chrome' && req.method === 'POST') {
4839
- try {
4840
- return json(res, await startChromeDebug());
4841
- } catch (err) {
4842
- return json(res, { ok: false, error: err.message }, err.status || 500);
4843
- }
4844
- }
4845
- if (url.pathname === '/api/host/apps' && req.method === 'GET') {
4846
- return json(res, { ok: true, apps: Object.keys(cfg.apps || {}), commands: Object.keys(cfg.commands || {}), platform: process.platform });
4847
- }
4848
- if (url.pathname === '/api/host/ui' && req.method === 'POST') {
4849
- // Part of PC control, but its own switch: seeing the screen and moving the pointer is a bigger
4850
- // step than opening an app, so it only answers once the operator has granted capabilities.
4851
- if (cfg.ui !== true) {
4852
- 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);
4853
- }
4854
- const body = await readJson(req).catch(() => ({}));
4855
- const { status, payload } = await runHostUi(projectDir, body || {});
4856
- return json(res, payload, status);
4857
- }
4858
- if (url.pathname === '/api/host/exec' && req.method === 'POST') {
4859
- const body = await readJson(req).catch(() => ({}));
4860
- const name = String(body.command || '').trim().toLowerCase();
4861
- if (!name) return json(res, { ok: false, error: 'missing "command"' }, 400);
4862
- const bin = (cfg.commands || {})[name];
4863
- if (!bin) {
4864
- return json(res, {
4865
- ok: false,
4866
- error: `"${name}" is not in this machine's command list`,
4867
- commands: Object.keys(cfg.commands || {}),
4868
- }, 404);
4869
- }
4870
- // Args are passed literally (spawn with shell:false) so nothing in them is re-interpreted
4871
- // by a shell — the executable is fixed to the allow-listed path, callers cannot pick a
4872
- // different binary or inject a second command.
4873
- const args = Array.isArray(body.args) ? body.args.map((a) => String(a)) : [];
4874
- const input = body.input != null ? String(body.input) : null;
4875
- const timeoutMs = Math.min(Math.max(Number(body.timeoutMs) || 180000, 1000), 600000);
4876
- return runHostCommand(res, name, bin, args, input, timeoutMs);
4877
- }
4878
- if (url.pathname === '/api/host/open' && req.method === 'POST') {
4879
- const body = await readJson(req).catch(() => ({}));
4880
- const key = String(body.app || body.target || '').trim();
4881
- if (!key) return json(res, { ok: false, error: 'missing "app"' }, 400);
4882
- const path = (cfg.apps || {})[key.toLowerCase()];
4883
- if (!path) {
4884
- return json(res, {
4885
- ok: false,
4886
- error: `"${key}" is not in this machine's app list`,
4887
- apps: Object.keys(cfg.apps || {}),
4888
- }, 404);
4889
- }
4890
- openHostApp(path);
4891
- sendLog(`[host-control] Đã mở "${key}" trên máy (${path}).`);
4892
- return json(res, { ok: true, app: key, path });
4893
- }
4894
- return json(res, { ok: false, error: 'unknown endpoint' }, 404);
4895
- }
5063
+
4896
5064
 
4897
5065
  /**
4898
- * Teach every bot in the project how to reach the host-control service, and hand it the
4899
- * token. Written into TOOLS.md as a managed block so flipping the switch off removes it
4900
- * again a bot that still had the instructions would keep trying an endpoint that now
4901
- * refuses. `host.docker.internal` resolves in the container on every OS because the
4902
- * generated compose maps it to host-gateway.
5066
+ * Teach every bot in the project how to drive the machine with OpenClaw's own `computer` tool.
5067
+ *
5068
+ * This used to describe a local HTTP service on port 18795 that the bot called with curl. That
5069
+ * service is gone: OpenClaw ships the real thing, and a second half-capable path next to it only
5070
+ * gave the bot a way to fail. `computer` sees the screen and moves the pointer; `screen` takes the
5071
+ * snapshot. Both arrive once the operator presses "Điều khiển máy", which allows the tools, enables
5072
+ * cua-computer and starts the node host.
5073
+ *
5074
+ * Written as a managed block so switching PC control off removes the instructions again - a bot
5075
+ * still holding them would keep reaching for a tool it no longer has.
4903
5076
  */
4904
5077
  async function writeHostControlAccess(projectDir, cfg) {
4905
5078
  const openclawDir = join(projectDir, '.openclaw');
4906
5079
  if (!existsSync(openclawDir)) return;
4907
- const native = isNativeProject(projectDir);
4908
- // Native bots run on the host itself; host.docker.internal only resolves from inside a container,
4909
- // so a native bot curling it fails ("could not connect"). Use loopback there instead.
4910
- const base = native ? `http://127.0.0.1:${HOST_CONTROL_PORT}` : `http://host.docker.internal:${HOST_CONTROL_PORT}`;
4911
- const apps = Object.keys(cfg.apps || {});
4912
- const commands = Object.keys(cfg.commands || {});
4913
- const execBlock = commands.length ? [
4914
- '',
4915
- 'Chạy một CLI trên máy chủ và LẤY KẾT QUẢ về (chỉ lệnh trong danh sách; trả `{ok,code,stdout,stderr}`).',
4916
- 'Dùng để giao việc cho công cụ dòng lệnh, ví dụ Claude Code:',
4917
- '',
4918
- '```sh',
4919
- `curl -s -X POST ${base}/api/host/exec -H "x-openclaw-token: ${cfg.token}" \\`,
4920
- ' -H "content-type: application/json" -d \'{"command":"claude","args":["-p","tóm tắt repo hiện tại"]}\'',
4921
- '```',
4922
- '',
4923
- `Lệnh khả dụng: ${commands.map((c) => `\`${c}\``).join(', ')}. Lệnh mặc định timeout 180s, output tối đa ~200KB/luồng.`,
4924
- ] : [];
4925
- // Desktop actions: one endpoint, same JSON on every OS, so the bot does not need per-platform
4926
- // instructions. Screenshots land in the project folder, which the container already sees.
4927
- const uiBlock = cfg.ui === true ? [
4928
- '',
4929
- '### Thao tác trên màn hình chủ',
4930
- '',
4931
- '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** —',
4932
- 'đừng đoán vị trí. Toạ độ tính bằng pixel màn hình, gốc ở góc trên-trái.',
4933
- '',
4934
- '```sh',
4935
- `curl -s -X POST ${base}/api/host/ui -H "x-openclaw-token: ${cfg.token}" \\`,
4936
- ' -H "content-type: application/json" -d \'{"action":"screenshot"}\'',
4937
- '```',
4938
- '',
4939
- 'Trả về `containerPath` — **đọc/gửi ảnh bằng đường dẫn đó** (nằm trong project nên bạn thấy được),',
4940
- 'kèm `width`/`height` để biết màn hình bao lớn.',
4941
- '',
4942
- 'Các action khác (cùng dạng `{"action":...}`):',
4943
- '',
4944
- '- `screen_size` — kích thước màn hình',
4945
- '- `mouse_move` + `x`,`y` — di chuột',
4946
- '- `click` + `x`,`y`, tuỳ chọn `button` (`left`/`right`/`middle`) và `clicks` (2 = double-click)',
4947
- '- `drag` + `x`,`y`,`toX`,`toY` — kéo thả',
4948
- '- `scroll` + `amount` (âm = xuống), tuỳ chọn `x`,`y`',
4949
- '- `type` + `text` — gõ chữ vào cửa sổ đang focus',
4950
- '- `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',
4951
- '- `clipboard_get` / `clipboard_set` + `text` — đọc/ghi clipboard',
4952
- '- `windows` — liệt kê cửa sổ đang mở; `focus` + `title` — đưa cửa sổ lên trước',
4953
- '',
4954
- 'Nếu trả về lỗi "no desktop session available" thì installer đang chạy ngoài phiên desktop —',
4955
- 'nói chủ mở lại installer trong máy, đừng thử cách khác.',
4956
- 'Trên Linux, thiếu `xdotool`/`scrot` thì endpoint nói rõ cần cài gì — báo lại cho chủ.',
4957
- ] : [];
4958
- // Screen capture / recording — only advertised when the operator granted the matching tool, so
4959
- // the bot never tries a binary that is not on this machine's allow-list.
4960
- // Windows has no capture binary to allow-list (PowerShell does it inline), so the section shows
4961
- // up there too — a native bot runs the command itself, the allow-list only gates the bridge.
4962
- const hasCapture = commands.includes('screencapture') || commands.includes('screenshot') || commands.includes('ffmpeg') || (native && process.platform === 'win32');
4963
- const captureBlock = hasCapture ? [
4964
- '',
4965
- '### Chụp / quay màn hình',
4966
- '',
4967
- ...(commands.includes('screencapture') ? [
4968
- '- Chụp: `screencapture -x /tmp/shot.png` (thêm `-R x,y,w,h` để chụp một vùng, `-l <windowid>` chụp 1 cửa sổ).',
4969
- '- Quay: `screencapture -v -V 10 /tmp/rec.mov` (quay 10 giây rồi tự dừng).',
4970
- ] : []),
4971
- ...(commands.includes('screenshot') ? ['- Chụp: dùng lệnh `screenshot` (công cụ chụp của desktop này) với đường dẫn file đầu ra.'] : []),
4972
- ...(native && process.platform === 'win32' ? [
4973
- '- Chụp (Windows): `powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bm=New-Object Drawing.Bitmap $b.Width,$b.Height; [Drawing.Graphics]::FromImage($bm).CopyFromScreen($b.Location,[Drawing.Point]::Empty,$b.Size); $bm.Save(\'C:\\Temp\\shot.png\')"` (tạo sẵn thư mục đích).',
4974
- ] : []),
4975
- ...(commands.includes('ffmpeg') ? ['- Quay bằng `ffmpeg` khi cần định dạng khác (macOS: `-f avfoundation`, Linux: `-f x11grab`, Windows: `-f gdigrab -i desktop`).'] : []),
4976
- '',
4977
- 'Chụp xong thì ĐỌC file ảnh bằng tool đọc ảnh để phân tích, rồi xoá file tạm. Lần đầu macOS sẽ hỏi quyền **Screen Recording** cho `node`: nếu ảnh ra đen/rỗng hoặc lệnh lỗi quyền thì nhờ chủ bấm "Cấp quyền chụp/quay màn hình" trong dashboard, đừng thử vòng khác.',
4978
- ] : [];
4979
- const scriptCommands = commands.filter((c) => c === 'node' || c === 'npx' || c === 'codex' || c === 'claude');
4980
- const scriptBlock = scriptCommands.length ? [
4981
- '',
4982
- '### Chạy script & giao việc cho CLI khác',
4983
- '',
4984
- `Chủ đã cho phép: ${scriptCommands.map((c) => `\`${c}\``).join(', ')} — dùng cho việc tự động hoá nhỏ (ví dụ \`node -e "..."\`, \`node script.js\`).`,
4985
- ...(commands.includes('codex') ? [
4986
- '- Giao việc cho **Codex** (chạy ngầm, lấy kết quả text): `codex exec --skip-git-repo-check "việc cần làm"`. Việc cần nhìn/điều khiển màn hình thì thêm `--sandbox danger-full-access` (xem mục dưới). Lượt này tiêu quota gói ChatGPT của chủ, nên chỉ dùng khi chủ yêu cầu và mô tả việc gọn.',
4987
- ] : []),
4988
- ...(commands.includes('claude') ? [
4989
- '- Giao việc cho **Claude Code**: `claude -p "việc cần làm"` (một lượt, trả stdout).',
4990
- ] : []),
4991
- 'Đây là quyền chạy mã tuỳ ý trên máy chủ: chỉ chạy khi chủ yêu cầu rõ, không cài thêm gì, không sửa file ngoài phạm vi được yêu cầu.',
4992
- ] : [];
4993
5080
  const startTag = '<!-- OPENCLAW:HOST_CONTROL:START -->';
4994
5081
  const endTag = '<!-- OPENCLAW:HOST_CONTROL:END -->';
4995
- // NATIVE: the bot runs directly on the host with `exec`, so it opens apps with the OS command —
4996
- // no bridge, no host.docker.internal (which doesn't resolve off-container anyway). DOCKER: the
4997
- // bot is in a container and can't see the desktop, so it must call the installer's host service.
4998
- const nativeBlock = [
4999
- startTag,
5000
- '',
5001
- '## 🖥️ Điều khiển máy của chủ (host control — chế độ native)',
5002
- '',
5003
- 'Bạn chạy TRỰC TIẾP trên máy của chủ và có quyền `exec`, nên mở ứng dụng bằng lệnh hệ điều hành — KHÔNG cần service/bridge nào (đừng dùng host.docker.internal hay curl cổng 18795):',
5004
- '',
5005
- '- macOS: `open -a "<Tên app>"` — ví dụ `open -a "TeamViewer"`',
5006
- '- Linux: `xdg-open <app|url>` hoặc chạy binary trực tiếp',
5007
- '- Windows: `cmd /c start "" "<app>"`',
5008
- '',
5009
- apps.length ? `App chủ gợi ý: ${apps.map((a) => `\`${a}\``).join(', ')}.` : 'Chủ chưa liệt kê app — mở theo tên app có trên máy.',
5010
- ...captureBlock,
5011
- ...scriptBlock,
5012
- '',
5013
- ...(commands.includes('codex') ? [
5014
- '### Điều khiển chuột/bàn phím & nhìn màn hình',
5015
- '',
5016
- 'Bạn KHÔNG có tool điều khiển GUI. Việc đó giao cho **Codex** — nó có sẵn Computer Use. Dùng ĐÚNG script này (đã cài sẵn cờ cần thiết):',
5017
- '',
5018
- '```sh',
5019
- 'bin/pc-task.sh "mở TeamViewer, đọc mật khẩu đang hiện trên màn hình rồi in ra"',
5020
- '```',
5021
- '',
5022
- '⚠️ **Đừng gọi `codex exec` trần.** Mặc định nó chạy sandbox read-only và tự từ chối computer-use với lỗi `Computer Use was not approved to use <app>` — KHÔNG phải do thiếu quyền Screen Recording, đừng bảo chủ đi cấp lại quyền. Script trên đã kèm `--sandbox danger-full-access` (mức `workspace-write` không đủ).',
5023
- '',
5024
- 'Codex tự nhìn màn hình, click, gõ phím rồi trả kết quả text về cho bạn; bạn thuật lại cho chủ. Lưu ý:',
5025
- '',
5026
- '- Mô tả việc gọn nhưng đủ (mục tiêu + kết quả cần trả). Mỗi lượt tiêu quota gói ChatGPT của chủ, đừng gọi lung tung.',
5027
- '- Việc GUI có thể chạy lâu: đặt timeout rộng, đừng kết luận thất bại sớm.',
5028
- '- Computer Use từ chối vài app vì an toàn (Terminal, chính app ChatGPT/Codex): lỗi ghi rõ `not allowed to use the app ... for safety reasons` — báo chủ tự làm, đừng lách.',
5029
- '- Điều khiển chuột/bàn phím hiện chỉ chạy trên macOS. Trên Windows/Linux bạn vẫn mở app, chụp màn hình và chạy script được.',
5030
- '- Lỗi thật sự do thiếu quyền hệ điều hành sẽ nói về Screen Recording/Accessibility; chỉ khi đó mới nhờ chủ bấm nút cấp quyền trong dashboard. Luôn trích **nguyên văn** lỗi cho chủ thay vì đoán nguyên nhân.',
5031
- '',
5032
- ] : []),
5033
- 'Chỉ mở app, chụp/quay màn hình hoặc điều khiển máy khi chủ yêu cầu rõ. Không tự ý chụp màn hình để "xem thử".',
5034
- '',
5035
- endTag,
5036
- '',
5037
- ].join('\n');
5038
- const dockerBlock = [
5082
+ const block = [
5039
5083
  startTag,
5040
5084
  '',
5041
- '## 🖥️ Điều khiển máy của chủ (host control)',
5042
- '',
5043
- 'Bạn chạy trong container nên không thấy desktop của chủ. Muốn mở Chrome hay một ứng dụng trên máy thật thì gọi service của installer (chạy trên máy chủ) bằng `exec`:',
5044
- '',
5045
- '```sh',
5046
- `curl -s -X POST ${base}/api/browser/start-chrome -H "x-openclaw-token: ${cfg.token}"`,
5047
- '```',
5085
+ '## 🖥️ Điều khiển máy của chủ',
5048
5086
  '',
5049
- 'Mở ứng dụng (chỉ những apptrong danh sách của máy):',
5087
+ 'Chủ đã cho phép bạn dùng máy này. Bạn hai công cụ dưới đây và hãy dùng THẲNG chúng - đừng',
5088
+ 'gọi HTTP, đừng tự dựng script PowerShell, đừng đi tìm dịch vụ phụ nào khác:',
5050
5089
  '',
5051
- '```sh',
5052
- `curl -s -X POST ${base}/api/host/open -H "x-openclaw-token: ${cfg.token}" \\`,
5053
- ' -H "content-type: application/json" -d \'{"app":"teamviewer"}\'',
5054
- '```',
5090
+ '- `screen` — chụp màn hình. Luôn chụp TRƯỚC khi định bấm hay gõ, để biết đang nhìn thấy gì.',
5091
+ '- `computer` chuột, bấm, phím, kéo thả, cuộn, nhấn tổ hợp phím.',
5055
5092
  '',
5056
- 'Xem danh sách app đang được phép:',
5093
+ 'Cách mở một ứng dụng, dụ TeamViewer: chụp màn hình → bấm nút Start → gõ `TeamViewer` →',
5094
+ 'nhấn Enter → chụp lại để xác nhận nó đã mở. Cách này dùng được với MỌI app đã cài trên máy,',
5095
+ 'kể cả app vừa cài hôm qua, vì bạn thao tác đúng như người ngồi trước máy.',
5057
5096
  '',
5058
- '```sh',
5059
- `curl -s ${base}/api/host/apps -H "x-openclaw-token: ${cfg.token}"`,
5060
- '```',
5097
+ '**Luôn kiểm chứng bằng mắt.** Sau mỗi bước quan trọng hãy chụp lại màn hình rồi mới nói đã xong.',
5098
+ 'Đừng báo "đã mở" khi chưa nhìn thấy cửa sổ của nó.',
5061
5099
  '',
5062
- 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`.',
5063
- ...execBlock,
5064
- ...uiBlock,
5065
- // Docker only: a screenshot taken on the host lands on the HOST filesystem, which this
5066
- // container cannot read — say so instead of letting the bot hunt for a missing file.
5067
- ...(hasCapture ? [
5068
- '',
5069
- 'Chụp/quay màn hình chạy trên MÁY CHỦ nên file ảnh nằm ở ổ đĩa của chủ, container này KHÔNG đọc được. Chụp vào một thư mục đã mount cho bot (nếu có) hoặc nhờ chủ gửi ảnh; đừng đoán nội dung màn hình.',
5070
- ] : []),
5100
+ '**Khi không dùng được:** nếu công cụ báo lỗi, hãy trích **nguyên văn** câu lỗi cho chủ nói ',
5101
+ 'bạn đang định làm gì. Đừng đoán nguyên nhân, và đừng đi tìm đường vòng khác - không có đường',
5102
+ 'nào khác. Thường chỉ cần chủ bấm lại nút "Điều khiển máy" trong bảng điều khiển.',
5071
5103
  '',
5072
- 'Nếu trả về `host control is disabled` thì chủ chưa bật quyền này nói chủ bật trong dashboard,',
5073
- 'đừng cố tìm đường khác. Chỉ mở app hoặc chạy lệnh khi chủ yêu cầu rõ.',
5104
+ 'Chỉ dùng khi chủ yêu cầu rõ. Không tự chụp màn hình để "xem thử", không tự bấm vào thứ chủ',
5105
+ 'không nhắc tới, không mật khẩu hay thông tin thanh toán vào bất cứ đâu.',
5074
5106
  '',
5075
5107
  endTag,
5076
5108
  '',
5077
5109
  ].join('\n');
5078
- const block = native ? nativeBlock : dockerBlock;
5079
5110
  for (const entry of await fsp.readdir(openclawDir).catch(() => [])) {
5080
5111
  if (!entry.startsWith('workspace')) continue;
5081
5112
  const toolsMd = join(openclawDir, entry, 'TOOLS.md');
@@ -5099,46 +5130,6 @@ function removeManagedBlockFrom(content, blockId) {
5099
5130
  return `${content.substring(0, startIdx).trimEnd()}\n${content.substring(endIdx + endTag.length).trimStart()}`.trim() + '\n';
5100
5131
  }
5101
5132
 
5102
- async function ensureHostControl(projectDir) {
5103
- // Point the service at the project being enabled (re-points a service already running for
5104
- // another project — the handler reads _hostControlProjectDir per request).
5105
- _hostControlProjectDir = projectDir;
5106
- const cfg = await readHostControlConfig(projectDir);
5107
- if (!cfg.enabled) return { ok: false, reason: 'disabled' };
5108
- // Desktop only. Opening TeamViewer or an app needs a GUI, so a headless server has nothing
5109
- // to control — and, more importantly, it is where 0.0.0.0 would be a real exposure (a VPS
5110
- // has a public IP). Refusing here means the service never binds on a headless box, so the
5111
- // public-exposure question does not arise. A rare VPS-with-desktop can override with
5112
- // OPENCLAW_HOST_CONTROL_ALLOW_HEADLESS=1.
5113
- if (isHeadlessServer() && process.env.OPENCLAW_HOST_CONTROL_ALLOW_HEADLESS !== '1') {
5114
- return { ok: false, reason: 'headless server — no desktop to control' };
5115
- }
5116
- if (_hostControlServer) return { ok: true, port: HOST_CONTROL_PORT };
5117
- const bridgeIp = await getDockerBridgeIp().catch(() => null);
5118
- const server = http.createServer((req, res) => {
5119
- // Read the CURRENTLY active project each request, so re-pointing takes effect live.
5120
- handleHostControl(req, res, _hostControlProjectDir || projectDir).catch((err) => json(res, { ok: false, error: err.message }, 500));
5121
- });
5122
- // Bind all interfaces: the container reaches the host by different addresses per platform —
5123
- // docker0 (172.17.0.1) on native Linux, the Docker Desktop gateway (host.docker.internal,
5124
- // e.g. 192.168.65.254) on macOS/Windows — and binding one misses the others. The token is
5125
- // the guard here, not the interface: every request needs it, and the service only exists
5126
- // while the operator has host control switched on.
5127
- const bindOk = await new Promise((resolveP) => {
5128
- server.once('error', () => resolveP(false));
5129
- server.listen(HOST_CONTROL_PORT, '0.0.0.0', () => resolveP(true));
5130
- });
5131
- if (!bindOk) return { ok: false, reason: `port ${HOST_CONTROL_PORT} in use` };
5132
- _hostControlServer = server;
5133
- sendLog(`[host-control] Nghe ở 0.0.0.0:${HOST_CONTROL_PORT} (cần token) — bot có thể mở Chrome/app trên máy này.`);
5134
- if (bridgeIp && process.platform === 'linux') {
5135
- // ufw's default-deny drops container→host traffic silently. Scope the allow rule to the
5136
- // private bridge address only, so opening the port here does not expose it to the LAN.
5137
- run('sh', ['-c', `command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active" && ufw allow in to ${bridgeIp} port ${HOST_CONTROL_PORT} proto tcp comment "openclaw host-control (docker bridge only)" || true`])
5138
- .catch(() => {});
5139
- }
5140
- return { ok: true, port: HOST_CONTROL_PORT, host: '0.0.0.0' };
5141
- }
5142
5133
 
5143
5134
  async function ensureChromeRelay() {
5144
5135
  if (_chromeRelayServer) return true;
@@ -5308,6 +5299,79 @@ async function waitForDockerDaemon(timeoutMs) {
5308
5299
  }
5309
5300
  }
5310
5301
 
5302
+ // OpenClaw's own engines range, copied verbatim from its package.json:
5303
+ // ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0"
5304
+ // The gaps matter — Node 23.x and 25.0–25.8 are NOT usable — so "install the latest Node" is the
5305
+ // wrong instinct here. When we have to install, we install 24 LTS, which sits inside a supported
5306
+ // window and is what every working host in the fleet already runs.
5307
+ const NODE_TARGET_MAJOR = 24;
5308
+ function nodeVersionSupported(raw) {
5309
+ const m = String(raw || '').match(/v?(\d+)\.(\d+)\.(\d+)/);
5310
+ if (!m) return false;
5311
+ const [maj, min, pat] = [Number(m[1]), Number(m[2]), Number(m[3])];
5312
+ const atLeast = (a, b, c) => maj > a || (maj === a && (min > b || (min === b && pat >= c)));
5313
+ if (maj === 22) return atLeast(22, 22, 3);
5314
+ if (maj === 24) return atLeast(24, 15, 0);
5315
+ if (maj >= 25) return maj > 25 || atLeast(25, 9, 0);
5316
+ return false;
5317
+ }
5318
+
5319
+ // Native mode runs openclaw straight off the host, so the host's Node IS the runtime — unlike
5320
+ // docker mode, where the image carried its own. A customer machine that never needed Node before
5321
+ // (or carries one outside openclaw's supported range) must get a usable one before anything else,
5322
+ // otherwise the install dies deep inside `npm i -g openclaw` with an unreadable engine error.
5323
+ async function ensureNodeInstalled() {
5324
+ const current = await commandExists('node', ['--version']);
5325
+ if (current.ok && nodeVersionSupported(current.output)) {
5326
+ sendLog(`[node] Node ${current.output.trim()} hợp lệ.`);
5327
+ return;
5328
+ }
5329
+ const why = current.ok
5330
+ ? `Node ${current.output.trim()} nằm ngoài dải OpenClaw hỗ trợ`
5331
+ : 'Máy chưa có Node';
5332
+ sendLog(`[node] ${why} — đang cài Node ${NODE_TARGET_MAJOR} LTS...`);
5333
+
5334
+ if (process.platform === 'linux') {
5335
+ const root = typeof process.getuid === 'function' && process.getuid() === 0;
5336
+ const sudo = root ? '' : 'sudo ';
5337
+ // NodeSource covers deb/rpm and pins the major, so we land inside the supported window
5338
+ // instead of whatever "latest" happens to be that week.
5339
+ await run('sh', ['-c',
5340
+ `curl -fsSL https://deb.nodesource.com/setup_${NODE_TARGET_MAJOR}.x | ${sudo}bash - && ${sudo}apt-get install -y nodejs`,
5341
+ ]).catch(async () => {
5342
+ sendLog('[node] apt không dùng được — thử nvm...');
5343
+ await run('sh', ['-c',
5344
+ `export NVM_DIR="$HOME/.nvm"; curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash && . "$NVM_DIR/nvm.sh" && nvm install ${NODE_TARGET_MAJOR} && nvm alias default ${NODE_TARGET_MAJOR}`,
5345
+ ]);
5346
+ });
5347
+ } else if (process.platform === 'darwin') {
5348
+ const brew = await commandExists('brew', ['--version']);
5349
+ if (!brew.ok) throw httpError(400, 'macOS: cần Homebrew để tự cài Node. Cài tại https://brew.sh rồi thử lại.');
5350
+ await run('brew', ['install', `node@${NODE_TARGET_MAJOR}`]);
5351
+ await run('brew', ['link', '--overwrite', '--force', `node@${NODE_TARGET_MAJOR}`]).catch(() => {});
5352
+ } else if (process.platform === 'win32') {
5353
+ const winget = await commandExists('winget', ['--version']);
5354
+ const choco = await commandExists('choco', ['--version']);
5355
+ if (winget.ok) {
5356
+ await run('winget', ['install', '-e', '--id', `OpenJS.NodeJS.LTS`, '--accept-source-agreements', '--accept-package-agreements']);
5357
+ } else if (choco.ok) {
5358
+ await run('choco', ['install', 'nodejs-lts', '-y']);
5359
+ } else {
5360
+ throw httpError(400, `Windows: cần winget hoặc Chocolatey để tự cài Node (hoặc cài Node ${NODE_TARGET_MAJOR} LTS thủ công tại https://nodejs.org) rồi thử lại.`);
5361
+ }
5362
+ } else {
5363
+ throw httpError(400, `Hệ điều hành không tự cài Node được. Hãy cài Node ${NODE_TARGET_MAJOR} LTS thủ công rồi thử lại.`);
5364
+ }
5365
+
5366
+ // A fresh install lands on a PATH this process never picked up, so re-check through a new shell
5367
+ // rather than trusting our own env.
5368
+ const after = await commandExists('node', ['--version']);
5369
+ if (!after.ok || !nodeVersionSupported(after.output)) {
5370
+ throw httpError(500, `Đã cài Node nhưng phiên bản hiện tại (${after.output || 'không đọc được'}) vẫn ngoài dải OpenClaw hỗ trợ. Mở lại cửa sổ dòng lệnh (hoặc đăng xuất/đăng nhập lại) để PATH cập nhật, rồi cài lại.`);
5371
+ }
5372
+ sendLog(`[node] Node ${after.output.trim()} đã sẵn sàng.`);
5373
+ }
5374
+
5311
5375
  // Ensure Docker (engine + compose) is available before a docker-mode install, auto-installing the
5312
5376
  // latest version appropriate for the host OS when it is missing:
5313
5377
  // • Linux → Docker's official convenience script (get.docker.com; auto-detects the distro) + start daemon
@@ -5407,7 +5471,10 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
5407
5471
  // Make sure Docker is present (auto-install on Linux/VPS) before doing any work — fail fast
5408
5472
  // with a clear message rather than deep inside `docker compose up`. Native mode has no
5409
5473
  // container, so it skips this entirely (that is much of the point of choosing it).
5410
- if (mode !== 'native') await ensureDockerInstalled(osChoice);
5474
+ // Native has no image to carry a runtime, so the host's Node IS the runtime — get a supported
5475
+ // one on the machine before npm touches anything. Docker still needs the daemon instead.
5476
+ if (mode === 'native') await ensureNodeInstalled();
5477
+ else await ensureDockerInstalled(osChoice);
5411
5478
  await writeCoreProject({ projectDir, osChoice, mode, gatewayPort, routerPort, userTimezone });
5412
5479
  await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
5413
5480
  // The global runtime just changed — every version gate after this point (notably
@@ -5632,6 +5699,34 @@ async function discoverDockerBotProjectRoots() {
5632
5699
  // dir symlinked to it — without this filter the account home shows up as a phantom "root"
5633
5700
  // project tab after every refresh (measured on vps_c-thu). The native marker's `label` was
5634
5701
  // written from the ORIGINAL project dir, so a mismatch identifies the phantom.
5702
+ /**
5703
+ * Two paths can be the same project: a native install links the account home and the project dir
5704
+ * together, so `~/.openclaw` and `<project>/.openclaw` resolve to one directory on disk. Scanning
5705
+ * finds both and the dashboard then lists the SAME bots twice under two project names (seen on
5706
+ * win_kha as "bot" and "VT 2025", 4 identical bots online in each — 10/09/2026).
5707
+ *
5708
+ * Dedupe on where `.openclaw` really lands, and keep the entry that IS the real directory: the
5709
+ * link side is an artefact of the install layout, not somewhere the customer put a project.
5710
+ * Works whichever way the link points, which matters because the direction differs between a
5711
+ * fresh native install and one migrated over from docker.
5712
+ */
5713
+ function dedupeProjectsByRealState(dirs) {
5714
+ const byReal = new Map();
5715
+ for (const dir of dirs) {
5716
+ if (!dir) continue;
5717
+ const statePath = join(dir, '.openclaw');
5718
+ let real = statePath;
5719
+ let isLink = false;
5720
+ try { real = fs.realpathSync(statePath); } catch {}
5721
+ try { isLink = fs.lstatSync(statePath).isSymbolicLink(); } catch {}
5722
+ const key = resolve(real).toLowerCase();
5723
+ const prev = byReal.get(key);
5724
+ // First one wins, unless we already kept a link and now meet the real directory.
5725
+ if (!prev || (prev.isLink && !isLink)) byReal.set(key, { dir, isLink });
5726
+ }
5727
+ return [...byReal.values()].map((v) => v.dir);
5728
+ }
5729
+
5635
5730
  function isPhantomStateDirProject(dir) {
5636
5731
  try {
5637
5732
  const meta = readNativeMeta(dir);
@@ -5663,7 +5758,7 @@ async function discoverNativeProjectRoots(rootProjectDir) {
5663
5758
  if (rootProjectDir && existsSync(nativeMarkerPath(rootProjectDir)) && existsSync(join(rootProjectDir, '.openclaw', 'openclaw.json'))) {
5664
5759
  roots.add(resolve(rootProjectDir));
5665
5760
  }
5666
- return [...roots].filter((dir) => !isPhantomStateDirProject(dir));
5761
+ return dedupeProjectsByRealState([...roots].filter((dir) => !isPhantomStateDirProject(dir)));
5667
5762
  }
5668
5763
 
5669
5764
  async function findLatestProject(rootProjectDir) {
@@ -5718,7 +5813,10 @@ async function findLatestProject(rootProjectDir) {
5718
5813
  candidates.push({ dir: dr, mtimeMs: (st?.mtimeMs || 0) + 1e15 });
5719
5814
  }
5720
5815
  candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
5721
- return candidates[0]?.dir || null;
5816
+ // Drop the linked twin before picking a winner, or the account-home view of the same project
5817
+ // can outrank the project itself purely on mtime.
5818
+ const keep = new Set(dedupeProjectsByRealState(candidates.map((c) => c.dir)));
5819
+ return candidates.find((c) => keep.has(c.dir))?.dir || candidates[0]?.dir || null;
5722
5820
  }
5723
5821
 
5724
5822
  async function ensureProjectsLoaded(rootProjectDir) {
@@ -5729,7 +5827,10 @@ async function ensureProjectsLoaded(rootProjectDir) {
5729
5827
  try {
5730
5828
  const saved = JSON.parse(await fsp.readFile(file, 'utf8'));
5731
5829
  if (Array.isArray(saved?.projects)) {
5732
- state.projects = saved.projects;
5830
+ // Filter on the way IN: a linked twin already written to the state file would otherwise
5831
+ // survive every later dedupe, because the dashboard reads state.projects directly.
5832
+ const keep = new Set(dedupeProjectsByRealState(saved.projects.map((p) => p && p.projectDir)));
5833
+ state.projects = saved.projects.filter((p) => p && keep.has(p.projectDir));
5733
5834
  }
5734
5835
  } catch {}
5735
5836
  }
@@ -5774,6 +5875,11 @@ async function computeDiscoverProjects(rootProjectDir) {
5774
5875
  // Drop phantom state-dir "projects" that slipped into the saved list (e.g. the account
5775
5876
  // home after a 2026.8.x native state move) — and keep them out of the persisted state.
5776
5877
  state.projects = state.projects.filter((p) => !isPhantomStateDirProject(p.projectDir));
5878
+ // The label check above only catches projects that carry a marker with a mismatched label.
5879
+ // A linked twin without one still slips through and the dashboard shows the same bots under
5880
+ // two project names, both "online" — collapse those onto the real directory.
5881
+ const keepDirs = new Set(dedupeProjectsByRealState(state.projects.map((p) => p.projectDir)));
5882
+ state.projects = state.projects.filter((p) => keepDirs.has(p.projectDir));
5777
5883
 
5778
5884
  // In parallel: each buildProjectMeta runs runtime detection (docker calls, port probes), so a
5779
5885
  // handful of projects turned into seconds of dashboard load when this was a sequential loop.
@@ -6903,25 +7009,18 @@ async function handler(req, res, rootProjectDir) {
6903
7009
  // start-chrome-debug is the old path; kept so an already-open dashboard keeps working.
6904
7010
  return json(res, await startChromeDebug());
6905
7011
  }
6906
- // Host control: read/flip the switch and see which apps this machine offers. The bot does
6907
- // not come through here (the dashboard is loopback-only) it calls the bridge-bound
6908
- // service from ensureHostControl.
7012
+ // PC control: read or flip the switch. There is no service behind it any more - enabling
7013
+ // configures OpenClaw's own `computer`/`screen` tools and starts the node host that drives
7014
+ // the screen.
6909
7015
  if (url.pathname === '/api/host/control' && req.method === 'GET') {
6910
- // Target the SELECTED project (not the launch root), so host-control provisions the bot
6911
- // the operator is actually looking at a connected project can differ from rootProjectDir.
7016
+ // Target the SELECTED project (not the launch root), so this provisions the bot the
7017
+ // operator is actually looking at - a connected project can differ from rootProjectDir.
6912
7018
  const projectDir = await resolveProjectDir(rootProjectDir, {});
6913
7019
  const cfg = await readHostControlConfig(projectDir);
6914
7020
  return json(res, {
6915
7021
  ok: true,
6916
7022
  enabled: cfg.enabled,
6917
- port: HOST_CONTROL_PORT,
6918
- apps: Object.keys(cfg.apps || {}),
6919
- commands: Object.keys(cfg.commands || {}),
6920
- running: Boolean(_hostControlServer),
6921
7023
  native: isNativeProject(projectDir),
6922
- // What enabling will additionally grant, so the confirm dialog can spell it out.
6923
- grants: Object.keys(detectHostCapabilityCommands()),
6924
- codexApp: detectCodexApp(),
6925
7024
  });
6926
7025
  }
6927
7026
  if (url.pathname === '/api/host/control' && req.method === 'POST') {
@@ -6929,40 +7028,28 @@ async function handler(req, res, rootProjectDir) {
6929
7028
  const projectDir = await resolveProjectDir(rootProjectDir, body);
6930
7029
  const cfg = await readHostControlConfig(projectDir);
6931
7030
  if (typeof body.enabled === 'boolean') cfg.enabled = body.enabled;
6932
- if (body.apps && typeof body.apps === 'object') cfg.apps = body.apps;
6933
- if (body.commands && typeof body.commands === 'object') cfg.commands = body.commands;
6934
- // Turning PC control ON is the operator's explicit ask, so it is also where the screen
6935
- // capture / recording and node-script permissions get granted (opt out with grants:false).
6936
- const granted = cfg.enabled && body.grants !== false ? grantHostCapabilities(cfg) : [];
6937
- if (granted.length) sendLog(`[host-control] Đã cấp thêm quyền chạy: ${granted.join(', ')}.`);
7031
+ // Pressing this button means "let the bot drive this machine", and it delivers exactly that:
7032
+ // OpenClaw's own `computer` tool (screenshot -> click -> type -> drag). That needs four
7033
+ // things switched on together, and any one missing leaves the bot insisting it has no
7034
+ // permission: the tools allowed for agents, the cua-computer plugin, those two node commands
7035
+ // on the gateway's per-platform allowlist, and a node host advertising them.
7036
+ // Doing it here rather than in a side script matters: the node host must live in a real
7037
+ // interactive desktop session, and the operator pressing this button IS in one.
7038
+ const computerUse = await setComputerUse(projectDir, cfg.enabled).catch((e) => {
7039
+ sendLog(`[host-control] computer-use: ${e.message}`);
7040
+ return { ok: false, error: e.message };
7041
+ });
6938
7042
  await fsp.writeFile(hostControlConfigPath(projectDir), JSON.stringify(cfg, null, 2), 'utf8');
6939
- let started = { ok: false, reason: 'disabled' };
6940
- if (cfg.enabled) started = await ensureHostControl(projectDir);
6941
- // Always rewrite the workspace guidance: enabling adds the block (with the token),
6942
- // disabling strips it so a bot never keeps instructions for an endpoint now refusing.
7043
+ // Always rewrite the workspace guidance: enabling adds the block, disabling strips it so a
7044
+ // bot never keeps instructions for a capability it no longer has.
6943
7045
  await writeHostControlAccess(projectDir, cfg).catch(() => {});
6944
7046
  sendLog(`[host-control] ${cfg.enabled ? 'Đã BẬT' : 'Đã TẮT'} quyền điều khiển máy cho bot.`);
6945
- // Make sure the Codex desktop app can actually do GUI work, so `codex exec` is enough for
6946
- // the bot: install computer-use into the app and repair its MCP registration. Nothing is
6947
- // installed into the OpenClaw project and the gateway never restarts.
6948
- let codex = null;
6949
- if (cfg.enabled && body.codex !== false && (cfg.commands || {}).codex) {
6950
- const app = detectCodexApp();
6951
- codex = await ensureCodexComputerUsePlugin(app, detectCodexMarketplace())
6952
- .then((r) => ({ ...r, app }))
6953
- .catch((err) => ({ error: err.message, app }));
6954
- // The wrapper carries the sandbox flag, so a bot cannot get the invocation wrong.
6955
- codex.taskScript = await writeCodexTaskScript(projectDir, (cfg.commands || {}).codex).catch(() => '');
6956
- }
7047
+ if (cfg.enabled && computerUse?.ok) sendLog('[host-control] Bot thể chụp màn hình, click chuột, phím trên máy này.');
6957
7048
  return json(res, {
6958
7049
  ok: true,
6959
7050
  enabled: cfg.enabled,
6960
- started,
6961
- apps: Object.keys(cfg.apps || {}),
6962
- commands: Object.keys(cfg.commands || {}),
6963
- granted,
6964
7051
  native: isNativeProject(projectDir),
6965
- codex,
7052
+ computerUse,
6966
7053
  });
6967
7054
  }
6968
7055
  // Take the operator to the OS privacy pane PC control needs (screen recording, accessibility).
@@ -7419,9 +7506,6 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
7419
7506
  ensureReopenShortcut();
7420
7507
  if (openBrowser) openUrl(url);
7421
7508
  printRemoteAccessHint(port).catch(() => {});
7422
- // Bring the host-control service back up when the operator left it enabled, so the bot's
7423
- // saved instructions keep working across installer restarts.
7424
- ensureHostControl(projectDir).catch(() => {});
7425
7509
  // Warm the probes the first page load would otherwise wait on (project list, runtime versions,
7426
7510
  // public IP, Zalo status). They run while the browser is still starting, so the dashboard opens
7427
7511
  // against a warm cache instead of paying for docker and CLI round-trips on first paint.
@@ -7433,4 +7517,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
7433
7517
  ]).catch(() => {});
7434
7518
  }
7435
7519
 
7436
- export { patchBrowserAutomationHostPreference, debugChromeProfileDir, defaultChromeProfileDir, createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations, runHostCommand, detectHostCommands, detectHostCapabilityCommands, grantHostCapabilities, detectCodexApp, detectCodexMarketplace, resolveCodexCli, openPrivacyPane, projectDeployMode, isNativeProject, nativeServiceLabel, nativeEnv, ocArgv, migrateNativePaths, discoverNativeProjectRoots, detectOs, stripCliWarnings, migrationLeaseDeadline, ensureNativePlugins, findFreeHostPort, syncNativeServiceEnv, adoptStrayNativeHome, runNativeConfigMigrations, detectExistingSetupUi, hardenNativeServiceRestarts, clearNativeServiceFailure, describePortHolder, reportNativeGatewayBlockage };
7520
+ export { patchBrowserAutomationHostPreference, debugChromeProfileDir, defaultChromeProfileDir, createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations, openPrivacyPane, projectDeployMode, isNativeProject, nativeServiceLabel, nativeEnv, ocArgv, migrateNativePaths, discoverNativeProjectRoots, detectOs, stripCliWarnings, migrationLeaseDeadline, ensureNativePlugins, findFreeHostPort, syncNativeServiceEnv, adoptStrayNativeHome, runNativeConfigMigrations, detectExistingSetupUi, nodeVersionSupported, migrateDockerProjectToNative, dedupeProjectsByRealState, hardenNativeServiceRestarts, clearNativeServiceFailure, describePortHolder, reportNativeGatewayBlockage };