create-openclaw-bot 5.17.0 → 5.17.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,7 +39,7 @@ const CHROME_PROFILE_CACHE_DIRS = [
39
39
  ];
40
40
 
41
41
  // openclaw 2026.8.x replaced exec-approvals.json with shared SQLite state and BLOCKS every
42
- // message dispatch while the legacy file exists ("ExecApprovalsMigrationRequiredError"
42
+ // message dispatch while the legacy file exists ("ExecApprovalsMigrationRequiredError" -
43
43
  // measured on vps_c-thu 02/09/2026: the bot went silent with zero model calls, and doctor
44
44
  // refuses to migrate the file itself). The exec policy already lives in openclaw.json
45
45
  // (tools.exec), so on 2026.8+ we must neither write nor keep this file.
@@ -59,7 +59,7 @@ function invalidateHostOpenclawVersion() {
59
59
  async function hostOpenclawMajorMinor() {
60
60
  // Cache only a REAL version: during bot creation this is first called before
61
61
  // `npm i -g openclaw` has run, and caching that 0 forever made prepareNativeStateHome
62
- // skip the 2026.8 state-home move daemon install then failed with "non-default
62
+ // skip the 2026.8 state-home move - daemon install then failed with "non-default
63
63
  // state dir" (measured on a fresh native install, 03/09/2026).
64
64
  //
65
65
  // A real-but-STALE version is just as bad and does not self-heal: on a host that already
@@ -116,7 +116,7 @@ async function syncExecApprovals(projectDir, cfg) {
116
116
  /**
117
117
  * Write files into the bot container's plugin folder. Needed when `.openclaw/extensions` is a
118
118
  * Docker named volume: the host sees an empty directory, so there is nothing on disk to patch.
119
- * Best-effort a stopped container or a project without Docker just yields 0.
119
+ * Best-effort - a stopped container or a project without Docker just yields 0.
120
120
  */
121
121
  async function pushBrowserScriptsIntoContainer(projectDir, aliases, files, sendLog = () => {}) {
122
122
  if (isNativeProject(projectDir)) return 0;
@@ -138,7 +138,7 @@ async function pushBrowserScriptsIntoContainer(projectDir, aliases, files, sendL
138
138
  try {
139
139
  await fsp.writeFile(tmp, content, 'utf8');
140
140
  // runCapture, not run: run() forces a shell on Windows, and the temp path goes through
141
- // a home directory that usually has a space in it ("VT 2025") cmd then splits it and
141
+ // a home directory that usually has a space in it ("VT 2025") - cmd then splits it and
142
142
  // docker cp fails with a usage error.
143
143
  const cp = await runCapture('docker', ['cp', tmp, `${container}:${dir}/${name}`], { shell: false, timeout: 20000 });
144
144
  if (cp.code !== 0) throw new Error(String(cp.stderr || cp.stdout || 'docker cp failed').trim());
@@ -159,7 +159,7 @@ async function pushBrowserScriptsIntoContainer(projectDir, aliases, files, sendL
159
159
  /**
160
160
  * The browser-automation plugin defaults every high-impact behaviour to off (ClawHub's review reads
161
161
  * broad defaults as "rogue agent", fairly). A dashboard install is an explicit, informed action, so
162
- * the flags are turned on here and stay visible in openclaw.json for anyone who wants them off.
162
+ * the flags are turned on here - and stay visible in openclaw.json for anyone who wants them off.
163
163
  */
164
164
  function browserAutomationOptIns() {
165
165
  return { patchDocker: true, allowPageScripting: true, allowFileUpload: true };
@@ -217,7 +217,7 @@ async function connectPreferredChrome() {
217
217
  };
218
218
 
219
219
  // The shipped script is replaced outright rather than tweaked: it launches Chrome against a
220
- // throwaway profile under %TEMP%, which is the single clearest bot signal a site can read
220
+ // throwaway profile under %TEMP%, which is the single clearest bot signal a site can read -
221
221
  // no cookies, no logins, no history, no extensions, brand new on every run.
222
222
  //
223
223
  // The obvious fix, pointing --user-data-dir at the operator's real profile, is what earlier
@@ -229,7 +229,7 @@ async function connectPreferredChrome() {
229
229
  // So: a dedicated profile directory, seeded once from the real one. Cookies, logins,
230
230
  // history and extensions come along (that was the point of using the real profile), the
231
231
  // debug port is allowed because the directory is not the default one, and the operator's
232
- // own Chrome can keep running next to it. Set OPENCLAW_CHROME_PROFILE_DIR to override
232
+ // own Chrome can keep running next to it. Set OPENCLAW_CHROME_PROFILE_DIR to override -
233
233
  // anything except the default profile directory works.
234
234
  const chromeProfileCacheJunk = CHROME_PROFILE_CACHE_DIRS;
235
235
 
@@ -257,7 +257,7 @@ async function connectPreferredChrome() {
257
257
  'REM vi ban dieu khien dung profile rieng.',
258
258
  'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq \'chrome.exe\' -and $_.CommandLine -like \'*--remote-debugging-port=9222*\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }" >nul 2>&1',
259
259
  // `timeout` dies with "Input redirection is not supported" whenever stdin is not a
260
- // console which is every run from the dashboard, a scheduled task or SSH. ping waits
260
+ // console - which is every run from the dashboard, a scheduled task or SSH. ping waits
261
261
  // the same way and does not care.
262
262
  'ping -n 3 127.0.0.1 >nul',
263
263
  '',
@@ -375,8 +375,8 @@ async function connectPreferredChrome() {
375
375
  ].join('\n');
376
376
 
377
377
  // Scripts from before the dedicated-profile fix carry OPENCLAW_CHROME_PROFILE_DIR but point
378
- // it at the default profile, so they are dead on Chrome 136+. The marker not the variable
379
- // name decides whether a script is current; anything older is replaced.
378
+ // it at the default profile, so they are dead on Chrome 136+. The marker - not the variable
379
+ // name - decides whether a script is current; anything older is replaced.
380
380
  const patchChromeDebugScript = (content, isBat) => (
381
381
  content.includes(CHROME_SCRIPT_MARKER) ? content : (isBat ? startChromeBat : startChromeSh)
382
382
  );
@@ -454,7 +454,7 @@ async function connectPreferredChrome() {
454
454
  ['start-chrome-debug.sh', startChromeSh, '755'],
455
455
  ], sendLog);
456
456
  if (pushed > 0) {
457
- sendLog(`[browser] Extensions live in a Docker volume pushed ${pushed} start-chrome script(s) into the container so the plugin delivers the current one.`);
457
+ sendLog(`[browser] Extensions live in a Docker volume - pushed ${pushed} start-chrome script(s) into the container so the plugin delivers the current one.`);
458
458
  }
459
459
  }
460
460
 
@@ -478,7 +478,7 @@ Chrome launches with a profile copied from the operator's own on first run, so p
478
478
 
479
479
  The tool connects to whichever Chrome answers first: the operator's Chrome on the host, then a local one on \`127.0.0.1:9222\`. On a server with no desktop Chrome, the container starts its own headless Chromium there at boot, so the same commands work everywhere.
480
480
 
481
- **Use these commands, not OpenClaw's built-in \`browser\` tool** that tool is switched off here because it cannot read page text or links, which is the whole reason this skill exists. If a command reports it cannot connect, the operator's Chrome is not running: ask them to run the debug script above. Do not conclude that the environment has no browser.
481
+ **Use these commands, not OpenClaw's built-in \`browser\` tool** - that tool is switched off here because it cannot read page text or links, which is the whole reason this skill exists. If a command reports it cannot connect, the operator's Chrome is not running: ask them to run the debug script above. Do not conclude that the environment has no browser.
482
482
 
483
483
  ## Browser Commands
484
484
 
@@ -507,7 +507,7 @@ Do not call \`search-tool.js\`; browser-automation does not own search. Use \`we
507
507
  const hostOs = normalizeHostOs(await resolveProjectHostOs(projectDir));
508
508
  const shouldKeepBat = hostOs === 'win';
509
509
  // Shipped as start-chrome-debug.* by the plugin; delivered to the workspace as
510
- // start-chrome.* it launches Chrome with the debug port open, so "debug" in the name only
510
+ // start-chrome.* - it launches Chrome with the debug port open, so "debug" in the name only
511
511
  // ever made people think it was a developer-only thing.
512
512
  const scriptToKeep = shouldKeepBat ? 'start-chrome.bat' : 'start-chrome.sh';
513
513
  const legacyScripts = ['start-chrome-debug.bat', 'start-chrome-debug.sh', shouldKeepBat ? 'start-chrome.sh' : 'start-chrome.bat'];
@@ -568,7 +568,7 @@ async function fetchLatestSetupVersionBg() {
568
568
  if (isFetchingLatestSetup) return;
569
569
  isFetchingLatestSetup = true;
570
570
  try {
571
- // Distribution is GitHub (not npm) read the version straight from main so the
571
+ // Distribution is GitHub (not npm) - read the version straight from main so the
572
572
  // "latest" reflects `npx github:…`, not the stale last-published npm release.
573
573
  const resp = await fetch(
574
574
  'https://raw.githubusercontent.com/tuanminhhole/openclaw-setup/main/package.json',
@@ -646,7 +646,7 @@ function detectOs() {
646
646
  if (platform === 'linux') {
647
647
  // WSL always has a Windows desktop behind it, so it counts as a desktop. Otherwise a session
648
648
  // with no display server is a headless server, and the distinction is not cosmetic: 'vps' is
649
- // what opens the gateway bind past loopback (bot-config-gen) pick 'linux-desktop' on a VPS
649
+ // what opens the gateway bind past loopback (bot-config-gen) - pick 'linux-desktop' on a VPS
650
650
  // and the dashboard is only reachable through an SSH tunnel, while the browser tooling is set
651
651
  // up as though a local Chrome existed.
652
652
  if (os.release().toLowerCase().includes('microsoft')) return 'linux-desktop';
@@ -700,12 +700,12 @@ function getRealHomedir() {
700
700
  /**
701
701
  * Directories to search when a bare command is not sitting next to the running node.
702
702
  *
703
- * resolveBinPath's original assumption "the CLI lives beside the node that runs me" holds only
703
+ * resolveBinPath's original assumption - "the CLI lives beside the node that runs me" - holds only
704
704
  * while the Setup UI is launched by the same node that installed openclaw globally. It breaks the
705
705
  * moment the UI runs under the SYSTEM node while openclaw lives in an nvm prefix: `openclaw` stays a
706
706
  * bare name, spawn's own PATH lookup finds nothing either, and every skill/plugin install dies with
707
707
  * `spawn openclaw ENOENT`. Hit on a customer VPS 2026-08-28, right after the UI became a systemd
708
- * unit whose PATH carried only the system directories `openclaw` existed the whole time, at
708
+ * unit whose PATH carried only the system directories - `openclaw` existed the whole time, at
709
709
  * /root/.nvm/versions/node/v24.20.0/bin/openclaw.
710
710
  *
711
711
  * PATH first (so an operator's own choice still wins), then nvm's per-version bins newest first,
@@ -727,6 +727,19 @@ function extraBinDirs() {
727
727
  return dirs;
728
728
  }
729
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
+
730
743
  function resolveBinPath(cmd) {
731
744
  if (!cmd || cmd.includes('/') || cmd.includes('\\')) return cmd;
732
745
  const names = process.platform === 'win32' ? [`${cmd}.cmd`, `${cmd}.exe`, cmd] : [cmd];
@@ -751,8 +764,8 @@ function resolveBinPath(cmd) {
751
764
  *
752
765
  * Load-bearing for anything picked out of an nvm prefix: `openclaw` there is a shebang script
753
766
  * (`#!/usr/bin/env node`), so running it by absolute path still resolves `node` from the CHILD's
754
- * PATH. Without this the CLI is found and then executed by whatever node happens to be first
755
- * the system one which is not the node it was installed for.
767
+ * PATH. Without this the CLI is found and then executed by whatever node happens to be first -
768
+ * the system one - which is not the node it was installed for.
756
769
  */
757
770
  function binEnv(bin, extra = {}) {
758
771
  const env = { ...process.env, ...extra };
@@ -851,7 +864,7 @@ function globalNodeModulesDirs() {
851
864
  }
852
865
 
853
866
  // `9router --version` boots the whole CLI and takes ~4 SECONDS on a normal machine. /api/system
854
- // used to pay that on every single call and the UI calls it after every action, so the whole
867
+ // used to pay that on every single call - and the UI calls it after every action, so the whole
855
868
  // dashboard felt slow for one version string. The version is right there in package.json.
856
869
  function readGlobalPackageVersion(name) {
857
870
  for (const dir of globalNodeModulesDirs()) {
@@ -875,7 +888,7 @@ async function getCurrentRuntimeVersions() {
875
888
  nineRouter: readGlobalPackageVersion('9router'),
876
889
  node: process.version || '',
877
890
  };
878
- // Only shell out for what disk did not answer a global install in a prefix we do not know
891
+ // Only shell out for what disk did not answer - a global install in a prefix we do not know
879
892
  // about, mostly. Still cached, so an odd layout costs the slow probe once, not every request.
880
893
  const needCli = !fromDisk.openclaw || !fromDisk.nineRouter;
881
894
  if (needCli) {
@@ -1022,8 +1035,8 @@ function runCapture(cmd, args, opts = {}) {
1022
1035
  return new Promise((resolve) => {
1023
1036
  let stdout = '';
1024
1037
  let stderr = '';
1025
- const shell = opts.shell ?? process.platform === 'win32';
1026
1038
  const rawBin = resolveBinPath(cmd);
1039
+ const shell = needsWindowsShell(rawBin) || (opts.shell ?? process.platform === 'win32');
1027
1040
  const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
1028
1041
  const child = spawn(bin, args, {
1029
1042
  cwd: opts.cwd,
@@ -1217,7 +1230,7 @@ async function detectRuntime(projectDir) {
1217
1230
  // Ask projectDeployMode, don't re-derive from the compose file: a project migrated to native
1218
1231
  // KEEPS its docker/ folder on purpose (that is the way back), so "compose file exists" stops
1219
1232
  // meaning "runs on docker" the moment migration lands. Getting this wrong made the dashboard
1220
- // report OpenClaw and 9Router OFFLINE on a machine whose native gateway was answering 200
1233
+ // report OpenClaw and 9Router OFFLINE on a machine whose native gateway was answering 200 -
1221
1234
  // it was probing the stopped containers. Seen on win_kha, 10/09/2026.
1222
1235
  mode: projectDeployMode(projectDir),
1223
1236
  cliGatewayStatus,
@@ -1231,7 +1244,7 @@ async function detectRuntime(projectDir) {
1231
1244
 
1232
1245
  // Projects whose one-time migration + Docker-infra sync has already run this server lifetime.
1233
1246
  // The legacy-path migration, 9router-key resolution and Docker-file regeneration only need to
1234
- // happen once per project (or after an explicit update) not on every status poll. detectRuntime
1247
+ // happen once per project (or after an explicit update) - not on every status poll. detectRuntime
1235
1248
  // (cached) still refreshes ports/mode cheaply on each call so state stays current.
1236
1249
  const _runtimeSynced = new Set();
1237
1250
  async function syncRuntimeState(projectDir, { full = false } = {}) {
@@ -1263,7 +1276,7 @@ async function syncRuntimeState(projectDir, { full = false } = {}) {
1263
1276
  state.mode = state.mode || rt.mode;
1264
1277
  state.syncSource = rt.syncSource || 'config';
1265
1278
  state.installed = true;
1266
- // Auto-sync Docker files if outdated only on first sync (or forced); the version stamp gate
1279
+ // Auto-sync Docker files if outdated - only on first sync (or forced); the version stamp gate
1267
1280
  // inside syncDockerInfra already no-ops on matching versions, but skipping the call entirely
1268
1281
  // avoids the repeated file reads on every page load.
1269
1282
  if (firstSync && rt.mode === 'docker') {
@@ -1287,7 +1300,7 @@ async function removeEmptyWorkspaceAttestations(projectDir) {
1287
1300
  * Native counterpart of migrateContainerPaths. A native bot runs on the host with cwd = the
1288
1301
  * project dir, so any Docker/legacy container path baked into openclaw.json (e.g. an agent
1289
1302
  * `workspace` of "/home/node/project/.openclaw/workspace-x", left over from a bot created by an
1290
- * older build or carried over from Docker) points at a directory that does not exist on the host
1303
+ * older build or carried over from Docker) points at a directory that does not exist on the host -
1291
1304
  * the gateway then fails every turn with `ENOENT: mkdir '/home/node'` and the bot never replies.
1292
1305
  * Strip the container prefix so the path becomes project-relative (what bot-config-gen now emits).
1293
1306
  */
@@ -1300,7 +1313,7 @@ async function migrateNativePaths(projectDir) {
1300
1313
  // writes the workspace under projectDir/.openclaw/<name>. A relative value can't satisfy both:
1301
1314
  // ".openclaw/workspace-x" → runtime doubles it to .openclaw/.openclaw/workspace-x (blank persona)
1302
1315
  // "workspace-x" → setup's own resolver looks in projectDir/workspace-x
1303
- // Only an ABSOLUTE host path is correct for both the direct parallel of Docker's absolute
1316
+ // Only an ABSOLUTE host path is correct for both - the direct parallel of Docker's absolute
1304
1317
  // "/home/node/project/.openclaw/workspace-x". Normalise every agent's workspace to it.
1305
1318
  const wsRoot = join(projectDir, '.openclaw');
1306
1319
  let changed = false;
@@ -1311,7 +1324,16 @@ async function migrateNativePaths(projectDir) {
1311
1324
  const abs = join(wsRoot, base);
1312
1325
  if (obj.workspace !== abs) { obj.workspace = abs; changed = true; }
1313
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.
1314
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
+ }
1315
1337
  fix(cfg.agents?.defaults);
1316
1338
  if (changed) {
1317
1339
  await fsp.copyFile(cfgPath, `${cfgPath}.bak`).catch(() => {});
@@ -1464,7 +1486,7 @@ function ensureConfigShape(cfg) {
1464
1486
  // `agents.list` in the FILE (measured on vps_c-thu: doctor moves list→entries, then the
1465
1487
  // whole UI shows 0 bots because everything here reads .list). Bridge the two shapes:
1466
1488
  // hydrate a hidden .list view from entries, and expose .entries as a getter built from
1467
- // .list so JSON.stringify writes only schema-valid entries while every read/mutation
1489
+ // .list - so JSON.stringify writes only schema-valid entries while every read/mutation
1468
1490
  // path in this file keeps working on .list unchanged. Configs that still use a real
1469
1491
  // list (openclaw ≤2026.7 projects) keep the old behavior untouched.
1470
1492
  const rawEntries = (cfg.agents.entries && typeof cfg.agents.entries === 'object' && !Array.isArray(cfg.agents.entries))
@@ -1515,7 +1537,7 @@ function ensureConfigShape(cfg) {
1515
1537
  cfg.channels = cfg.channels || {};
1516
1538
  cfg.bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
1517
1539
  cfg.plugins = cfg.plugins || { entries: { 'memory-core': { config: { dreaming: { enabled: false } } } } };
1518
- // Preserve plugins.allow needed for external plugins such as Zalo Connect.
1540
+ // Preserve plugins.allow - needed for external plugins such as Zalo Connect.
1519
1541
  if (!cfg.plugins.allow) cfg.plugins.allow = [];
1520
1542
  cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
1521
1543
  return cfg;
@@ -1540,7 +1562,7 @@ function zaloBackendForConfig(cfg) {
1540
1562
  }
1541
1563
 
1542
1564
  function ensureZaloConnectChannel(cfg) {
1543
- // Secure defaults DM pairing, no groups enabled
1565
+ // Secure defaults - DM pairing, no groups enabled
1544
1566
  // until the owner picks them. Existing zalo-connect config is preserved as-is.
1545
1567
  cfg.channels['zalo-connect'] = cfg.channels['zalo-connect'] || buildZaloConnectChannelConfig();
1546
1568
  cfg.channels['zalo-connect'].enabled = true;
@@ -1586,7 +1608,7 @@ function ensureZaloModPluginConfig(entry, cfg) {
1586
1608
  entry.config.dashboardPort = gwPort + 1;
1587
1609
  }
1588
1610
  // Seed the default bot's identity under bots.default (per-bot shape). zalo-mod
1589
- // treats bots.<profile> as the canonical source do NOT write legacy top-level
1611
+ // treats bots.<profile> as the canonical source - do NOT write legacy top-level
1590
1612
  // botName/zaloDisplayNames (they get stripped by the plugin's normalizer anyway).
1591
1613
  const firstAgentName = cfg.agents?.list?.[0]?.name;
1592
1614
  if (firstAgentName) {
@@ -1741,6 +1763,52 @@ async function appendEnvValue(projectDir, key, value) {
1741
1763
  await fsp.writeFile(envPath, env, 'utf8');
1742
1764
  }
1743
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
+
1744
1812
  function validateOpenclawConfig(cfg) {
1745
1813
  if (!Array.isArray(cfg.agents?.list)) throw httpError(500, 'openclaw.json missing agents.list');
1746
1814
  for (const a of cfg.agents.list) {
@@ -1748,7 +1816,7 @@ function validateOpenclawConfig(cfg) {
1748
1816
  }
1749
1817
  if (!cfg.channels || typeof cfg.channels !== 'object') throw httpError(500, 'openclaw.json missing channels');
1750
1818
 
1751
- // Self-healing: two zalo-connect bindings can never share one account one Zalo number, one
1819
+ // Self-healing: two zalo-connect bindings can never share one account - one Zalo number, one
1752
1820
  // listener. An older edit path hardcoded `accountId: 'default'`, so an edited bot could end up
1753
1821
  // squatting the first bot's account while its own account sat unbound (no listener, and the
1754
1822
  // other bot answered in its place). Only repair where the intent is unambiguous: the agent
@@ -1764,7 +1832,7 @@ function validateOpenclawConfig(cfg) {
1764
1832
  if (owner.get(accountId) === agentId || !zaloAccounts[agentId]) continue;
1765
1833
  binding.match.accountId = agentId;
1766
1834
  owner.set(agentId, agentId);
1767
- sendLog(`[config] Bot "${agentId}" đang dùng chung account Zalo "${accountId}" với "${owner.get(accountId)}" đã trả về account riêng "${agentId}".`);
1835
+ sendLog(`[config] Bot "${agentId}" đang dùng chung account Zalo "${accountId}" với "${owner.get(accountId)}" - đã trả về account riêng "${agentId}".`);
1768
1836
  }
1769
1837
  }
1770
1838
 
@@ -1816,7 +1884,7 @@ function bindingChannelId(channel = '') {
1816
1884
  }
1817
1885
 
1818
1886
  // OpenClaw ≥2026.8 materialises a DEFAULT agent `main` ("Trợ lý OpenClaw") the first time a
1819
- // gateway boots against a roster it considers empty which is exactly what happens in Docker
1887
+ // gateway boots against a roster it considers empty - which is exactly what happens in Docker
1820
1888
  // mode, where `docker compose up` runs while the project still has zero bots (writeCoreProject
1821
1889
  // seeds agents with an empty list). The entry is legitimate OpenClaw state and is left alone in
1822
1890
  // openclaw.json, but it is NOT a bot the operator created: showing it next to the real bot made
@@ -1904,7 +1972,7 @@ async function deleteBotInProject(projectDir, agentId) {
1904
1972
  }
1905
1973
  if (cfg.channels?.telegram?.accounts?.[agentId]) delete cfg.channels.telegram.accounts[agentId];
1906
1974
 
1907
- // Drop any channel orphaned by this deletion no binding references it and it has no accounts
1975
+ // Drop any channel orphaned by this deletion - no binding references it and it has no accounts
1908
1976
  // (e.g. a Telegram channel whose only bot was just removed). An enabled channel with no account
1909
1977
  // keeps erroring in `channels status` ("not configured") and shows a broken card.
1910
1978
  const stillReferenced = new Set((cfg.bindings || []).map((b) => b.match?.channel).filter(Boolean));
@@ -1960,7 +2028,7 @@ function portStatus(port) {
1960
2028
  * listener. Docker tolerates that (compose publishes into loopback and fails loudly on a clash);
1961
2029
  * native binds the host directly, so it has to ask the host.
1962
2030
  *
1963
- * `reserveNext` also requires port+1 to be free that is where the zalo-mod dashboard lands.
2031
+ * `reserveNext` also requires port+1 to be free - that is where the zalo-mod dashboard lands.
1964
2032
  */
1965
2033
  async function findFreeHostPort(start, { reserveNext = false, limit = 100 } = {}) {
1966
2034
  for (let port = start; port < start + limit; port++) {
@@ -2069,7 +2137,7 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
2069
2137
  cfg.agents.list.push({
2070
2138
  id: agentId,
2071
2139
  name: botName,
2072
- // Relative workspace path resolves against the process cwd (project root) in both docker
2140
+ // Relative workspace path - resolves against the process cwd (project root) in both docker
2073
2141
  // and native. See buildOpenclawJson() for the full rationale.
2074
2142
  workspace: `.openclaw/${workspaceDir}`,
2075
2143
  agentDir: `agents/${agentId}/agent`,
@@ -2133,6 +2201,9 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
2133
2201
  // so the generator's ".openclaw/workspace-x" would double. Rewrite to an absolute path now so
2134
2202
  // the bot reads its persona on the very first turn (not only after the next runtime sync).
2135
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);
2136
2207
  await syncExecApprovals(projectDir, cfg);
2137
2208
 
2138
2209
  const hasScheduler = !!(cfg.tools?.alsoAllow || []).includes('group:automation');
@@ -2163,7 +2234,7 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
2163
2234
  // the token); don't pollute other channels' bot-meta.json with an empty appId.
2164
2235
  if (channel === 'fb-messenger') botMeta.appId = fbAppId;
2165
2236
  await writeBotMeta(projectDir, workspaceDir, botMeta);
2166
- // PC control is granted per PROJECT, so a bot added afterwards must get the same instructions
2237
+ // PC control is granted per PROJECT, so a bot added afterwards must get the same instructions -
2167
2238
  // its TOOLS.md was just written fresh and would otherwise have no host-control block at all.
2168
2239
  const hostCfg = await readHostControlConfig(projectDir).catch(() => null);
2169
2240
  if (hostCfg?.enabled) await writeHostControlAccess(projectDir, hostCfg).catch(() => {});
@@ -2196,7 +2267,7 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
2196
2267
  // The zalo-connect branch below used to hardcode 'default' (and this lookup only covered
2197
2268
  // telegram), so editing ANY Zalo bot re-pointed it at the FIRST bot's Zalo account: two
2198
2269
  // bindings claimed `default`, the earlier one won, and the edited bot's own account was left
2199
- // with no binding at all so its listener never started and messages were answered by the
2270
+ // with no binding at all - so its listener never started and messages were answered by the
2200
2271
  // other bot. Seen live on a 5-bot install (bot answered under the first bot's name).
2201
2272
  const previousBindings = (cfg.bindings || []).filter((b) => b.agentId === agentId);
2202
2273
  const previousAccountId = (ch) => previousBindings.find((b) => b.match?.channel === ch)?.match?.accountId || null;
@@ -2244,10 +2315,21 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
2244
2315
  }
2245
2316
 
2246
2317
  agent.name = botName;
2247
- 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.
2248
2326
  validateOpenclawConfig(cfg);
2249
2327
  if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
2250
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);
2251
2333
  await syncExecApprovals(projectDir, cfg);
2252
2334
 
2253
2335
  // Synchronize the token to .env files for the primary bot to ensure Docker picks it up
@@ -2341,7 +2423,7 @@ async function waitForDockerContainer(name, timeoutMs = 30000) {
2341
2423
  * The banner prints on EVERY invocation and quotes the offending config keys verbatim, so a project
2342
2424
  * whose zalo-connect plugin is missing has `channels.zalo-connect: unknown channel id: zalo-connect`
2343
2425
  * in the output of *any* command. A readiness check that greps stdout for a channel id therefore
2344
- * reports "channel loaded" precisely when the plugin is absent the check inverts itself. Strip the
2426
+ * reports "channel loaded" precisely when the plugin is absent - the check inverts itself. Strip the
2345
2427
  * warnings before matching so only real command output counts.
2346
2428
  */
2347
2429
  function stripCliWarnings(text = '') {
@@ -2391,7 +2473,7 @@ async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 900
2391
2473
  await new Promise((r) => setTimeout(r, 5000));
2392
2474
  }
2393
2475
  if (!ready) {
2394
- sendLog('[zalo-connect] Gateway readiness timeout after ' + Math.round(timeoutMs / 1000) + 's proceeding anyway.');
2476
+ sendLog('[zalo-connect] Gateway readiness timeout after ' + Math.round(timeoutMs / 1000) + 's - proceeding anyway.');
2395
2477
  }
2396
2478
  return ready;
2397
2479
  }
@@ -2411,7 +2493,7 @@ async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, chan
2411
2493
  // cannot possibly be loaded, so return right away and let the caller install it instead of
2412
2494
  // burning the whole timeout waiting for something that will never appear.
2413
2495
  if (!existsSync(extDir)) {
2414
- sendLog('[zalo-connect] Plugin folder .openclaw/extensions/zalo-connect is absent not waiting.');
2496
+ sendLog('[zalo-connect] Plugin folder .openclaw/extensions/zalo-connect is absent - not waiting.');
2415
2497
  return false;
2416
2498
  }
2417
2499
  if (await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500)) {
@@ -2424,7 +2506,7 @@ async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, chan
2424
2506
  }
2425
2507
  await new Promise((r) => setTimeout(r, 5000));
2426
2508
  }
2427
- if (!ready) sendLog('[zalo-connect] Native gateway readiness timeout after ' + Math.round(timeoutMs / 1000) + 's proceeding anyway.');
2509
+ if (!ready) sendLog('[zalo-connect] Native gateway readiness timeout after ' + Math.round(timeoutMs / 1000) + 's - proceeding anyway.');
2428
2510
  return ready;
2429
2511
  }
2430
2512
 
@@ -2439,13 +2521,13 @@ async function startZaloLogin(projectDir, agentId = "") {
2439
2521
  (!agentId || b.agentId === agentId) && b.match?.channel === "zalo-connect"
2440
2522
  );
2441
2523
  // A roster with more than one agent makes OpenClaw ≥2026.8 refuse any channel operation that
2442
- // cannot name its owner: `AgentSelectionRequiredError Multiple agents are configured, but
2524
+ // cannot name its owner: `AgentSelectionRequiredError - Multiple agents are configured, but
2443
2525
  // this operation has no explicit owner`. That is exactly what a Docker project looks like once
2444
2526
  // the gateway has materialised its default `main` agent alongside the real bot, and the QR
2445
2527
  // login dies with it (measured on vps_tracy-hong, 03/09).
2446
2528
  //
2447
2529
  // `openclaw channels login` has no --agent flag (2026.8.1: only --channel/--account/--verbose),
2448
- // and neither does the channels.start call it makes into the gateway so the owner can only be
2530
+ // and neither does the channels.start call it makes into the gateway - so the owner can only be
2449
2531
  // named the way OpenClaw's own error hint says: through a binding. Pin one to the real bot
2450
2532
  // before the QR starts. Additive: an existing binding is never rewritten, and the phantom
2451
2533
  // `main` is never the target.
@@ -2456,7 +2538,7 @@ async function startZaloLogin(projectDir, agentId = "") {
2456
2538
  binding = { agentId: target, match: { channel: 'zalo-connect', accountId: 'default' } };
2457
2539
  cfg.bindings = [...(cfg.bindings || []), binding];
2458
2540
  await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
2459
- sendLog(`[zalo-connect] No zalo-connect binding found pinned this QR login to agent [${target}] so OpenClaw knows who owns the channel.`);
2541
+ sendLog(`[zalo-connect] No zalo-connect binding found - pinned this QR login to agent [${target}] so OpenClaw knows who owns the channel.`);
2460
2542
  }
2461
2543
  }
2462
2544
  return startZaloConnectLogin(projectDir, binding?.match?.accountId || "default");
@@ -2467,7 +2549,7 @@ async function startZaloLogin(projectDir, agentId = "") {
2467
2549
  // container's tmpdir, announced with "QR image saved at: /tmp/zalo-connect-qr-<id>.png".
2468
2550
  // We watch stdout for that line, read the PNG out of the container, and push it to
2469
2551
  // the UI modal as a data URL ([zalo-connect:qr] log tag). Reconnect NEVER reinstalls the
2470
- // plugin install runs only when extensions/zalo-connect is absent, and always with the
2552
+ // plugin - install runs only when extensions/zalo-connect is absent, and always with the
2471
2553
  // pinned spec (never `latest`).
2472
2554
  async function startZaloConnectLogin(projectDir, accountId = 'default') {
2473
2555
  if (zaloLoginInFlight) {
@@ -2491,14 +2573,14 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2491
2573
  const gatewayReady = await waitForNativeGatewayZaloReady(projectDir, 180000);
2492
2574
  if (!gatewayReady) {
2493
2575
  // ensureNativePlugins is the single place that knows what a native project owes itself, and
2494
- // it skips whatever is already on disk so this covers learning-memory too, and reconnects
2576
+ // it skips whatever is already on disk - so this covers learning-memory too, and reconnects
2495
2577
  // on a healthy project cost nothing.
2496
2578
  const installed = await ensureNativePlugins(projectDir);
2497
2579
  if (installed.includes(ZALO_PLUGIN_ID)) {
2498
2580
  await restartNativeRuntime(projectDir).catch((err) => sendLog(`[native] restart skipped/failed: ${err.message}`));
2499
2581
  await waitForNativeGatewayZaloReady(projectDir, 180000);
2500
2582
  } else if (!existsSync(join(projectDir, '.openclaw', 'extensions', 'zalo-connect'))) {
2501
- sendLog('[zalo-connect] Cài plugin không thành công thử lại bằng nút "Đăng nhập Zalo".');
2583
+ sendLog('[zalo-connect] Cài plugin không thành công - thử lại bằng nút "Đăng nhập Zalo".');
2502
2584
  }
2503
2585
  }
2504
2586
  } else {
@@ -2510,23 +2592,23 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2510
2592
  // gateway is up but the plugin is genuinely absent (projects created before the
2511
2593
  // backend-aware entrypoint existed).
2512
2594
  const containerUp = await waitForDockerContainer(botContainer, 90000);
2513
- if (!containerUp) sendLog(`[zalo-connect] ${botContainer} chưa chạy sau 90s vẫn thử tiếp...`);
2595
+ if (!containerUp) sendLog(`[zalo-connect] ${botContainer} chưa chạy sau 90s - vẫn thử tiếp...`);
2514
2596
  const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000);
2515
2597
  if (!gatewayReady) {
2516
2598
  const check = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', '[ -d "${OPENCLAW_HOME:-/home/node/project/.openclaw}/extensions/zalo-connect" ] && echo OK || echo MISSING'], { cwd: projectDir, shell: false }).catch(() => ({ stdout: 'ERR' }));
2517
2599
  if (String(check.stdout || '').trim() === 'MISSING') {
2518
- sendLog(`[zalo-connect] Plugin missing installing ${ZALO_CONNECT_PLUGIN_SPEC}...`);
2600
+ sendLog(`[zalo-connect] Plugin missing - installing ${ZALO_CONNECT_PLUGIN_SPEC}...`);
2519
2601
  const installCmd = `cd /home/node/project && (openclaw plugins install ${ZALO_CONNECT_PLUGIN_SPEC} --force --accept-capabilities || openclaw plugins install ${ZALO_CONNECT_PLUGIN_SPEC} --force ${LEGACY_CLAWHUB_FLAG}) 2>&1`;
2520
2602
  const inst = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', installCmd], { cwd: projectDir, shell: false });
2521
2603
  const instOut = `${inst.stdout}\n${inst.stderr}`;
2522
2604
  for (const line of instOut.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
2523
2605
  if (/installed plugin/i.test(instOut)) {
2524
- // Gateway must reload to pick the plugin up safe here: the gateway is past
2606
+ // Gateway must reload to pick the plugin up - safe here: the gateway is past
2525
2607
  // its boot (we only reach this branch when it answered the exec above).
2526
2608
  await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
2527
2609
  await waitForGatewayZaloReady(botContainer, projectDir, 180000);
2528
2610
  } else {
2529
- sendLog('[zalo-connect] Cài plugin không thành công thử lại bằng nút "Đăng nhập Zalo" sau khi container ổn định.');
2611
+ sendLog('[zalo-connect] Cài plugin không thành công - thử lại bằng nút "Đăng nhập Zalo" sau khi container ổn định.');
2530
2612
  }
2531
2613
  }
2532
2614
  }
@@ -2544,7 +2626,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2544
2626
  const pushQr = async (pngPath) => {
2545
2627
  let b64 = '';
2546
2628
  if (native) {
2547
- // The CLI ran on the host, so the QR PNG is a real host path read it directly.
2629
+ // The CLI ran on the host, so the QR PNG is a real host path - read it directly.
2548
2630
  try {
2549
2631
  const st = await fsp.stat(pngPath);
2550
2632
  if (st.size > 100) b64 = (await fsp.readFile(pngPath)).toString('base64');
@@ -2604,7 +2686,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2604
2686
  zaloLoginInFlight = false;
2605
2687
  } else if (code !== 0 && !qrSent && !wasCancelled && attempt < MAX_ATTEMPTS) {
2606
2688
  const delay = RETRY_DELAYS[attempt] || 15000;
2607
- sendLog(`[zalo-connect] QR chưa sẵn sàng thử lại sau ${delay / 1000}s...`);
2689
+ sendLog(`[zalo-connect] QR chưa sẵn sàng - thử lại sau ${delay / 1000}s...`);
2608
2690
  setTimeout(runAttempt, delay);
2609
2691
  } else {
2610
2692
  if (!qrSent && !wasCancelled) sendLog('[zalo-connect] Login ended without a QR. Click "Đăng nhập Zalo" to retry.');
@@ -2742,7 +2824,7 @@ async function computeZaloHealth(projectDir) {
2742
2824
  meta.installedVersion = JSON.parse(await fsp.readFile(manifestHost, 'utf8')).version || null;
2743
2825
  } catch {
2744
2826
  // Only Docker projects keep the manifest inside a container. A native project has no
2745
- // container at all, and this fallback used to shell into one anyway seconds of waiting on
2827
+ // container at all, and this fallback used to shell into one anyway - seconds of waiting on
2746
2828
  // a `docker exec` that could never succeed, on a request the dashboard makes constantly.
2747
2829
  if (!native) {
2748
2830
  try {
@@ -2856,7 +2938,7 @@ function getBotServiceName(projectDir) {
2856
2938
  }
2857
2939
 
2858
2940
  // ═══════════════════════════════════════════════════════════════════════════════
2859
- // Native runtime openclaw + 9router straight on the host, no Docker
2941
+ // Native runtime - openclaw + 9router straight on the host, no Docker
2860
2942
  // ═══════════════════════════════════════════════════════════════════════════════
2861
2943
  // Two things replace the container:
2862
2944
  // 1. `docker exec <container> openclaw …` → `openclaw …` carrying the project env.
@@ -2871,14 +2953,14 @@ function getBotServiceName(projectDir) {
2871
2953
  const NATIVE_MARKER = 'native.json';
2872
2954
  // Native uses the same ports as everything else: openclaw's 18789 and 9router's 20128. It used to
2873
2955
  // jump a hundred above them unconditionally so it could sit next to a docker project, but that fired
2874
- // even on a machine with nothing running at all a fresh VPS still landed on 18889/20228, so every
2956
+ // even on a machine with nothing running at all - a fresh VPS still landed on 18889/20228, so every
2875
2957
  // tunnel command, bookmark and doc pointed at a port the user never chose. findFreeHostPort() now
2876
2958
  // handles coexistence by asking the host what is actually taken, which the fixed offset never did.
2877
2959
  const NATIVE_DEFAULT_GATEWAY_PORT = 18789;
2878
2960
  const NATIVE_DEFAULT_ROUTER_PORT = 20128;
2879
2961
  // The gateway's startup migrations hold a lease on the state directory for FIVE MINUTES, and a boot
2880
2962
  // that collides with it exits immediately instead of waiting. Any health wait shorter than that
2881
- // lease reports failure on a gateway that was always going to come up on its own the old 180s
2963
+ // lease reports failure on a gateway that was always going to come up on its own - the old 180s
2882
2964
  // could not outlast it even in the best case.
2883
2965
  const NATIVE_GATEWAY_HEALTH_TIMEOUT_MS = 420000;
2884
2966
 
@@ -2903,7 +2985,7 @@ function isNativeProject(projectDir) {
2903
2985
  return projectDeployMode(projectDir) === 'native';
2904
2986
  }
2905
2987
 
2906
- /** launchd label / systemd unit / scheduled-task name unique per project so installs coexist. */
2988
+ /** launchd label / systemd unit / scheduled-task name - unique per project so installs coexist. */
2907
2989
  function nativeServiceLabel(projectDir) {
2908
2990
  const meta = readNativeMeta(projectDir);
2909
2991
  if (meta && meta.label) return meta.label;
@@ -2915,7 +2997,7 @@ function nativeServiceLabel(projectDir) {
2915
2997
  function nativeEnv(projectDir, extra = {}) {
2916
2998
  const dir = projectDir || state.projectDir || '';
2917
2999
  // openclaw 2026.8.x fs-safe refuses atomic writes through a symlinked state dir
2918
- // ("Atomic replace parent must be a real directory") hand it the real path. Native
3000
+ // ("Atomic replace parent must be a real directory") - hand it the real path. Native
2919
3001
  // projects on 2026.8.x keep real state at ~/.openclaw with the project dir symlinked
2920
3002
  // (daemon install rejects a custom OPENCLAW_HOME), so this path IS a symlink there.
2921
3003
  let home = join(dir, '.openclaw');
@@ -2939,9 +3021,31 @@ function nativeEnv(projectDir, extra = {}) {
2939
3021
  };
2940
3022
  }
2941
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
+
2942
3042
  /** Resolve `openclaw <args>` for whichever runtime this project uses. */
2943
3043
  function ocArgv(projectDir, args) {
2944
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) } };
2945
3049
  return { cmd: 'openclaw', args, opts: { cwd: projectDir, env: nativeEnv(projectDir) } };
2946
3050
  }
2947
3051
  return { cmd: 'docker', args: ['exec', getBotContainerName(projectDir), 'openclaw', ...args], opts: { cwd: projectDir } };
@@ -2957,7 +3061,7 @@ function ocCapture(projectDir, args, opts = {}) {
2957
3061
  return runCapture(a.cmd, a.args, { shell: false, ...a.opts, ...opts, env: { ...(a.opts.env || {}), ...(opts.env || {}) } });
2958
3062
  }
2959
3063
 
2960
- // openclaw 2026.8.x renamed the ClawHub consent flags plugins: --accept-capabilities,
3064
+ // openclaw 2026.8.x renamed the ClawHub consent flags - plugins: --accept-capabilities,
2961
3065
  // skills: --acknowledge-install-policy-warning. 2026.7 and older only know
2962
3066
  // --acknowledge-clawhub-risk. Callers pass the NEW flag; when the CLI rejects it
2963
3067
  // ("does not recognize option") retry once with the legacy flag so an updated setup can
@@ -3007,7 +3111,7 @@ async function waitForNativeGatewayHealthy(projectDir, timeoutMs = 120000) {
3007
3111
  * The first gateway boot runs OpenClaw's startup migrations under a state-directory lease, and a
3008
3112
  * second gateway that tries to start meanwhile exits 1 with this message rather than waiting. The
3009
3113
  * docker path sidesteps it by never poking a booting container (see startZaloConnectLogin); when we
3010
- * do hit it natively, the message carries the exact instant the lease frees so wait that out
3114
+ * do hit it natively, the message carries the exact instant the lease frees - so wait that out
3011
3115
  * instead of retrying blind into systemd's StartLimitBurst (5 per 60s, after which the unit is
3012
3116
  * abandoned for good).
3013
3117
  */
@@ -3023,7 +3127,7 @@ async function ocDaemon(projectDir, verb, extraArgs = []) {
3023
3127
  const args = ['daemon', verb, ...extraArgs];
3024
3128
  sendLog(`$ openclaw ${args.join(' ')}`);
3025
3129
  // openclaw 2026.8.x refuses EVERY `daemon *` verb (not just install) while OPENCLAW_HOME is
3026
- // set "service management skipped: non-default state dir" (measured 03/09/2026: the
3130
+ // set - "service management skipped: non-default state dir" (measured 03/09/2026: the
3027
3131
  // post-plugin-install restart failed on a fresh native host). On the 2026.8 layout the
3028
3132
  // state already lives at ~/.openclaw, so run daemon verbs with the plain account HOME and
3029
3133
  // no OPENCLAW_* overrides; STOP even suggests --force for the operator gateway, so pass it.
@@ -3052,11 +3156,11 @@ async function ocDaemon(projectDir, verb, extraArgs = []) {
3052
3156
  *
3053
3157
  * Health is confirmed over /health at the end rather than trusted from the CLI's exit code: the
3054
3158
  * CLI gives up verifying after ~13s while the generated unit allows 30s to start, so a slow but
3055
- * perfectly healthy gateway reports "restart failed" which used to send callers down a pointless
3159
+ * perfectly healthy gateway reports "restart failed" - which used to send callers down a pointless
3056
3160
  * stop+start that raced the migration lease all over again.
3057
3161
  */
3058
3162
  /**
3059
- * Docker projects replay the idempotent config migrations on every container start the
3163
+ * Docker projects replay the idempotent config migrations on every container start - the
3060
3164
  * entrypoint embeds contextDefaultsScript. Native projects have no entrypoint, so an existing
3061
3165
  * native bot never received those fixes (a bot created before 5.16.0 kept the smart-route
3062
3166
  * contextWindow at 200000 and stayed exposed to the compaction deadlock). Replaying the exact
@@ -3078,9 +3182,9 @@ async function runNativeConfigMigrations(projectDir) {
3078
3182
  * OpenClaw 2026.9 từ chối khởi động khi workspace còn ở dạng cũ:
3079
3183
  * "Gateway failed to start: Legacy workspace setup state requires migration for
3080
3184
  * …/workspace-<agent>; run openclaw doctor --fix."
3081
- * Đo trên vps_thuy-le 07/09 bot chết cho tới khi chạy tay `doctor --fix`. Việc dọn này chỉ
3185
+ * Đo trên vps_thuy-le 07/09 - bot chết cho tới khi chạy tay `doctor --fix`. Việc dọn này chỉ
3082
3186
  * OpenClaw biết cách làm, nên gọi thẳng nó trước mỗi lần (re)start thay vì tự đoán rồi sửa mò.
3083
- * Chỉ chạy trên 2026.9+ (bản cũ không có phép migrate đó), và lỗi ở đây KHÔNG chặn boot
3187
+ * Chỉ chạy trên 2026.9+ (bản cũ không có phép migrate đó), và lỗi ở đây KHÔNG chặn boot -
3084
3188
  * gateway vẫn được thử khởi động, cùng lắm là báo đúng lỗi cũ.
3085
3189
  */
3086
3190
  async function runOpenclawDoctorFixIfNeeded(projectDir) {
@@ -3094,8 +3198,52 @@ async function runOpenclawDoctorFixIfNeeded(projectDir) {
3094
3198
  }
3095
3199
  }
3096
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
+
3097
3245
  async function restartNativeRuntime(projectDir) {
3098
- // Every restart is a chance to repair a project installed before these fixes existed the
3246
+ // Every restart is a chance to repair a project installed before these fixes existed - the
3099
3247
  // calls are no-ops once the service env is complete, stray files are adopted, and the config
3100
3248
  // already carries the migrated defaults.
3101
3249
  await adoptStrayNativeHome(projectDir).catch(() => {});
@@ -3109,6 +3257,18 @@ async function restartNativeRuntime(projectDir) {
3109
3257
  };
3110
3258
  let res;
3111
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.');
3112
3272
  res = await stopStart();
3113
3273
  } else {
3114
3274
  res = await ocDaemon(projectDir, 'restart');
@@ -3122,14 +3282,14 @@ async function restartNativeRuntime(projectDir) {
3122
3282
  const deadline = migrationLeaseDeadline(res.text);
3123
3283
  if (deadline) {
3124
3284
  const waitMs = Math.max(0, Math.min(deadline - Date.now(), 300000)) + 3000;
3125
- sendLog(`[native] Startup migrations hold the state lease waiting ${Math.ceil(waitMs / 1000)}s before retrying.`);
3285
+ sendLog(`[native] Startup migrations hold the state lease - waiting ${Math.ceil(waitMs / 1000)}s before retrying.`);
3126
3286
  await new Promise((r) => setTimeout(r, waitMs));
3127
3287
  res = await ocDaemon(projectDir, 'restart');
3128
3288
  if (res.code !== 0) res = await stopStart();
3129
3289
  }
3130
3290
  // With the start limit lifted (hardenNativeServiceRestarts) systemd really does keep restarting a
3131
3291
  // crash-looping unit every RestartSec, so a gateway blocked by a lease we never saw still comes up
3132
- // on its own give it the full lease window before calling the restart a failure.
3292
+ // on its own - give it the full lease window before calling the restart a failure.
3133
3293
  if (!(await waitForNativeGatewayHealthy(projectDir, NATIVE_GATEWAY_HEALTH_TIMEOUT_MS))) {
3134
3294
  throw new Error('gateway did not answer /health after restart');
3135
3295
  }
@@ -3140,7 +3300,7 @@ async function restartNativeRuntime(projectDir) {
3140
3300
  * Make the generated service carry everything nativeEnv() promises.
3141
3301
  *
3142
3302
  * `openclaw daemon install` propagates only a fixed allow-list into the service it writes:
3143
- * OPENCLAW_STATE_DIR survives, but OPENCLAW_HOME does NOT verified on both a systemd user unit and
3303
+ * OPENCLAW_STATE_DIR survives, but OPENCLAW_HOME does NOT - verified on both a systemd user unit and
3144
3304
  * a launchd env-wrapper. Anything resolving paths from OPENCLAW_HOME then falls back to `~/.openclaw`
3145
3305
  * and writes OUTSIDE the project. zalo-connect is the visible casualty: it stages inbound files and
3146
3306
  * its Zalo session credentials under the wrong home, so a PDF sent to the bot lands somewhere the
@@ -3204,7 +3364,7 @@ async function syncNativeServiceEnv(projectDir) {
3204
3364
  * Reunite a native project with the files an unset OPENCLAW_HOME scattered into `~/.openclaw`.
3205
3365
  *
3206
3366
  * This MUST run before syncNativeServiceEnv takes effect: once OPENCLAW_HOME is finally correct, the
3207
- * plugin looks for its Zalo session inside the project and if the credentials are still sitting in
3367
+ * plugin looks for its Zalo session inside the project - and if the credentials are still sitting in
3208
3368
  * the home directory it finds nothing and demands a fresh QR login. Copy (never move) so a failed
3209
3369
  * run leaves the working original in place; skip anything the project already has.
3210
3370
  */
@@ -3234,7 +3394,7 @@ async function adoptStrayNativeHome(projectDir) {
3234
3394
 
3235
3395
  /**
3236
3396
  * `openclaw daemon install` has no `--system` flag, so on Linux the gateway becomes a systemd USER
3237
- * unit and a user manager without linger is torn down when that user's last session exits. On a
3397
+ * unit - and a user manager without linger is torn down when that user's last session exits. On a
3238
3398
  * desktop the graphical session holds it open, which is why this never showed up on macOS or a
3239
3399
  * Linux desktop; on a VPS the bot dies the moment the operator closes SSH and never comes back
3240
3400
  * after a reboot. Linger is what makes a user unit behave like the `restart: always` container it
@@ -3249,7 +3409,7 @@ async function ensureSystemdLinger() {
3249
3409
  if (/Linger=yes/i.test(cur.stdout || '')) return true;
3250
3410
  const out = await runCapture('loginctl', ['enable-linger', user], { shell: false, timeout: 20000 });
3251
3411
  if (out.code === 0) {
3252
- sendLog(`[native] systemd linger enabled for "${user}" the gateway now survives logout and reboot.`);
3412
+ sendLog(`[native] systemd linger enabled for "${user}" - the gateway now survives logout and reboot.`);
3253
3413
  return true;
3254
3414
  }
3255
3415
  sendLog(`[native] WARNING: could not enable systemd linger for "${user}" (${(out.stderr || out.stdout || '').trim() || `exit ${out.code}`}).`);
@@ -3262,7 +3422,7 @@ async function ensureSystemdLinger() {
3262
3422
  *
3263
3423
  * `openclaw daemon install` writes `StartLimitBurst=5` / `StartLimitIntervalSec=60` next to
3264
3424
  * `Restart=always`. The gateway's startup migrations take a lease on the state directory that lasts
3265
- * five minutes, and a boot that collides with it exits 1 *immediately* so five collisions burn the
3425
+ * five minutes, and a boot that collides with it exits 1 *immediately* - so five collisions burn the
3266
3426
  * whole limit in 30 seconds, systemd logs "Start request repeated too quickly", and the unit stays
3267
3427
  * dead for good even though the lease frees itself minutes later. Verified on a fresh Ubuntu 24.04
3268
3428
  * VPS (2026-08-28): the setup UI sat on "Waiting for gateway on 18789..." until it timed out, while
@@ -3277,7 +3437,7 @@ async function hardenNativeServiceRestarts(projectDir) {
3277
3437
  const dir = join(os.homedir(), '.config', 'systemd', 'user', `${unit}.d`);
3278
3438
  const file = join(dir, '99-openclaw-setup.conf');
3279
3439
  const body = [
3280
- '# Written by openclaw-setup regenerated on every install, do not edit.',
3440
+ '# Written by openclaw-setup - regenerated on every install, do not edit.',
3281
3441
  '# Startup migrations hold the state lease for ~5 minutes and a colliding boot exits at once, so',
3282
3442
  '# the stock StartLimitBurst=5/StartLimitIntervalSec=60 abandons the unit for good within 30s.',
3283
3443
  '# No start limit = systemd keeps retrying every RestartSec until the lease frees itself.',
@@ -3297,7 +3457,7 @@ async function hardenNativeServiceRestarts(projectDir) {
3297
3457
 
3298
3458
  /**
3299
3459
  * A unit parked at "Start request repeated too quickly" refuses every later `start` until its
3300
- * failure is cleared including the one that would finally succeed. Cheap and idempotent, so it
3460
+ * failure is cleared - including the one that would finally succeed. Cheap and idempotent, so it
3301
3461
  * runs before each start and also repairs a project abandoned by an earlier install.
3302
3462
  */
3303
3463
  async function clearNativeServiceFailure(projectDir) {
@@ -3315,7 +3475,7 @@ async function clearNativeServiceFailure(projectDir) {
3315
3475
  *
3316
3476
  * The trap this exists for: an operator runs the port-forward command out of `ssh <host>-setup`
3317
3477
  * (`ssh -L 18789:127.0.0.1:18789 root@<host>`) while already logged INTO that host instead of from
3318
- * their own machine. ssh binds 18789 locally and forwards it to 18789 on the same box a self-loop
3478
+ * their own machine. ssh binds 18789 locally and forwards it to 18789 on the same box - a self-loop
3319
3479
  * that accepts every connection and answers nothing. The gateway can then never bind its port, and
3320
3480
  * health probes hang instead of failing, so the port looks alive and is useless. Seen on a customer
3321
3481
  * VPS 2026-08-28. `ss` (iproute2) is used on Linux rather than `lsof`, which a minimal Ubuntu lacks.
@@ -3343,7 +3503,7 @@ async function describePortHolder(port) {
3343
3503
  *
3344
3504
  * Three causes, all indistinguishable from "still booting" without this: an ssh port-forward
3345
3505
  * self-loop holding the port (describePortHolder), the migration lease plus systemd's start limit
3346
- * leaving the unit dead for good (hardenNativeServiceRestarts), or an ordinary crash on boot for
3506
+ * leaving the unit dead for good (hardenNativeServiceRestarts), or an ordinary crash on boot - for
3347
3507
  * which the unit's own journal is the only thing that ever says so.
3348
3508
  */
3349
3509
  async function reportNativeGatewayBlockage(projectDir, port) {
@@ -3351,7 +3511,7 @@ async function reportNativeGatewayBlockage(projectDir, port) {
3351
3511
  if (holder) {
3352
3512
  sendLog(`[native] Port ${port} is already held by → ${holder}`);
3353
3513
  if (/(^|\/|\s)ssh(\s|$)/.test(holder) && new RegExp(`-L\\s*\\d*:?${port}:`).test(holder)) {
3354
- sendLog(`[native] That is an SSH port-forward, not the gateway. \`ssh -L ${port}:...\` belongs on YOUR OWN machine run it on the server and it steals port ${port} from the gateway and answers nothing. Kill that ssh process on the server, then start the gateway again.`);
3514
+ sendLog(`[native] That is an SSH port-forward, not the gateway. \`ssh -L ${port}:...\` belongs on YOUR OWN machine - run it on the server and it steals port ${port} from the gateway and answers nothing. Kill that ssh process on the server, then start the gateway again.`);
3355
3515
  }
3356
3516
  }
3357
3517
  if (process.platform !== 'linux' || !isNativeProject(projectDir)) return;
@@ -3369,14 +3529,14 @@ async function reportNativeGatewayBlockage(projectDir, port) {
3369
3529
  * A container reinstalls its missing plugins on every boot; a native project has no entrypoint, so
3370
3530
  * nothing ever put zalo-connect or learning-memory on disk. The generated config declares both
3371
3531
  * anyway (bot-config-gen writes plugins.entries + allow + slots.contextEngine), so without this the
3372
- * gateway boots with "plugin not found" warnings, `channels.zalo-connect` has no owner Zalo login
3373
- * fails with `Unsupported channel "zalo-connect"` and the bot silently runs with no context
3532
+ * gateway boots with "plugin not found" warnings, `channels.zalo-connect` has no owner - Zalo login
3533
+ * fails with `Unsupported channel "zalo-connect"` - and the bot silently runs with no context
3374
3534
  * engine at all. Same set and same skip-if-present cheapness as ensure_plugin.
3375
3535
  */
3376
3536
  async function ensureNativePlugins(projectDir, { restart = false } = {}) {
3377
3537
  if (!isNativeProject(projectDir)) return [];
3378
3538
  // Same cleanup the container entrypoint does (docker-gen.js): an interrupted `plugins install`
3379
- // leaves extensions/.openclaw-install-stage-XXXXXX behind, and it still carries a plugin manifest
3539
+ // leaves extensions/.openclaw-install-stage-XXXXXX behind, and it still carries a plugin manifest -
3380
3540
  // so the gateway logs "duplicate plugin id detected" every boot and a stale build competes with the
3381
3541
  // real one for the same id. Native has no entrypoint, so it has to happen here.
3382
3542
  const extRoot = join(projectDir, '.openclaw', 'extensions');
@@ -3392,7 +3552,7 @@ async function ensureNativePlugins(projectDir, { restart = false } = {}) {
3392
3552
  const wanted = new Set(['learning-memory']);
3393
3553
  if (cfg?.channels?.[ZALO_CHANNEL_ID] || cfg?.plugins?.entries?.[ZALO_PLUGIN_ID]) wanted.add(ZALO_PLUGIN_ID);
3394
3554
  // openclaw >=2026.8 unbundled duckduckgo AND refuses to report ready while the config
3395
- // declares a plugin that lacks capability consent the docker entrypoint ensures it,
3555
+ // declares a plugin that lacks capability consent - the docker entrypoint ensures it,
3396
3556
  // native must too (measured 03/09/2026: fresh install crash-looped on this).
3397
3557
  if (cfg?.plugins?.entries?.duckduckgo) wanted.add('duckduckgo');
3398
3558
  const installed = [];
@@ -3405,7 +3565,7 @@ async function ensureNativePlugins(projectDir, { restart = false } = {}) {
3405
3565
  const text = `${out.stdout || ''}\n${out.stderr || ''}`;
3406
3566
  for (const line of text.split(/\r?\n/).map((l) => l.trimEnd()).filter(Boolean)) sendLog(`[native] ${line}`);
3407
3567
  if (existsSync(dir) || /installed plugin/i.test(text)) installed.push(id);
3408
- else sendLog(`[native] WARNING: could not install plugin ${id} the bot will run without it.`);
3568
+ else sendLog(`[native] WARNING: could not install plugin ${id} - the bot will run without it.`);
3409
3569
  }
3410
3570
  if (installed.length && restart) {
3411
3571
  sendLog(`[native] Restarting gateway to load: ${installed.join(', ')}`);
@@ -3454,7 +3614,7 @@ async function probeHttpOk(url, timeoutMs = 2000) {
3454
3614
  /**
3455
3615
  * Start 9router for a native project. Bound to loopback on purpose: openclaw talks to it over
3456
3616
  * localhost (see get9RouterBaseUrl), so exposing the LLM proxy on 0.0.0.0 would only create an
3457
- * open relay on a VPS that is a real risk. Data lives in the project so projects stay separate.
3617
+ * open relay - on a VPS that is a real risk. Data lives in the project so projects stay separate.
3458
3618
  */
3459
3619
  async function startNative9Router(projectDir, { restart = false } = {}) {
3460
3620
  const meta = readNativeMeta(projectDir) || {};
@@ -3469,7 +3629,7 @@ async function startNative9Router(projectDir, { restart = false } = {}) {
3469
3629
  return routerPort;
3470
3630
  }
3471
3631
  // Linux: run 9router as a systemd USER unit, not a detached child. startDetached leaves the
3472
- // process inside THIS server's cgroup restarting the setup-ui service (or rebooting the VPS)
3632
+ // process inside THIS server's cgroup - restarting the setup-ui service (or rebooting the VPS)
3473
3633
  // silently killed 9router and the bot lost its model (measured on vps_c-thu, 02/09/2026).
3474
3634
  // macOS/Windows keep the detached process: no deployed native host runs there yet, and each
3475
3635
  // would need its own service wrapper (launchd/Task Scheduler).
@@ -3520,12 +3680,12 @@ async function installNative9RouterUnit(projectDir, routerPort, dataDir) {
3520
3680
 
3521
3681
  /**
3522
3682
  * openclaw 2026.8.x `daemon install` only manages the service when the state dir is the
3523
- * canonical <account home>/.openclaw a custom OPENCLAW_HOME is refused, and its fs-safe
3683
+ * canonical <account home>/.openclaw - a custom OPENCLAW_HOME is refused, and its fs-safe
3524
3684
  * layer refuses atomic writes through a symlinked state dir ("parent must be a real
3525
3685
  * directory"). Measured on vps_c-thu 02/09/2026. So for 2026.8+ the REAL state lives at
3526
3686
  * ~/.openclaw and <project>/.openclaw becomes a symlink to it (junction on Windows, so no
3527
3687
  * admin rights needed). nativeEnv() realpaths the symlink, so every other CLI call keeps
3528
- * working. One native project per OS account which is how every deployed host runs.
3688
+ * working. One native project per OS account - which is how every deployed host runs.
3529
3689
  * Returns the env for `daemon install` (plain HOME, no OPENCLAW_* overrides), or null when
3530
3690
  * the runtime is older than 2026.8 and nothing should change.
3531
3691
  */
@@ -3546,7 +3706,7 @@ async function prepareNativeStateHome(projectDir) {
3546
3706
  try {
3547
3707
  if (resolve(fs.realpathSync(projState)) === resolve(homeState)) return installEnv;
3548
3708
  } catch {}
3549
- throw new Error(`${projState} là symlink nhưng không trỏ về ${homeState} kiểm tay trước khi cài service`);
3709
+ throw new Error(`${projState} là symlink nhưng không trỏ về ${homeState} - kiểm tay trước khi cài service`);
3550
3710
  }
3551
3711
  const homeExists = existsSync(homeState) || (() => { try { fs.lstatSync(homeState); return true; } catch { return false; } })();
3552
3712
  if (homeExists) {
@@ -3555,8 +3715,8 @@ async function prepareNativeStateHome(projectDir) {
3555
3715
  // Reverse layout from an earlier hand-fix attempt: drop the link, the real dir moves in below.
3556
3716
  fs.unlinkSync(homeState);
3557
3717
  } else if (existsSync(join(homeState, 'openclaw.json'))) {
3558
- // A real, populated state dir that is not this project's do not touch someone's data.
3559
- throw new Error(`${homeState} đã chứa state của một project khác 2026.8 chỉ hỗ trợ MỘT project native mỗi tài khoản`);
3718
+ // A real, populated state dir that is not this project's - do not touch someone's data.
3719
+ throw new Error(`${homeState} đã chứa state của một project khác - 2026.8 chỉ hỗ trợ MỘT project native mỗi tài khoản`);
3560
3720
  } else {
3561
3721
  // Stray/partial dir (stale CLI runs create these): park it, keep nothing in the way.
3562
3722
  const parked = `${homeState}.bak-stray-${Date.now()}`;
@@ -3574,8 +3734,8 @@ async function prepareNativeStateHome(projectDir) {
3574
3734
  * Turn OpenClaw's real computer-use on or off for a project.
3575
3735
  *
3576
3736
  * Three pieces must line up, and a bot with only two of them reports "I don't have permission"
3577
- * without saying which one is missing that ambiguity cost a whole debugging session:
3578
- * 1. `computer` (+ `screen`) allowed in tools.alsoAllow otherwise the agent never receives
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
3579
3739
  * the tool at all, no matter what else is configured;
3580
3740
  * 2. the `cua-computer` plugin, which is MANDATORY on Windows and ships disabled. It also has
3581
3741
  * to be on plugins.allow, or enabling is refused with "blocked by allowlist";
@@ -3583,11 +3743,17 @@ async function prepareNativeStateHome(projectDir) {
3583
3743
  * model-facing surface; the node is what actually touches the screen, which is why a
3584
3744
  * correctly configured tool still does nothing on its own.
3585
3745
  *
3586
- * The node host must run inside a real interactive desktop session started from a service or
3746
+ * The node host must run inside a real interactive desktop session - started from a service or
3587
3747
  * an SSH session it either dies with the session or cannot see a desktop at all. Launching it
3588
3748
  * from here works because the operator pressing the button is sitting at that desktop.
3589
3749
  */
3590
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
+ }
3591
3757
  const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
3592
3758
  if (!existsSync(cfgPath)) return { ok: false, error: 'openclaw.json not found' };
3593
3759
  const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
@@ -3601,43 +3767,265 @@ async function setComputerUse(projectDir, enable) {
3601
3767
  if (enable) {
3602
3768
  allow.add('computer');
3603
3769
  allow.add('screen');
3770
+ // Without this, `plugins enable` is refused outright with "blocked by allowlist".
3604
3771
  if (!cfg.plugins.allow.includes('cua-computer')) cfg.plugins.allow.push('cua-computer');
3605
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];
3606
3787
  } else {
3607
3788
  allow.delete('computer');
3608
3789
  allow.delete('screen');
3609
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');
3610
3798
  }
3611
3799
  cfg.tools.alsoAllow = [...allow];
3612
3800
  await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
3613
3801
 
3614
3802
  if (!enable) {
3615
3803
  await stopNodeHost().catch(() => {});
3616
- sendLog('[computer-use] Đã tắt: gỡ tool computer/screen và dừng node điều khiển.');
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.');
3617
3806
  return { ok: true, enabled: false };
3618
3807
  }
3619
3808
 
3620
- sendLog('[computer-use] Đã bật tool computer + plugin cua-computer. Đang khởi động node điều khiển...');
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
+
3621
3820
  const meta = readNativeMeta(projectDir) || {};
3622
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
+
3623
3830
  await startNodeHost(projectDir, port);
3624
- return { ok: true, enabled: true, gatewayPort: 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;
3625
3911
  }
3626
3912
 
3627
3913
  /** Run `openclaw node run` detached so it outlives this request but stays in this desktop session. */
3628
3914
  async function startNodeHost(projectDir, gatewayPort) {
3629
3915
  if (await nodeHostRunning()) { sendLog('[computer-use] Node điều khiển đã chạy sẵn.'); return; }
3630
- const a = ocArgv(projectDir, ['node', 'run', '--host', '127.0.0.1', '--port', String(gatewayPort)]);
3631
- const child = spawn(a.cmd, a.args, { ...a.opts, detached: true, stdio: 'ignore', windowsHide: true });
3632
- child.on('error', (err) => sendLog(`[computer-use] không chạy được node host: ${err.message}`));
3633
- child.unref();
3916
+ const token = gatewayAuthToken(projectDir);
3917
+ if (!token) sendLog('[computer-use] Không đọc được gateway token - node 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
+ }
3634
3956
  // Confirm instead of assuming: a node host that failed to start looks exactly like one that
3635
3957
  // started, until the bot says it has no permission.
3636
3958
  for (let i = 0; i < 15; i++) {
3637
3959
  await new Promise((r) => setTimeout(r, 2000));
3638
3960
  if (await nodeHostRunning()) { sendLog('[computer-use] Node điều khiển đã kết nối.'); return; }
3639
3961
  }
3640
- sendLog('[computer-use] Node điều khiển chưa lên sau 30s kiểm tra lại bằng `openclaw node status`.');
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;
3641
4029
  }
3642
4030
 
3643
4031
  async function nodeHostRunning() {
@@ -3665,7 +4053,13 @@ async function stopNodeHost() {
3665
4053
  * install or update instead of leaving the customer pressing a stale copy.
3666
4054
  */
3667
4055
  async function writeWindowsLaunchers(projectDir, gatewayPort, routerPort) {
3668
- const files = buildWindowsLaunchers({ projectDir, gatewayPort, routerPort, setupPort: activeUiPort || 51789 });
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
+ });
3669
4063
  for (const [name, content] of Object.entries(files)) {
3670
4064
  await fsp.writeFile(join(projectDir, name), content, 'utf8');
3671
4065
  }
@@ -3685,11 +4079,11 @@ async function writeWindowsLaunchers(projectDir, gatewayPort, routerPort) {
3685
4079
  * file after an atomic rename. That check cannot pass through a Docker Desktop bind mount on
3686
4080
  * Windows, so the moment a docker image is rebuilt onto 2026.9.x the gateway can no longer write
3687
4081
  * its own config: every boot is unclean, the restart-loop breaker trips, and the bot is down with
3688
- * a misleading "ENOENT: fstat". Measured end to end on win_kha, 09/09/2026 native on the very
4082
+ * a misleading "ENOENT: fstat". Measured end to end on win_kha, 09/09/2026 - native on the very
3689
4083
  * same NTFS path works, so the fault is the bind-mount layer, not the disk.
3690
4084
  *
3691
4085
  * The data lives in two different places and only one of them is visible on the host:
3692
- * bind-mounted paths are already on disk, but named volumes are not state (the sqlite DB with
4086
+ * bind-mounted paths are already on disk, but named volumes are not - state (the sqlite DB with
3693
4087
  * every session), the plugin tree, and 9router's login live inside volumes. Copy those out FIRST;
3694
4088
  * everything after this point is reversible, the containers and image are left untouched.
3695
4089
  */
@@ -3698,11 +4092,11 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
3698
4092
  if (!existsSync(composeFile)) return false;
3699
4093
  sendLog('[migrate] Chuyển project từ Docker sang native...');
3700
4094
 
3701
- // 1. Stop the containers so nothing writes while we copy. Never `down -v` that deletes the
4095
+ // 1. Stop the containers so nothing writes while we copy. Never `down -v` - that deletes the
3702
4096
  // very volumes holding the customer's sessions.
3703
4097
  await run('docker', ['compose', '-f', composeFile, 'stop']).catch(() => {});
3704
4098
  // Stopping is not enough: the containers carry a restart policy, so the next reboot brings them
3705
- // back and they grab 18789/18790 before the native gateway can bind the native process then
4099
+ // back and they grab 18789/18790 before the native gateway can bind - the native process then
3706
4100
  // dies with EADDRINUSE seconds after reporting "ready", which reads like a random crash.
3707
4101
  // Measured on win_kha after its first reboot, 10/09/2026. Clear the policy, keep the containers.
3708
4102
  const psAll = await runCapture('docker', ['compose', '-f', composeFile, 'ps', '-a', '--format', '{{.Name}}'], { shell: false });
@@ -3725,7 +4119,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
3725
4119
  let hostDir = null;
3726
4120
  if (dest.startsWith(`${CONTAINER_HOME}/`)) hostDir = join(projectDir, dest.slice(CONTAINER_HOME.length + 1));
3727
4121
  else if (dest === '/root/.9router' || dest.endsWith('/.9router')) hostDir = join(projectDir, '.9router');
3728
- if (!hostDir) { sendLog(`[migrate] bỏ qua volume ${volName} (${dest}) không map được về project`); continue; }
4122
+ if (!hostDir) { sendLog(`[migrate] bỏ qua volume ${volName} (${dest}) - không map được về project`); continue; }
3729
4123
  await fsp.mkdir(hostDir, { recursive: true });
3730
4124
  // Copy with a throwaway container: the volume has no host path we can read directly.
3731
4125
  const r = await runCapture('docker', [
@@ -3733,7 +4127,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
3733
4127
  'node:22-slim', 'sh', '-c', 'cp -a /__src/. /__dst/ 2>/dev/null; ls -A /__dst | head -1',
3734
4128
  ], { shell: false });
3735
4129
  if (r.stdout && r.stdout.trim()) { copied++; sendLog(`[migrate] ${volName} → ${hostDir}`); }
3736
- else sendLog(`[migrate] ${volName} rỗng hoặc không chép được kiểm tra tay: ${hostDir}`);
4130
+ else sendLog(`[migrate] ${volName} rỗng hoặc không chép được - kiểm tra tay: ${hostDir}`);
3737
4131
  }
3738
4132
  }
3739
4133
  sendLog(`[migrate] đã đưa ${copied} volume xuống đĩa.`);
@@ -3755,7 +4149,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
3755
4149
  // /home/node/project so ".openclaw/workspace-x" resolved correctly. Native has no such
3756
4150
  // guarantee: the customer double-clicks a launcher from wherever they filed it, and the
3757
4151
  // agent then looks for its workspace under THAT folder and dies with WORKSPACE_VANISHED
3758
- // while agents that happen to carry an absolute path keep working, so only some bots
4152
+ // - while agents that happen to carry an absolute path keep working, so only some bots
3759
4153
  // break. Exactly what happened when the launchers were moved into a Desktop subfolder
3760
4154
  // (win_kha 10/09/2026). Pin every workspace to the project.
3761
4155
  {
@@ -3774,7 +4168,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
3774
4168
 
3775
4169
  // 3a-bis. Docker-only HOSTNAMES. `http://9router:20128` resolves through compose's internal
3776
4170
  // DNS and nowhere else, so on native every model call dies with
3777
- // `getaddrinfo ENOTFOUND 9router` the bot still reacts (that is the channel, no model
4171
+ // `getaddrinfo ENOTFOUND 9router` - the bot still reacts (that is the channel, no model
3778
4172
  // needed) but can never reply, which reads as "bot ignores me". Same story for
3779
4173
  // host.docker.internal and the 192.168.65.x Desktop bridge. Measured win_kha 10/09/2026.
3780
4174
  text = text
@@ -3794,7 +4188,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
3794
4188
  sendLog('[migrate] gateway.bind → loopback (native bind thẳng lên host, 0.0.0.0 là mở ra mạng)');
3795
4189
  }
3796
4190
  // 3c. The browser-automation plugin rewrites the browser block on every boot, so simply
3797
- // deleting the bad key below is not enough it grows back and the config is invalid
4191
+ // deleting the bad key below is not enough - it grows back and the config is invalid
3798
4192
  // again by the next restart (same shape as the old toolResultMaxChars reinfection).
3799
4193
  // Take the pen away from it; patchDocker is pointless here now too.
3800
4194
  const baEntry = cfg.plugins && cfg.plugins.entries && cfg.plugins.entries['browser-automation'];
@@ -3818,7 +4212,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
3818
4212
  }
3819
4213
  }
3820
4214
 
3821
- // 4. Backup folders sitting inside extensions/ are still scanned as real plugins that is where
4215
+ // 4. Backup folders sitting inside extensions/ are still scanned as real plugins - that is where
3822
4216
  // the long-standing "duplicate plugin id" warnings came from. A leading dot does NOT hide them
3823
4217
  // on Windows, so move them out of the tree entirely.
3824
4218
  const extDir = join(projectDir, '.openclaw', 'extensions');
@@ -3833,7 +4227,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
3833
4227
  }
3834
4228
 
3835
4229
  // 5. zalo-connect wrote its Zalo sessions next to the container's HOME, which was itself
3836
- // <project>/.openclaw leaving them one level too deep. Lift them so 3.1.5's one-time
4230
+ // <project>/.openclaw - leaving them one level too deep. Lift them so 3.1.5's one-time
3837
4231
  // migration finds them; without this every account comes back "not authenticated".
3838
4232
  const nested = join(projectDir, '.openclaw', '.openclaw');
3839
4233
  if (existsSync(nested)) {
@@ -3869,8 +4263,8 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
3869
4263
 
3870
4264
  // Windows has no usable service story for this (see windows-launcher-gen.js): the Scheduled
3871
4265
  // Task openclaw installs is bound to a login session, so it dies with the SSH session and is
3872
- // refused outright when set to run at boot. Give the owner double-click launchers instead
3873
- // the same shape as the .command files on macOS and put the three they actually press on
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
3874
4268
  // the Desktop where they can find them.
3875
4269
  if (process.platform === 'win32') {
3876
4270
  await writeWindowsLaunchers(projectDir, gwPort, rtPort).catch((e) => sendLog(`[native] launcher: ${e.message}`));
@@ -3899,12 +4293,12 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
3899
4293
  await new Promise((r) => setTimeout(r, 8000));
3900
4294
  await applyResolved9RouterApiKey(projectDir).catch(() => {});
3901
4295
 
3902
- // Plugins BEFORE the gateway's first boot the container entrypoint installs them ahead of the
4296
+ // Plugins BEFORE the gateway's first boot - the container entrypoint installs them ahead of the
3903
4297
  // gateway for the same reason: a gateway that boots with its plugins already on disk loads them
3904
4298
  // straight away, needs no follow-up restart, and prints no "plugin not found" warnings.
3905
4299
  // Config migrations FIRST: `openclaw plugins install` validates the config, so a config
3906
4300
  // still carrying pre-2026.8 keys makes every plugin install fail before migrations ran
3907
- // (measured on a fresh native install, 03/09/2026 learning-memory refused to install).
4301
+ // (measured on a fresh native install, 03/09/2026 - learning-memory refused to install).
3908
4302
  await runNativeConfigMigrations(projectDir).catch(() => {});
3909
4303
 
3910
4304
  await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin bootstrap skipped: ${e.message}`));
@@ -3926,8 +4320,8 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
3926
4320
  await hardenNativeServiceRestarts(projectDir).catch((e) => sendLog(`[native] service hardening skipped: ${e.message}`));
3927
4321
  await clearNativeServiceFailure(projectDir).catch(() => {});
3928
4322
  // `daemon install` already STARTED the unit, and that first boot begins the state migrations that
3929
- // hold a five-minute lease on the state directory. This used to restart it right away to pick up
3930
- // the env completed below and the restart landed mid-migration: the lease was left behind, every
4323
+ // hold a five-minute lease on the state directory. This used to restart it right away - to pick up
4324
+ // the env completed below - and the restart landed mid-migration: the lease was left behind, every
3931
4325
  // following boot exited 1 until it expired, and systemd's start limit abandoned the unit long
3932
4326
  // before that. The setup UI then waited out its whole timeout on a service that was never coming
3933
4327
  // back (real Ubuntu 24.04 VPS, 2026-08-28). So let this boot finish UNDISTURBED first, and only
@@ -3943,7 +4337,7 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
3943
4337
  if (envAdded.length || !firstBootOk) {
3944
4338
  sendLog(envAdded.length
3945
4339
  ? '[native] restarting gateway to load the completed service env'
3946
- : '[native] first boot never answered /health restarting once to recover');
4340
+ : '[native] first boot never answered /health - restarting once to recover');
3947
4341
  await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart after install: ${e.message}`));
3948
4342
  }
3949
4343
  sendLog(`[native] gateway service "${label}" running on 127.0.0.1:${gwPort}, 9router on 127.0.0.1:${rtPort}`);
@@ -3995,11 +4389,11 @@ async function syncDockerInfra(projectDir, force = false) {
3995
4389
  const compose = await readComposeText(projectDir);
3996
4390
 
3997
4391
  // If the compose was hand-customized (reverse-proxy/Traefik labels, an external network
3998
- // like `web`, or an explicit opt-out marker), DO NOT regenerate ANY infra file a full
4392
+ // like `web`, or an explicit opt-out marker), DO NOT regenerate ANY infra file - a full
3999
4393
  // docker-gen rewrite would wipe that routing (this once silently broke a live webhook).
4000
4394
  // Leave everything untouched; the version stamp stays old but each check just no-ops.
4001
4395
  if (/^\s*traefik\.|external:\s*true|openclaw-setup:\s*custom|openclaw-setup:keep/im.test(compose)) {
4002
- sendLog('[sync] Custom docker-compose.yml detected (Traefik/external network/keep marker) leaving infra untouched to preserve your routing.');
4396
+ sendLog('[sync] Custom docker-compose.yml detected (Traefik/external network/keep marker) - leaving infra untouched to preserve your routing.');
4003
4397
  return false;
4004
4398
  }
4005
4399
 
@@ -4049,7 +4443,7 @@ async function syncDockerInfra(projectDir, force = false) {
4049
4443
 
4050
4444
  sendLog(`[sync] Updating Docker infrastructure files (v${existingVersion} \u2192 v${SETUP_VERSION})`);
4051
4445
  await fsp.writeFile(join(dockerDir, 'Dockerfile'), docker.dockerfile, 'utf8');
4052
- // Capture the user's custom disk/folder mounts from the OLD compose before we overwrite it a
4446
+ // Capture the user's custom disk/folder mounts from the OLD compose before we overwrite it - a
4053
4447
  // full regen only re-emits the default volumes, so without this the bot loses granted drives.
4054
4448
  let carriedMounts = [];
4055
4449
  try {
@@ -4066,7 +4460,7 @@ async function syncDockerInfra(projectDir, force = false) {
4066
4460
  let cc = await fsp.readFile(join(dockerDir, 'docker-compose.yml'), 'utf8');
4067
4461
  if (!cc.includes(`:${dp}`)) {
4068
4462
  const gpStr = String(gatewayPort);
4069
- // Match the gateway published-port line whatever the host prefix is the generated form is
4463
+ // Match the gateway published-port line whatever the host prefix is - the generated form is
4070
4464
  // "127.0.0.1:<gw>:<gw>", so keying off the container port (":<gw>" before the quote) is the
4071
4465
  // only reliable anchor. The old `(?:\d+:)?` variant never matched the "127.0.0.1:" prefix.
4072
4466
  cc = cc.replace(
@@ -4098,13 +4492,13 @@ async function syncDockerInfra(projectDir, force = false) {
4098
4492
  }
4099
4493
 
4100
4494
  async function recreateDockerBot(projectDir) {
4101
- // Native: there is no image to rebuild the gateway reads openclaw.json from disk on boot, so
4495
+ // Native: there is no image to rebuild - the gateway reads openclaw.json from disk on boot, so
4102
4496
  // reloading config after a bot/plugin change is just a service restart. Callers stay unchanged.
4103
4497
  if (isNativeProject(projectDir)) {
4104
4498
  // Never restart a gateway that is still on its first boot: OpenClaw runs startup migrations
4105
4499
  // under a state lease, a restart mid-migration exits 1, and systemd's start limit can then
4106
4500
  // abandon the unit. This is the same trap the docker path avoids by waiting for the container
4107
- // before touching it (see startZaloConnectLogin) wait for /health first.
4501
+ // before touching it (see startZaloConnectLogin) - wait for /health first.
4108
4502
  await waitForNativeGatewayHealthy(projectDir, NATIVE_GATEWAY_HEALTH_TIMEOUT_MS);
4109
4503
  // The bot that was just created/edited may have added the Zalo channel or the context engine to
4110
4504
  // openclaw.json; put those plugins on disk now so this one reload loads them too.
@@ -4144,13 +4538,13 @@ async function updateRuntime(target, projectDir) {
4144
4538
  probeCacheClear();
4145
4539
  return { ok: true, target, spec, mode: 'native' };
4146
4540
  }
4147
- // A docker project asked to update is a docker project about to rebuild its image which is
4541
+ // A docker project asked to update is a docker project about to rebuild its image - which is
4148
4542
  // exactly the step that lands it on openclaw >=2026.9 and breaks writing through the bind mount
4149
4543
  // (see migrateDockerProjectToNative). So the update IS the migration: move the data onto native
4150
4544
  // first, then update the native way. Doing it here rather than on every page load keeps it a
4151
4545
  // deliberate act by the customer, not something that reshapes their machine behind their back.
4152
4546
  if (!isNativeProject(projectDir) && projectDir && existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'))) {
4153
- sendLog('[update] Project đang chạy Docker chuyển sang native trước khi cập nhật.');
4547
+ sendLog('[update] Project đang chạy Docker - chuyển sang native trước khi cập nhật.');
4154
4548
  await ensureNodeInstalled();
4155
4549
  await migrateDockerProjectToNative(projectDir, { osChoice: state.os || '' });
4156
4550
  await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
@@ -4201,7 +4595,7 @@ async function restartDockerBotContainer(projectDir = state.projectDir) {
4201
4595
  sendLog(`[docker] Restarting ${containerName} container...`);
4202
4596
  await run('docker', ['restart', containerName], { shell: false });
4203
4597
  await waitForDockerContainer(containerName);
4204
- // Restart may apply config/port changes drop cached runtime/status for this project.
4598
+ // Restart may apply config/port changes - drop cached runtime/status for this project.
4205
4599
  probeCacheClear(`runtime:${projectDir}`);
4206
4600
  return true;
4207
4601
  }
@@ -4253,7 +4647,7 @@ async function addBotMount(projectDir, hostPath, mountName = '') {
4253
4647
  // forward slashes on every OS, incl. `C:/Users/...`), drop trailing separators. This avoids
4254
4648
  // YAML backslash issues and keeps the path uniform.
4255
4649
  let cleanPath = String(hostPath || '').trim().replace(/\\+/g, '/').replace(/\/+$/, '');
4256
- // A bare Windows drive letter ("D:") is an INVALID Docker bind source the trailing-slash strip
4650
+ // A bare Windows drive letter ("D:") is an INVALID Docker bind source - the trailing-slash strip
4257
4651
  // above turns "D:/" into "D:". Restore the slash so mounting a whole drive (e.g. D:\) works.
4258
4652
  if (/^[a-zA-Z]:$/.test(cleanPath)) cleanPath += '/';
4259
4653
  if (!cleanPath) throw httpError(400, 'Đường dẫn ổ đĩa/thư mục đang trống');
@@ -4291,7 +4685,7 @@ async function addBotMount(projectDir, hostPath, mountName = '') {
4291
4685
  }
4292
4686
 
4293
4687
  // Sync a managed "granted mounts" block into every agent's AGENTS.md from the /mnt/* mounts in
4294
- // docker-compose.yml (excludes /mnt/project that's the project root, always mounted).
4688
+ // docker-compose.yml (excludes /mnt/project - that's the project root, always mounted).
4295
4689
  async function updateGrantedMountsInAgents(projectDir) {
4296
4690
  const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
4297
4691
  const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
@@ -4320,7 +4714,7 @@ async function updateGrantedMountsInAgents(projectDir) {
4320
4714
  const END = '<!-- granted-mounts:end -->';
4321
4715
  const block = mounts.length
4322
4716
  ? `${START}\n## 💽 Thư mục/ổ đĩa được cấp quyền (toàn project)\n`
4323
- + mounts.map((x) => `- \`${x.target}\` ← host \`${x.host}\` bot được phép đọc/ghi tại đây.`).join('\n')
4717
+ + mounts.map((x) => `- \`${x.target}\` ← host \`${x.host}\` - bot được phép đọc/ghi tại đây.`).join('\n')
4324
4718
  + `\n- Mặc định MỌI bot trong project đều dùng được các thư mục trên. Muốn giới hạn theo từng bot thì ghi rõ ngay dưới mục này.\n${END}`
4325
4719
  : '';
4326
4720
  const blockRe = new RegExp(`\\n*${START}[\\s\\S]*?${END}\\n*`);
@@ -4436,111 +4830,22 @@ async function getDockerBridgeIp() {
4436
4830
  } catch {}
4437
4831
  return '172.17.0.1';
4438
4832
  }
4439
- // ── Host control ────────────────────────────────────────────────────────────────
4440
- // The bot runs inside a container: it has no view of the host desktop and cannot start a
4441
- // program there, which is why asking it to open TeamViewer gets a refusal. The installer,
4442
- // though, already runs ON the host and already spawns processes (it launches Chrome). This
4443
- // exposes that ability to the bot over a small HTTP service.
4444
- //
4445
- // Reachability: the dashboard itself binds to 127.0.0.1, which a container cannot reach, so
4446
- // this listens on the Docker bridge address as well — the same approach the Chrome relay
4447
- // uses, private to this machine and not routable from outside.
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.
4448
4839
  //
4449
- // Everything is gated: the service only starts when hostControl.enabled is true, every
4450
- // request needs the per-project token, and `open` accepts a key from the operator's own app
4451
- // list rather than an arbitrary command line. Opening apps on the host is a real capability,
4452
- // so it stays opt-in and enumerable instead of a general shell.
4453
- const HOST_CONTROL_PORT = 18795;
4454
- let _hostControlServer = null;
4455
- // The project the running host-control service serves. Tracked separately from the server
4456
- // singleton so enabling from a different (connected) project re-points the service without a
4457
- // restart — the request handler reads config from THIS dir, not a value captured at first-start.
4458
- let _hostControlProjectDir = null;
4840
+ // What is left is the switch itself, recorded per project in .openclaw/host-control.json.
4459
4841
 
4460
4842
  function hostControlConfigPath(projectDir) {
4461
4843
  return join(projectDir, '.openclaw', 'host-control.json');
4462
4844
  }
4463
4845
 
4464
- /** Common install locations, so the app list is useful before anyone edits it. */
4465
- function detectHostApps() {
4466
- const apps = {};
4467
- const add = (key, candidates) => {
4468
- for (const candidate of candidates) {
4469
- if (candidate && existsSync(candidate)) {
4470
- apps[key] = candidate;
4471
- return;
4472
- }
4473
- }
4474
- };
4475
- if (process.platform === 'win32') {
4476
- const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
4477
- const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
4478
- const local = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
4479
- add('teamviewer', [join(pf, 'TeamViewer', 'TeamViewer.exe'), join(pf86, 'TeamViewer', 'TeamViewer.exe')]);
4480
- add('chrome', [join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'), join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe')]);
4481
- add('zalo', [join(local, 'Programs', 'Zalo', 'Zalo.exe'), join(local, 'Zalo', 'Zalo.exe')]);
4482
- add('explorer', ['C:\\Windows\\explorer.exe']);
4483
- add('notepad', ['C:\\Windows\\System32\\notepad.exe']);
4484
- // A hand-written shortlist only covers what WE thought of. Every customer machine has its
4485
- // own software, and the bot is useless the moment it is asked for something not on the list —
4486
- // "mở TeamViewer" fails not because the app is missing but because nobody enumerated it.
4487
- // The Start Menu is the one place Windows guarantees an entry per installed app, and a .lnk
4488
- // launches correctly without knowing where the .exe actually lives. Scanning it turns the
4489
- // list from "5 apps we guessed" into "everything this machine has", and it stays correct
4490
- // when the customer installs something new. Measured on win_kha: 5 → 182 apps.
4491
- Object.assign(apps, scanWindowsStartMenuApps(), apps); // hand-written entries win
4492
- } else if (process.platform === 'darwin') {
4493
- add('teamviewer', ['/Applications/TeamViewer.app']);
4494
- add('chrome', ['/Applications/Google Chrome.app']);
4495
- add('zalo', ['/Applications/Zalo.app']);
4496
- add('finder', ['/System/Library/CoreServices/Finder.app']);
4497
- // Same idea as Windows: enumerate what is really installed instead of guessing.
4498
- Object.assign(apps, scanMacApplications(), apps);
4499
- }
4500
- return apps;
4501
- }
4502
4846
 
4503
- /** Every .lnk under both Start Menu trees, keyed by a slug of its name. */
4504
- function scanWindowsStartMenuApps() {
4505
- const apps = {};
4506
- // Uninstallers and doc links are not apps; opening one by accident is worse than not having it.
4507
- const SKIP = /(uninstall|gỡ cài đặt|go cai dat|readme|help|documentation|website|release notes|license|repair|modify)/i;
4508
- const roots = [
4509
- join(process.env.ProgramData || 'C:\\ProgramData', 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
4510
- join(process.env.APPDATA || '', 'Microsoft', 'Windows', 'Start Menu', 'Programs'),
4511
- ];
4512
- const walk = (dir, depth = 0) => {
4513
- if (depth > 4) return;
4514
- let entries = [];
4515
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
4516
- for (const e of entries) {
4517
- const p = join(dir, e.name);
4518
- if (e.isDirectory()) { walk(p, depth + 1); continue; }
4519
- if (!/\.lnk$/i.test(e.name)) continue;
4520
- const name = e.name.replace(/\.lnk$/i, '');
4521
- if (SKIP.test(name)) continue;
4522
- const key = slugify(name, '');
4523
- if (key && !apps[key]) apps[key] = p;
4524
- }
4525
- };
4526
- for (const r of roots) if (r) walk(r);
4527
- return apps;
4528
- }
4529
4847
 
4530
- /** Installed .app bundles, so macOS gets the same "everything on this machine" list. */
4531
- function scanMacApplications() {
4532
- const apps = {};
4533
- for (const dir of ['/Applications', '/System/Applications', join(os.homedir(), 'Applications')]) {
4534
- let entries = [];
4535
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
4536
- for (const e of entries) {
4537
- if (!e.name.endsWith('.app')) continue;
4538
- const key = slugify(e.name.replace(/\.app$/, ''), '');
4539
- if (key && !apps[key]) apps[key] = join(dir, e.name);
4540
- }
4541
- }
4542
- return apps;
4543
- }
4848
+
4544
4849
 
4545
4850
  /** Resolve an executable on PATH synchronously (returns absolute path or ''). */
4546
4851
  function whichSync(name) {
@@ -4549,7 +4854,7 @@ function whichSync(name) {
4549
4854
  const out = execFileSync(finder, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
4550
4855
  const hits = String(out).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
4551
4856
  if (process.platform !== 'win32') return hits[0] || '';
4552
- // `where claude` lists the extensionless npm shim FIRST a shell script Windows cannot spawn
4857
+ // `where claude` lists the extensionless npm shim FIRST - a shell script Windows cannot spawn
4553
4858
  // ("spawn ...\\npm\\claude ENOENT"), which is how an allow-listed CLI ended up unusable for the
4554
4859
  // bot. Prefer something Windows can actually execute.
4555
4860
  const rank = (f) => {
@@ -4566,7 +4871,7 @@ function whichSync(name) {
4566
4871
 
4567
4872
  /**
4568
4873
  * What to actually spawn for an allow-listed command. Windows needs the indirection:
4569
- * - the path may be the extensionless npm shim (a shell script) try the real siblings;
4874
+ * - the path may be the extensionless npm shim (a shell script) - try the real siblings;
4570
4875
  * - a `.cmd`/`.bat` shim cannot be spawned without a shell on current Node, so read it and run
4571
4876
  * what it points at (`…\pkg\bin\x.exe`, or node + a cli.js) directly.
4572
4877
  * Keeping shell:false matters: the bot supplies the arguments, and a shell would let one of them
@@ -4599,226 +4904,18 @@ function resolveHostExecutable(bin) {
4599
4904
  return { file: target, prefixArgs: [] };
4600
4905
  }
4601
4906
 
4602
- /**
4603
- * CLI tools the bot may RUN (not just open) via /api/host/exec — output is captured and
4604
- * returned. Kept as a name→path allow-list, mirroring detectHostApps: the executable is fixed,
4605
- * only allow-listed names run. Auto-detects Claude Code CLI; add more by editing
4606
- * `.openclaw/host-control.json` → `commands`.
4607
- */
4608
- function detectHostCommands() {
4609
- const commands = {};
4610
- const claude = whichSync('claude');
4611
- if (claude) commands.claude = claude;
4612
- return commands;
4613
- }
4614
4907
 
4615
- /**
4616
- * Extra capabilities the operator grants together with PC control: seeing the screen
4617
- * (screenshot / screen recording) and running scripts through node or the Codex CLI.
4618
- *
4619
- * Kept out of detectHostCommands() on purpose. That one is the default list every project gets
4620
- * as soon as the dashboard reads host-control state; these are only merged in when the operator
4621
- * actually flips PC control on, so nothing is granted before they ask for it. `node` in
4622
- * particular runs arbitrary code, which is why it takes an explicit act.
4623
- */
4624
- function detectHostCapabilityCommands() {
4625
- const commands = {};
4626
- // The installer is itself node, so this path is guaranteed to exist and to be the same
4627
- // interpreter the native bot runs under (the one macOS will attach the screen permission to).
4628
- commands.node = process.execPath;
4629
- for (const name of ['npx', 'codex', 'claude', 'ffmpeg']) {
4630
- const bin = whichSync(name);
4631
- if (bin) commands[name] = bin; // ffmpeg = screen recording on Linux/macOS
4632
- }
4633
- // The Codex CLI usually is not on PATH — it ships inside the desktop app. With it allow-listed
4634
- // the bot can hand a job to Codex headlessly (`codex exec "…"`) and read the answer back.
4635
- if (!commands.codex) {
4636
- const bundledCodex = resolveCodexCli(detectCodexApp());
4637
- if (bundledCodex) commands.codex = bundledCodex;
4638
- }
4639
- if (process.platform === 'darwin') {
4640
- // Both a screenshot (`-x`) and a screen recording (`-v -V <secs>`) tool.
4641
- if (existsSync('/usr/sbin/screencapture')) commands.screencapture = '/usr/sbin/screencapture';
4642
- } else if (process.platform === 'linux') {
4643
- for (const name of ['gnome-screenshot', 'spectacle', 'scrot', 'import']) {
4644
- const bin = whichSync(name);
4645
- if (bin) { commands.screenshot = bin; break; }
4646
- }
4647
- }
4648
- return commands;
4649
- }
4650
4908
 
4651
- /**
4652
- * Merge the capability commands into the project's allow-list, and report what was added so the
4653
- * dashboard can name it. Existing entries are left alone: an operator who pointed `node` at a
4654
- * specific interpreter keeps that path.
4655
- */
4656
- function grantHostCapabilities(cfg) {
4657
- const detected = detectHostCapabilityCommands();
4658
- const added = [];
4659
- cfg.commands = cfg.commands || {};
4660
- for (const [name, bin] of Object.entries(detected)) {
4661
- if (!cfg.commands[name]) {
4662
- cfg.commands[name] = bin;
4663
- added.push(name);
4664
- }
4665
- }
4666
- // Desktop actions (/api/host/ui) come with the same grant: screenshot, pointer, keyboard,
4667
- // clipboard, windows. Built in, so they work on a machine with no Codex and no extra tools —
4668
- // on Linux they lean on xdotool/scrot, which the endpoint reports if missing.
4669
- if (cfg.ui !== true) {
4670
- cfg.ui = true;
4671
- added.push('desktop actions (screenshot/click/type)');
4672
- }
4673
- return added;
4674
- }
4675
-
4676
- // Mouse/keyboard/screen control comes from the Codex desktop app's own `computer-use` plugin.
4677
- // The bot reaches it by running `codex exec "<task>"`, which is a normal allow-listed command —
4678
- // no OpenClaw-side harness, no second agent, no gateway restart. All this code has to do is make
4679
- // sure the desktop app itself has computer-use installed and wired.
4680
- //
4681
- /** Where the desktop app that ships the Codex CLI + computer-use bundle lives. */
4682
- function detectCodexApp() {
4683
- const candidates = process.platform === 'darwin'
4684
- ? [
4685
- { app: '/Applications/Codex.app', bundle: '/Applications/Codex.app/Contents/Resources/plugins/openai-bundled' },
4686
- { app: '/Applications/ChatGPT.app', bundle: '/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled' },
4687
- ]
4688
- : process.platform === 'win32'
4689
- ? [
4690
- { app: join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'Programs', 'Codex'), bundle: '' },
4691
- { app: join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'Programs', 'ChatGPT'), bundle: '' },
4692
- ]
4693
- : [];
4694
- for (const candidate of candidates) {
4695
- if (existsSync(candidate.app)) {
4696
- return { present: true, app: candidate.app, bundle: candidate.bundle && existsSync(candidate.bundle) ? candidate.bundle : '' };
4697
- }
4698
- }
4699
- return { present: false, app: '', bundle: '' };
4700
- }
4701
4909
 
4702
- /**
4703
- * Find a marketplace the Codex app-server has ALREADY registered that carries the computer-use
4704
- * plugin, by reading its own `~/.codex/config.toml`.
4705
- *
4706
- * This matters because auto-install refuses to add new sources: pointing the plugin at a
4707
- * marketplace directory it has not discovered fails with "auto-install only uses marketplaces
4708
- * Codex app-server has already discovered … run /codex computer-use install". Naming a discovered
4709
- * marketplace instead keeps provisioning fully automatic.
4710
- */
4711
- function detectCodexMarketplace() {
4712
- const codexHome = process.env.CODEX_HOME || join(getRealHomedir(), '.codex');
4713
- const configPath = join(codexHome, 'config.toml');
4714
- if (!existsSync(configPath)) return null;
4715
- let toml = '';
4716
- try {
4717
- toml = fs.readFileSync(configPath, 'utf8');
4718
- } catch (_) {
4719
- return null;
4720
- }
4721
- // Minimal line-based TOML read: [marketplaces.<name>] headers and their `source = "..."`. A full
4722
- // TOML parser is not worth pulling in for two fields of someone else's config.
4723
- let name = '';
4724
- for (const rawLine of toml.split(/\r?\n/)) {
4725
- const line = rawLine.trim();
4726
- const header = line.match(/^\[([^\]]+)\]$/);
4727
- if (header) {
4728
- const section = header[1];
4729
- name = section.startsWith('marketplaces.') ? section.slice('marketplaces.'.length).replace(/^["']|["']$/g, '') : '';
4730
- continue;
4731
- }
4732
- if (!name) continue;
4733
- const source = (line.match(/^source\s*=\s*"([^"]+)"$/) || [])[1];
4734
- if (source && existsSync(join(source, 'plugins', 'computer-use'))) return { name, source };
4735
- }
4736
- return null;
4737
- }
4738
4910
 
4739
- /** The Codex CLI that ships inside the desktop app (or one on PATH). */
4740
- function resolveCodexCli(app) {
4741
- const bundled = app && app.app ? join(app.app, 'Contents', 'Resources', 'codex') : '';
4742
- if (bundled && existsSync(bundled)) return bundled;
4743
- return whichSync('codex');
4744
- }
4745
4911
 
4746
- /**
4747
- * Last mile on the Codex side: the OpenClaw plugin can only USE computer-use, it cannot install it
4748
- * into the desktop app. Two things have to be true there, and both are fixable with the app's own
4749
- * CLI (verified on a real machine):
4750
- * - the `computer-use` plugin is installed from a discovered marketplace, and
4751
- * - the `computer-use` MCP server points at that installed plugin. A stale global entry (left by
4752
- * an earlier manual attempt) shadows the plugin's own and exposes zero tools, which surfaces as
4753
- * the confusing "Computer Use is ready" with nothing behind it.
4754
- */
4755
- async function ensureCodexComputerUsePlugin(app, marketplace) {
4756
- const result = { cli: resolveCodexCli(app), pluginInstalled: false, installedNow: false, mcpRepaired: false };
4757
- if (!result.cli || !marketplace) return result;
4758
- const list = await runCapture(result.cli, ['plugin', 'list'], { shell: false }).catch(() => null);
4759
- if (!list) return result;
4760
- const ref = `computer-use@${marketplace.name}`;
4761
- const row = `${list.stdout || ''}\n${list.stderr || ''}`.split(/\r?\n/).find((line) => line.trim().startsWith(ref));
4762
- if (!row) return result;
4763
- result.pluginInstalled = /\binstalled\b/.test(row) && !/not installed/.test(row);
4764
- if (!result.pluginInstalled) {
4765
- sendLog(`[computer-use] Cài plugin ${ref} vào app Codex…`);
4766
- const add = await runCapture(result.cli, ['plugin', 'add', ref], { shell: false }).catch((err) => ({ code: 1, stderr: err.message }));
4767
- result.installedNow = add.code === 0;
4768
- if (!result.installedNow) result.error = (add.stderr || add.stdout || '').trim().split(/\r?\n/).slice(-2).join(' ');
4769
- else result.pluginInstalled = true;
4770
- }
4771
- // Repair the MCP registration only when it clearly is NOT the plugin's own (its cwd lives under
4772
- // the plugin cache). Removing the global entry lets the plugin-provided server take over.
4773
- const mcp = await runCapture(result.cli, ['mcp', 'get', 'computer-use'], { shell: false }).catch(() => null);
4774
- const mcpText = mcp ? `${mcp.stdout || ''}${mcp.stderr || ''}` : '';
4775
- if (mcpText && !/plugins\/cache\//.test(mcpText)) {
4776
- sendLog('[computer-use] Gỡ khai báo MCP computer-use cũ (trỏ sai chỗ) để dùng bản của plugin…');
4777
- const removed = await runCapture(result.cli, ['mcp', 'remove', 'computer-use'], { shell: false }).catch(() => ({ code: 1 }));
4778
- result.mcpRepaired = removed.code === 0;
4779
- }
4780
- return result;
4781
- }
4782
4912
 
4783
- /**
4784
- * Drop a tiny wrapper next to each workspace so GUI hand-off is one fixed command.
4785
- *
4786
- * Relying on the model to remember `--sandbox danger-full-access` does not work: a running session
4787
- * still holds the TOOLS.md it loaded at session start, so a bot mid-conversation keeps calling
4788
- * plain `codex exec`, gets "Computer Use was not approved to use <app>", and then invents a reason
4789
- * (observed twice: it told the operator to grant Screen Recording, which was already granted).
4790
- * With the wrapper the flags live on disk instead of in the prompt.
4791
- */
4792
- async function writeCodexTaskScript(projectDir, cliPath) {
4793
- const openclawDir = join(projectDir, '.openclaw');
4794
- if (!existsSync(openclawDir) || !cliPath) return '';
4795
- const body = [
4796
- '#!/bin/sh',
4797
- '# Managed by create-openclaw-bot — hand a desktop/GUI job to Codex and print its answer.',
4798
- '# Usage: pc-task.sh "mở TeamViewer và đọc ID trên màn hình"',
4799
- '# The sandbox flag is REQUIRED: the default read-only sandbox makes Codex refuse computer-use',
4800
- '# with "Computer Use was not approved to use <app>".',
4801
- 'if [ $# -eq 0 ]; then echo "usage: pc-task.sh \\"việc cần làm\\"" >&2; exit 2; fi',
4802
- `exec ${JSON.stringify(cliPath)} exec --skip-git-repo-check --sandbox danger-full-access "$@"`,
4803
- '',
4804
- ].join('\n');
4805
- let written = '';
4806
- for (const entry of await fsp.readdir(openclawDir).catch(() => [])) {
4807
- if (!entry.startsWith('workspace')) continue;
4808
- const binDir = join(openclawDir, entry, 'bin');
4809
- await fsp.mkdir(binDir, { recursive: true }).catch(() => {});
4810
- const path = join(binDir, 'pc-task.sh');
4811
- await fsp.writeFile(path, body, 'utf8').catch(() => {});
4812
- await fsp.chmod(path, 0o755).catch(() => {});
4813
- written = path;
4814
- }
4815
- return written;
4816
- }
4913
+
4817
4914
 
4818
4915
  /**
4819
4916
  * macOS/Windows privacy panes for the permissions PC control needs. The OS never lets an app
4820
4917
  * grant these for you (that is the point of TCC), so the best we can do is take the operator
4821
- * straight to the right pane and for screen capture poke the API so the system prompt appears.
4918
+ * straight to the right pane and - for screen capture - poke the API so the system prompt appears.
4822
4919
  */
4823
4920
  function openPrivacyPane(kind) {
4824
4921
  const macPanes = {
@@ -4869,6 +4966,13 @@ async function probeScreenPermission() {
4869
4966
  return { supported: true, granted };
4870
4967
  }
4871
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
+ */
4872
4976
  async function readHostControlConfig(projectDir) {
4873
4977
  const path = hostControlConfigPath(projectDir);
4874
4978
  let cfg = {};
@@ -4877,24 +4981,8 @@ async function readHostControlConfig(projectDir) {
4877
4981
  } catch (_) {
4878
4982
  cfg = {};
4879
4983
  }
4880
- let changed = false;
4881
4984
  if (typeof cfg.enabled !== 'boolean') {
4882
4985
  cfg.enabled = false;
4883
- changed = true;
4884
- }
4885
- if (!cfg.token) {
4886
- cfg.token = _require('crypto').randomBytes(24).toString('hex');
4887
- changed = true;
4888
- }
4889
- if (!cfg.apps || typeof cfg.apps !== 'object') {
4890
- cfg.apps = detectHostApps();
4891
- changed = true;
4892
- }
4893
- if (!cfg.commands || typeof cfg.commands !== 'object') {
4894
- cfg.commands = detectHostCommands();
4895
- changed = true;
4896
- }
4897
- if (changed) {
4898
4986
  await fsp.mkdir(dirname(path), { recursive: true }).catch(() => {});
4899
4987
  await fsp.writeFile(path, JSON.stringify(cfg, null, 2), 'utf8').catch(() => {});
4900
4988
  }
@@ -4908,64 +4996,12 @@ function spawnDetached(command, args) {
4908
4996
  child.unref();
4909
4997
  }
4910
4998
 
4911
- function openHostApp(target) {
4912
- if (process.platform === 'win32') {
4913
- // `start` needs a shell; the empty "" is the window title cmd expects before the path.
4914
- spawnDetached('cmd', ['/c', 'start', '', target]);
4915
- return;
4916
- }
4917
- if (process.platform === 'darwin') {
4918
- spawnDetached('open', [target]);
4919
- return;
4920
- }
4921
- spawnDetached('xdg-open', [target]);
4922
- }
4923
4999
 
4924
- /**
4925
- * Run an allow-listed CLI (e.g. Claude Code) and return its output. Unlike openHostApp this is
4926
- * NOT detached: we wait for it, capture stdout/stderr (capped), and enforce a timeout. No shell
4927
- * (shell:false) so args are literal — no injection; the executable is fixed by the allow-list.
4928
- */
4929
- function runHostCommand(res, name, bin, args, input, timeoutMs) {
4930
- const MAX_OUT = 200_000; // ~200 KB cap per stream, so a runaway process can't flood the reply
4931
- return new Promise((resolveP) => {
4932
- let out = '';
4933
- let err = '';
4934
- let settled = false;
4935
- const finish = (payload, status) => {
4936
- if (settled) return;
4937
- settled = true;
4938
- clearTimeout(timer);
4939
- json(res, payload, status);
4940
- resolveP();
4941
- };
4942
- let child;
4943
- try {
4944
- const target = resolveHostExecutable(bin);
4945
- child = spawn(target.file, [...target.prefixArgs, ...args], { shell: false, windowsHide: true });
4946
- } catch (e) {
4947
- return finish({ ok: false, error: e.message }, 500);
4948
- }
4949
- const timer = setTimeout(() => {
4950
- try { child.kill('SIGKILL'); } catch (_) {}
4951
- finish({ ok: false, error: `timeout after ${timeoutMs}ms`, timedOut: true, stdout: out.slice(0, MAX_OUT), stderr: err.slice(0, MAX_OUT) }, 504);
4952
- }, timeoutMs);
4953
- child.stdout?.on('data', (d) => { if (out.length < MAX_OUT) out += d.toString(); });
4954
- child.stderr?.on('data', (d) => { if (err.length < MAX_OUT) err += d.toString(); });
4955
- child.on('error', (e) => finish({ ok: false, error: e.message }, 500));
4956
- child.on('close', (code) => {
4957
- sendLog(`[host-control] Đã chạy "${name}" (exit ${code}).`);
4958
- finish({ ok: code === 0, command: name, code, stdout: out.slice(0, MAX_OUT), stderr: err.slice(0, MAX_OUT) }, 200);
4959
- });
4960
- if (input != null) { try { child.stdin.write(input); } catch (_) {} }
4961
- try { child.stdin.end(); } catch (_) {}
4962
- });
4963
- }
4964
5000
 
4965
5001
  /**
4966
5002
  * Desktop actions for the bot: see the screen, move and click, type, read the clipboard, list and
4967
- * focus windows. The bot runs in a container with no desktop of its own, so the installer which
4968
- * already runs on the operator's machine and already opens apps for it performs them.
5003
+ * focus windows. The bot runs in a container with no desktop of its own, so the installer - which
5004
+ * already runs on the operator's machine and already opens apps for it - performs them.
4969
5005
  *
4970
5006
  * No native modules: the approach follows the dependency-free tools (and Anthropic's own
4971
5007
  * computer-use reference, which drives xdotool + a screenshot binary):
@@ -4976,7 +5012,7 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
4976
5012
  * do not fork per platform.
4977
5013
  *
4978
5014
  * Windows note: input injection and screen capture need a real desktop session. When the installer
4979
- * itself was started over SSH there is none, and the capture fails the error says so instead of
5015
+ * itself was started over SSH there is none, and the capture fails - the error says so instead of
4980
5016
  * leaking a raw Win32Exception.
4981
5017
  */
4982
5018
  const HOST_UI_ACTIONS = new Set([
@@ -4990,7 +5026,7 @@ function hostUiScriptPath(projectDir) {
4990
5026
 
4991
5027
  async function ensureHostUiScript(projectDir) {
4992
5028
  const path = hostUiScriptPath(projectDir);
4993
- const stamp = `# OpenClaw host UI helper version ${HOST_UI_PS1_VERSION}`;
5029
+ const stamp = `# OpenClaw host UI helper - version ${HOST_UI_PS1_VERSION}`;
4994
5030
  try {
4995
5031
  if (existsSync(path) && (await fsp.readFile(path, 'utf8')).startsWith(stamp)) return path;
4996
5032
  } catch (_) {}
@@ -5021,461 +5057,56 @@ async function hostUiScreenshotTarget(projectDir) {
5021
5057
  return { hostPath: join(dir, name), containerPath: `/home/node/project/.openclaw/media/host-ui/${name}` };
5022
5058
  }
5023
5059
 
5024
- async function runHostUiWindows(projectDir, action, body, shot) {
5025
- const script = await ensureHostUiScript(projectDir);
5026
- const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Action', action];
5027
- const push = (flag, value) => { if (value !== undefined && value !== null && value !== '') args.push(flag, String(value)); };
5028
- push('-X', body.x);
5029
- push('-Y', body.y);
5030
- push('-ToX', body.toX);
5031
- push('-ToY', body.toY);
5032
- push('-Amount', body.amount);
5033
- push('-Text', body.text);
5034
- push('-Button', body.button);
5035
- push('-Clicks', body.clicks);
5036
- push('-Title', body.title);
5037
- if (shot) push('-Path', shot.hostPath);
5038
- const r = await runCapture('powershell', args, { shell: false, timeout: 30000 });
5039
- const parsed = parseJsonText(String(r.stdout || '').trim(), null);
5040
- if (parsed) return parsed;
5041
- const err = String(r.stderr || r.stdout || '').trim();
5042
- if (/Win32Exception|CopyFromScreen|handle is invalid/i.test(err)) {
5043
- 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.' };
5044
- }
5045
- return { ok: false, error: err.split('\n')[0] || `powershell exited ${r.code}` };
5046
- }
5047
-
5048
- async function runHostUiMac(action, body, shot) {
5049
- const osa = (script) => runCapture('osascript', ['-e', script], { shell: false, timeout: 20000 });
5050
- const point = () => `{${Number(body.x) || 0}, ${Number(body.y) || 0}}`;
5051
- switch (action) {
5052
- case 'screenshot': {
5053
- const r = await runCapture('screencapture', ['-x', shot.hostPath], { shell: false, timeout: 20000 });
5054
- return r.code === 0 ? { ok: true, path: shot.hostPath } : { ok: false, error: String(r.stderr || 'screencapture failed').trim() };
5055
- }
5056
- case 'screen_size': {
5057
- const r = await osa('tell application "Finder" to get bounds of window of desktop');
5058
- const nums = String(r.stdout || '').trim().split(/\s*,\s*/).map(Number);
5059
- return nums.length === 4 ? { ok: true, width: nums[2], height: nums[3] } : { ok: false, error: 'could not read screen bounds' };
5060
- }
5061
- case 'mouse_move':
5062
- case 'click': {
5063
- // System Events can click at a point; a plain move has no equivalent, so a move is a click
5064
- // target set-up only. Accessibility permission is required (System Settings → Privacy).
5065
- const clicks = Math.max(1, Number(body.clicks) || 1);
5066
- 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 };
5067
- for (let i = 0; i < clicks; i++) {
5068
- const r = await osa(`tell application "System Events" to click at ${point()}`);
5069
- if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() || 'click failed (grant Accessibility permission)' };
5070
- }
5071
- return { ok: true, button: 'left', clicks };
5072
- }
5073
- case 'type': {
5074
- const text = String(body.text || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
5075
- const r = await osa(`tell application "System Events" to keystroke "${text}"`);
5076
- return r.code === 0 ? { ok: true, typed: String(body.text || '').length } : { ok: false, error: String(r.stderr || '').trim() };
5077
- }
5078
- case 'key': {
5079
- const map = { enter: 'return', esc: 'escape', pageup: 'page up', pagedown: 'page down' };
5080
- for (const combo of String(body.text || '').split(/\s+/).filter(Boolean)) {
5081
- const parts = combo.toLowerCase().split('+').map((p) => p.trim()).filter(Boolean);
5082
- const key = map[parts[parts.length - 1]] || parts[parts.length - 1];
5083
- 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);
5084
- const using = mods.length ? ` using {${mods.join(', ')}}` : '';
5085
- const named = ['return', 'escape', 'tab', 'space', 'delete', 'up', 'down', 'left', 'right', 'home', 'end', 'page up', 'page down'];
5086
- const script = named.includes(key)
5087
- ? `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}`
5088
- : `tell application "System Events" to keystroke "${key}"${using}`;
5089
- const r = await osa(script);
5090
- if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() };
5091
- }
5092
- return { ok: true, keys: body.text };
5093
- }
5094
- case 'scroll': {
5095
- const amount = Number(body.amount) || 3;
5096
- const dir = amount < 0 ? 121 : 116; // page down / page up
5097
- for (let i = 0; i < Math.abs(amount); i++) await osa(`tell application "System Events" to key code ${dir}`);
5098
- return { ok: true, amount };
5099
- }
5100
- case 'clipboard_get': {
5101
- const r = await runCapture('pbpaste', [], { shell: false, timeout: 10000 });
5102
- return { ok: true, text: String(r.stdout || '') };
5103
- }
5104
- case 'clipboard_set': {
5105
- const r = await runCapture('sh', ['-c', 'pbcopy'], { shell: false, timeout: 10000, input: String(body.text || '') });
5106
- return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: 'pbcopy failed' };
5107
- }
5108
- case 'windows': {
5109
- const r = await osa('tell application "System Events" to get name of every process whose background only is false');
5110
- const list = String(r.stdout || '').trim().split(/\s*,\s*/).filter(Boolean).map((title) => ({ title, process: title }));
5111
- return { ok: true, windows: list };
5112
- }
5113
- case 'focus': {
5114
- const title = String(body.title || '').replace(/"/g, '');
5115
- if (!title) return { ok: false, error: 'focus needs a title' };
5116
- const r = await osa(`tell application "${title}" to activate`);
5117
- return r.code === 0 ? { ok: true, focused: title } : { ok: false, error: String(r.stderr || '').trim() || `no app named ${title}` };
5118
- }
5119
- default:
5120
- return { ok: false, error: `unsupported on macOS: ${action}` };
5121
- }
5122
- }
5123
-
5124
- async function runHostUiLinux(action, body, shot) {
5125
- const xdo = whichSync('xdotool');
5126
- const need = (bin, hint) => ({ ok: false, error: `${hint} needs ${bin}; install it (e.g. apt install ${bin})` });
5127
- switch (action) {
5128
- case 'screenshot': {
5129
- const tool = firstExistingCommand(['gnome-screenshot', 'scrot', 'spectacle', 'import']);
5130
- if (!tool) return need('scrot', 'screenshot');
5131
- const argv = tool.name === 'gnome-screenshot' ? ['-f', shot.hostPath]
5132
- : tool.name === 'spectacle' ? ['-b', '-n', '-o', shot.hostPath]
5133
- : tool.name === 'import' ? ['-window', 'root', shot.hostPath]
5134
- : [shot.hostPath];
5135
- const r = await runCapture(tool.bin, argv, { shell: false, timeout: 20000 });
5136
- return r.code === 0 ? { ok: true, path: shot.hostPath, tool: tool.name } : { ok: false, error: String(r.stderr || 'capture failed').trim() };
5137
- }
5138
- case 'screen_size': {
5139
- if (!xdo) return need('xdotool', 'screen_size');
5140
- const r = await runCapture(xdo, ['getdisplaygeometry'], { shell: false, timeout: 10000 });
5141
- const [w, h] = String(r.stdout || '').trim().split(/\s+/).map(Number);
5142
- return w && h ? { ok: true, width: w, height: h } : { ok: false, error: 'could not read display geometry' };
5143
- }
5144
- case 'mouse_move':
5145
- case 'click':
5146
- case 'drag':
5147
- case 'scroll':
5148
- case 'type':
5149
- case 'key':
5150
- case 'windows':
5151
- case 'focus': {
5152
- if (!xdo) return need('xdotool', action);
5153
- const button = { left: 1, middle: 2, right: 3 }[String(body.button || 'left')] || 1;
5154
- const argvFor = {
5155
- mouse_move: ['mousemove', String(body.x ?? 0), String(body.y ?? 0)],
5156
- click: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'click', '--repeat', String(Math.max(1, Number(body.clicks) || 1)), String(button)],
5157
- drag: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'mousedown', '1', 'mousemove', String(body.toX ?? 0), String(body.toY ?? 0), 'mouseup', '1'],
5158
- scroll: ['click', '--repeat', String(Math.max(1, Math.abs(Number(body.amount) || 3))), (Number(body.amount) || 3) < 0 ? '5' : '4'],
5159
- type: ['type', '--delay', '12', '--', String(body.text || '')],
5160
- key: ['key', ...String(body.text || '').split(/\s+/).filter(Boolean)],
5161
- windows: ['search', '--onlyvisible', '--name', '.'],
5162
- focus: ['search', '--onlyvisible', '--name', String(body.title || ''), 'windowactivate'],
5163
- }[action];
5164
- const r = await runCapture(xdo, argvFor, { shell: false, timeout: 20000 });
5165
- if (action === 'windows') {
5166
- const ids = String(r.stdout || '').trim().split(/\s+/).filter(Boolean).slice(0, 40);
5167
- const titles = [];
5168
- for (const id of ids) {
5169
- const t = await runCapture(xdo, ['getwindowname', id], { shell: false, timeout: 5000 });
5170
- const title = String(t.stdout || '').trim();
5171
- if (title) titles.push({ title, id });
5172
- }
5173
- return { ok: true, windows: titles };
5174
- }
5175
- return r.code === 0 ? { ok: true, action } : { ok: false, error: String(r.stderr || '').trim() || `xdotool exited ${r.code}` };
5176
- }
5177
- case 'clipboard_get': {
5178
- const tool = firstExistingCommand(['wl-paste', 'xclip', 'xsel']);
5179
- if (!tool) return need('xclip', 'clipboard_get');
5180
- const argv = tool.name === 'xclip' ? ['-o', '-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-o'] : [];
5181
- const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000 });
5182
- return { ok: true, text: String(r.stdout || '') };
5183
- }
5184
- case 'clipboard_set': {
5185
- const tool = firstExistingCommand(['wl-copy', 'xclip', 'xsel']);
5186
- if (!tool) return need('xclip', 'clipboard_set');
5187
- const argv = tool.name === 'xclip' ? ['-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-i'] : [];
5188
- const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000, input: String(body.text || '') });
5189
- return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: `${tool.name} failed` };
5190
- }
5191
- default:
5192
- return { ok: false, error: `unsupported on Linux: ${action}` };
5193
- }
5194
- }
5195
5060
 
5196
- async function runHostUi(projectDir, body = {}) {
5197
- const action = String(body.action || '').trim();
5198
- if (!HOST_UI_ACTIONS.has(action)) {
5199
- return { status: 400, payload: { ok: false, error: `unknown action: ${action || '(none)'}`, actions: [...HOST_UI_ACTIONS] } };
5200
- }
5201
- const shot = action === 'screenshot' ? await hostUiScreenshotTarget(projectDir) : null;
5202
- let result;
5203
- try {
5204
- if (process.platform === 'win32') result = await runHostUiWindows(projectDir, action, body, shot);
5205
- else if (process.platform === 'darwin') result = await runHostUiMac(action, body, shot);
5206
- else result = await runHostUiLinux(action, body, shot);
5207
- } catch (err) {
5208
- result = { ok: false, error: err.message };
5209
- }
5210
- if (shot && result?.ok) {
5211
- // The project folder is bind-mounted into the container, so hand back the path the bot can
5212
- // actually open — otherwise it gets a Windows path it cannot read and reports failure.
5213
- result.path = shot.hostPath;
5214
- result.containerPath = shot.containerPath;
5215
- result.bytes = existsSync(shot.hostPath) ? (await fsp.stat(shot.hostPath)).size : 0;
5216
- }
5217
- sendLog(`[host-control] UI "${action}" → ${result?.ok ? 'ok' : `lỗi: ${result?.error || 'unknown'}`}`);
5218
- return { status: result?.ok ? 200 : 500, payload: result };
5219
- }
5220
5061
 
5221
- async function handleHostControl(req, res, projectDir) {
5222
- const cfg = await readHostControlConfig(projectDir);
5223
- const url = new URL(req.url, 'http://localhost');
5224
- const presented = req.headers['x-openclaw-token'] || url.searchParams.get('token') || '';
5225
- if (!cfg.enabled) return json(res, { ok: false, error: 'host control is disabled' }, 403);
5226
- if (presented !== cfg.token) return json(res, { ok: false, error: 'invalid token' }, 401);
5227
5062
 
5228
- if (url.pathname === '/api/browser/start-chrome' && req.method === 'POST') {
5229
- try {
5230
- return json(res, await startChromeDebug());
5231
- } catch (err) {
5232
- return json(res, { ok: false, error: err.message }, err.status || 500);
5233
- }
5234
- }
5235
- if (url.pathname === '/api/host/apps' && req.method === 'GET') {
5236
- return json(res, { ok: true, apps: Object.keys(cfg.apps || {}), commands: Object.keys(cfg.commands || {}), platform: process.platform });
5237
- }
5238
- if (url.pathname === '/api/host/ui' && req.method === 'POST') {
5239
- // Part of PC control, but its own switch: seeing the screen and moving the pointer is a bigger
5240
- // step than opening an app, so it only answers once the operator has granted capabilities.
5241
- if (cfg.ui !== true) {
5242
- 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);
5243
- }
5244
- const body = await readJson(req).catch(() => ({}));
5245
- const { status, payload } = await runHostUi(projectDir, body || {});
5246
- return json(res, payload, status);
5247
- }
5248
- if (url.pathname === '/api/host/exec' && req.method === 'POST') {
5249
- const body = await readJson(req).catch(() => ({}));
5250
- const name = String(body.command || '').trim().toLowerCase();
5251
- if (!name) return json(res, { ok: false, error: 'missing "command"' }, 400);
5252
- const bin = (cfg.commands || {})[name];
5253
- if (!bin) {
5254
- return json(res, {
5255
- ok: false,
5256
- error: `"${name}" is not in this machine's command list`,
5257
- commands: Object.keys(cfg.commands || {}),
5258
- }, 404);
5259
- }
5260
- // Args are passed literally (spawn with shell:false) so nothing in them is re-interpreted
5261
- // by a shell — the executable is fixed to the allow-listed path, callers cannot pick a
5262
- // different binary or inject a second command.
5263
- const args = Array.isArray(body.args) ? body.args.map((a) => String(a)) : [];
5264
- const input = body.input != null ? String(body.input) : null;
5265
- const timeoutMs = Math.min(Math.max(Number(body.timeoutMs) || 180000, 1000), 600000);
5266
- return runHostCommand(res, name, bin, args, input, timeoutMs);
5267
- }
5268
- if (url.pathname === '/api/host/open' && req.method === 'POST') {
5269
- const body = await readJson(req).catch(() => ({}));
5270
- const key = String(body.app || body.target || '').trim();
5271
- if (!key) return json(res, { ok: false, error: 'missing "app"' }, 400);
5272
- const path = (cfg.apps || {})[key.toLowerCase()];
5273
- if (!path) {
5274
- return json(res, {
5275
- ok: false,
5276
- error: `"${key}" is not in this machine's app list`,
5277
- apps: Object.keys(cfg.apps || {}),
5278
- }, 404);
5279
- }
5280
- openHostApp(path);
5281
- sendLog(`[host-control] Đã mở "${key}" trên máy (${path}).`);
5282
- return json(res, { ok: true, app: key, path });
5283
- }
5284
- return json(res, { ok: false, error: 'unknown endpoint' }, 404);
5285
- }
5063
+
5286
5064
 
5287
5065
  /**
5288
- * Teach every bot in the project how to reach the host-control service, and hand it the
5289
- * token. Written into TOOLS.md as a managed block so flipping the switch off removes it
5290
- * again a bot that still had the instructions would keep trying an endpoint that now
5291
- * refuses. `host.docker.internal` resolves in the container on every OS because the
5292
- * 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.
5293
5076
  */
5294
5077
  async function writeHostControlAccess(projectDir, cfg) {
5295
5078
  const openclawDir = join(projectDir, '.openclaw');
5296
5079
  if (!existsSync(openclawDir)) return;
5297
- const native = isNativeProject(projectDir);
5298
- // Native bots run on the host itself; host.docker.internal only resolves from inside a container,
5299
- // so a native bot curling it fails ("could not connect"). Use loopback there instead.
5300
- const base = native ? `http://127.0.0.1:${HOST_CONTROL_PORT}` : `http://host.docker.internal:${HOST_CONTROL_PORT}`;
5301
- const apps = Object.keys(cfg.apps || {});
5302
- const commands = Object.keys(cfg.commands || {});
5303
- const execBlock = commands.length ? [
5304
- '',
5305
- '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}`).',
5306
- 'Dùng để giao việc cho công cụ dòng lệnh, ví dụ Claude Code:',
5307
- '',
5308
- '```sh',
5309
- `curl -s -X POST ${base}/api/host/exec -H "x-openclaw-token: ${cfg.token}" \\`,
5310
- ' -H "content-type: application/json" -d \'{"command":"claude","args":["-p","tóm tắt repo hiện tại"]}\'',
5311
- '```',
5312
- '',
5313
- `Lệnh khả dụng: ${commands.map((c) => `\`${c}\``).join(', ')}. Lệnh mặc định timeout 180s, output tối đa ~200KB/luồng.`,
5314
- ] : [];
5315
- // Desktop actions: one endpoint, same JSON on every OS, so the bot does not need per-platform
5316
- // instructions. Screenshots land in the project folder, which the container already sees.
5317
- const uiBlock = cfg.ui === true ? [
5318
- '',
5319
- '### Thao tác trên màn hình chủ',
5320
- '',
5321
- '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** —',
5322
- 'đừng đoán vị trí. Toạ độ tính bằng pixel màn hình, gốc ở góc trên-trái.',
5323
- '',
5324
- '```sh',
5325
- `curl -s -X POST ${base}/api/host/ui -H "x-openclaw-token: ${cfg.token}" \\`,
5326
- ' -H "content-type: application/json" -d \'{"action":"screenshot"}\'',
5327
- '```',
5328
- '',
5329
- 'Trả về `containerPath` — **đọc/gửi ảnh bằng đường dẫn đó** (nằm trong project nên bạn thấy được),',
5330
- 'kèm `width`/`height` để biết màn hình bao lớn.',
5331
- '',
5332
- 'Các action khác (cùng dạng `{"action":...}`):',
5333
- '',
5334
- '- `screen_size` — kích thước màn hình',
5335
- '- `mouse_move` + `x`,`y` — di chuột',
5336
- '- `click` + `x`,`y`, tuỳ chọn `button` (`left`/`right`/`middle`) và `clicks` (2 = double-click)',
5337
- '- `drag` + `x`,`y`,`toX`,`toY` — kéo thả',
5338
- '- `scroll` + `amount` (âm = xuống), tuỳ chọn `x`,`y`',
5339
- '- `type` + `text` — gõ chữ vào cửa sổ đang focus',
5340
- '- `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',
5341
- '- `clipboard_get` / `clipboard_set` + `text` — đọc/ghi clipboard',
5342
- '- `windows` — liệt kê cửa sổ đang mở; `focus` + `title` — đưa cửa sổ lên trước',
5343
- '',
5344
- 'Nếu trả về lỗi "no desktop session available" thì installer đang chạy ngoài phiên desktop —',
5345
- 'nói chủ mở lại installer trong máy, đừng thử cách khác.',
5346
- 'Trên Linux, thiếu `xdotool`/`scrot` thì endpoint nói rõ cần cài gì — báo lại cho chủ.',
5347
- ] : [];
5348
- // Screen capture / recording — only advertised when the operator granted the matching tool, so
5349
- // the bot never tries a binary that is not on this machine's allow-list.
5350
- // Windows has no capture binary to allow-list (PowerShell does it inline), so the section shows
5351
- // up there too — a native bot runs the command itself, the allow-list only gates the bridge.
5352
- const hasCapture = commands.includes('screencapture') || commands.includes('screenshot') || commands.includes('ffmpeg') || (native && process.platform === 'win32');
5353
- const captureBlock = hasCapture ? [
5354
- '',
5355
- '### Chụp / quay màn hình',
5356
- '',
5357
- ...(commands.includes('screencapture') ? [
5358
- '- 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ổ).',
5359
- '- Quay: `screencapture -v -V 10 /tmp/rec.mov` (quay 10 giây rồi tự dừng).',
5360
- ] : []),
5361
- ...(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.'] : []),
5362
- ...(native && process.platform === 'win32' ? [
5363
- '- 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).',
5364
- ] : []),
5365
- ...(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`).'] : []),
5366
- '',
5367
- '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.',
5368
- ] : [];
5369
- const scriptCommands = commands.filter((c) => c === 'node' || c === 'npx' || c === 'codex' || c === 'claude');
5370
- const scriptBlock = scriptCommands.length ? [
5371
- '',
5372
- '### Chạy script & giao việc cho CLI khác',
5373
- '',
5374
- `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\`).`,
5375
- ...(commands.includes('codex') ? [
5376
- '- 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.',
5377
- ] : []),
5378
- ...(commands.includes('claude') ? [
5379
- '- Giao việc cho **Claude Code**: `claude -p "việc cần làm"` (một lượt, trả stdout).',
5380
- ] : []),
5381
- 'Đâ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.',
5382
- ] : [];
5383
5080
  const startTag = '<!-- OPENCLAW:HOST_CONTROL:START -->';
5384
5081
  const endTag = '<!-- OPENCLAW:HOST_CONTROL:END -->';
5385
- // NATIVE: the bot runs directly on the host with `exec`, so it opens apps with the OS command —
5386
- // no bridge, no host.docker.internal (which doesn't resolve off-container anyway). DOCKER: the
5387
- // bot is in a container and can't see the desktop, so it must call the installer's host service.
5388
- const nativeBlock = [
5082
+ const block = [
5389
5083
  startTag,
5390
5084
  '',
5391
- '## 🖥️ Điều khiển máy của chủ (host control — chế độ native)',
5392
- '',
5393
- '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):',
5394
- '',
5395
- '- macOS: `open -a "<Tên app>"` — ví dụ `open -a "TeamViewer"`',
5396
- '- Linux: `xdg-open <app|url>` hoặc chạy binary trực tiếp',
5397
- ...(process.platform === 'win32' ? [
5398
- '- Windows: **đừng** gọi `Start-Process <TênApp>` — tên app KHÔNG phải lệnh trên PATH, bạn sẽ nhận',
5399
- ' `The system cannot find the file specified`. Hãy tra shortcut trong Start Menu rồi mở nó:',
5400
- '',
5401
- '```sh',
5402
- 'powershell -NoProfile -Command "$n=\'TeamViewer\'; $r=@(\"$env:ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\",\"$env:APPDATA\\Microsoft\\Windows\\Start Menu\\Programs\"); $l=Get-ChildItem $r -Recurse -Filter \"*$n*.lnk\" -EA SilentlyContinue | Select -First 1; if($l){ Start-Process $l.FullName } else { \"khong tim thay $n\" }"',
5403
- '```',
5404
- '',
5405
- 'Đổi `TeamViewer` thành tên app cần mở. Cách này đúng với MỌI app đã cài, kể cả app chủ mới cài hôm qua,',
5406
- 'vì Windows luôn đặt shortcut vào Start Menu và `.lnk` tự biết đường dẫn thật.',
5407
- ] : ['- Windows: `cmd /c start "" "<app>"`']),
5408
- '',
5409
- apps.length ? `App có trên máy này: ${apps.map((a) => `\`${a}\``).join(', ')}.` : 'Chủ chưa liệt kê app — mở theo tên app có trên máy.',
5410
- ...captureBlock,
5411
- ...scriptBlock,
5412
- '',
5413
- ...(commands.includes('codex') ? [
5414
- '### Điều khiển chuột/bàn phím & nhìn màn hình',
5415
- '',
5416
- '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):',
5417
- '',
5418
- '```sh',
5419
- 'bin/pc-task.sh "mở TeamViewer, đọc mật khẩu đang hiện trên màn hình rồi in ra"',
5420
- '```',
5421
- '',
5422
- '⚠️ **Đừ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 đủ).',
5423
- '',
5424
- '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 ý:',
5425
- '',
5426
- '- 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.',
5427
- '- Việc GUI có thể chạy lâu: đặt timeout rộng, đừng kết luận thất bại sớm.',
5428
- '- 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.',
5429
- '- Đ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.',
5430
- '- 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.',
5431
- '',
5432
- ] : []),
5433
- '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ử".',
5434
- '',
5435
- endTag,
5436
- '',
5437
- ].join('\n');
5438
- const dockerBlock = [
5439
- startTag,
5085
+ '## 🖥️ Điều khiển máy của chủ',
5440
5086
  '',
5441
- '## 🖥️ Điều khiển máy của chủ (host control)',
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:',
5442
5089
  '',
5443
- '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`:',
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` - rê chuột, bấm, gõ phím, kéo thả, cuộn, nhấn tổ hợp phím.',
5444
5092
  '',
5445
- '```sh',
5446
- `curl -s -X POST ${base}/api/browser/start-chrome -H "x-openclaw-token: ${cfg.token}"`,
5447
- '```',
5093
+ 'Cách mở một ứng dụng, ví 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.',
5448
5096
  '',
5449
- 'Mở ứng dụng (chỉ những app trong danh sách của máy):',
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ó.',
5450
5099
  '',
5451
- '```sh',
5452
- `curl -s -X POST ${base}/api/host/open -H "x-openclaw-token: ${cfg.token}" \\`,
5453
- ' -H "content-type: application/json" -d \'{"app":"teamviewer"}\'',
5454
- '```',
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ủ và nói rõ',
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.',
5455
5103
  '',
5456
- 'Xem danh sách app đang được phép:',
5457
- '',
5458
- '```sh',
5459
- `curl -s ${base}/api/host/apps -H "x-openclaw-token: ${cfg.token}"`,
5460
- '```',
5461
- '',
5462
- 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`.',
5463
- ...execBlock,
5464
- ...uiBlock,
5465
- // Docker only: a screenshot taken on the host lands on the HOST filesystem, which this
5466
- // container cannot read — say so instead of letting the bot hunt for a missing file.
5467
- ...(hasCapture ? [
5468
- '',
5469
- '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.',
5470
- ] : []),
5471
- '',
5472
- '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,',
5473
- 'đừ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, và không gõ mật khẩu hay thông tin thanh toán vào bất cứ đâu.',
5474
5106
  '',
5475
5107
  endTag,
5476
5108
  '',
5477
5109
  ].join('\n');
5478
- const block = native ? nativeBlock : dockerBlock;
5479
5110
  for (const entry of await fsp.readdir(openclawDir).catch(() => [])) {
5480
5111
  if (!entry.startsWith('workspace')) continue;
5481
5112
  const toolsMd = join(openclawDir, entry, 'TOOLS.md');
@@ -5499,46 +5130,6 @@ function removeManagedBlockFrom(content, blockId) {
5499
5130
  return `${content.substring(0, startIdx).trimEnd()}\n${content.substring(endIdx + endTag.length).trimStart()}`.trim() + '\n';
5500
5131
  }
5501
5132
 
5502
- async function ensureHostControl(projectDir) {
5503
- // Point the service at the project being enabled (re-points a service already running for
5504
- // another project — the handler reads _hostControlProjectDir per request).
5505
- _hostControlProjectDir = projectDir;
5506
- const cfg = await readHostControlConfig(projectDir);
5507
- if (!cfg.enabled) return { ok: false, reason: 'disabled' };
5508
- // Desktop only. Opening TeamViewer or an app needs a GUI, so a headless server has nothing
5509
- // to control — and, more importantly, it is where 0.0.0.0 would be a real exposure (a VPS
5510
- // has a public IP). Refusing here means the service never binds on a headless box, so the
5511
- // public-exposure question does not arise. A rare VPS-with-desktop can override with
5512
- // OPENCLAW_HOST_CONTROL_ALLOW_HEADLESS=1.
5513
- if (isHeadlessServer() && process.env.OPENCLAW_HOST_CONTROL_ALLOW_HEADLESS !== '1') {
5514
- return { ok: false, reason: 'headless server — no desktop to control' };
5515
- }
5516
- if (_hostControlServer) return { ok: true, port: HOST_CONTROL_PORT };
5517
- const bridgeIp = await getDockerBridgeIp().catch(() => null);
5518
- const server = http.createServer((req, res) => {
5519
- // Read the CURRENTLY active project each request, so re-pointing takes effect live.
5520
- handleHostControl(req, res, _hostControlProjectDir || projectDir).catch((err) => json(res, { ok: false, error: err.message }, 500));
5521
- });
5522
- // Bind all interfaces: the container reaches the host by different addresses per platform —
5523
- // docker0 (172.17.0.1) on native Linux, the Docker Desktop gateway (host.docker.internal,
5524
- // e.g. 192.168.65.254) on macOS/Windows — and binding one misses the others. The token is
5525
- // the guard here, not the interface: every request needs it, and the service only exists
5526
- // while the operator has host control switched on.
5527
- const bindOk = await new Promise((resolveP) => {
5528
- server.once('error', () => resolveP(false));
5529
- server.listen(HOST_CONTROL_PORT, '0.0.0.0', () => resolveP(true));
5530
- });
5531
- if (!bindOk) return { ok: false, reason: `port ${HOST_CONTROL_PORT} in use` };
5532
- _hostControlServer = server;
5533
- 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.`);
5534
- if (bridgeIp && process.platform === 'linux') {
5535
- // ufw's default-deny drops container→host traffic silently. Scope the allow rule to the
5536
- // private bridge address only, so opening the port here does not expose it to the LAN.
5537
- 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`])
5538
- .catch(() => {});
5539
- }
5540
- return { ok: true, port: HOST_CONTROL_PORT, host: '0.0.0.0' };
5541
- }
5542
5133
 
5543
5134
  async function ensureChromeRelay() {
5544
5135
  if (_chromeRelayServer) return true;
@@ -5558,7 +5149,7 @@ async function ensureChromeRelay() {
5558
5149
  // traffic to the relay. Open the port scoped to the PRIVATE bridge IP only (not reachable
5559
5150
  // from the internet). Best-effort; `ufw allow` skips duplicates on re-runs.
5560
5151
  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 9222 proto tcp comment "openclaw chrome-debug relay (docker bridge only)" || true`])
5561
- .catch(() => sendLog('[chrome] Không thể tự mở firewall cho relay nếu bot không thấy Chrome, chạy: ufw allow in to ' + bridgeIp + ' port 9222 proto tcp'));
5152
+ .catch(() => sendLog('[chrome] Không thể tự mở firewall cho relay - nếu bot không thấy Chrome, chạy: ufw allow in to ' + bridgeIp + ' port 9222 proto tcp'));
5562
5153
  resolveP(true);
5563
5154
  });
5564
5155
  });
@@ -5567,9 +5158,9 @@ async function ensureChromeRelay() {
5567
5158
  // Launch real host Chrome in remote-debugging mode (port 9222) so the browser-automation plugin
5568
5159
  // can drive the user's actual Chrome (logged-in profile) instead of headless Chromium. The bot
5569
5160
  // reaches it via CDP (host.docker.internal:9222 from the container). Detached: keeps running after
5570
- // this request. The debug port stays on loopback and no origin wildcard is passed a Node CDP
5161
+ // this request. The debug port stays on loopback and no origin wildcard is passed - a Node CDP
5571
5162
  // client sends no Origin header, so the wildcard only widened who could drive the browser.
5572
- // On a headless VPS there is no Chrome to open here instead we start the bridge relay and hand
5163
+ // On a headless VPS there is no Chrome to open here - instead we start the bridge relay and hand
5573
5164
  // back copy-paste commands so the user runs Chrome on THEIR machine + a reverse SSH tunnel.
5574
5165
  // Where Chrome keeps the operator's own profile, per OS. Chrome must not already be running
5575
5166
  // on it when we attach the debug port, which is why the callers close Chrome first.
@@ -5586,7 +5177,7 @@ function defaultChromeProfileDir() {
5586
5177
 
5587
5178
  // The profile Chrome is actually launched with. Never the directory above: Chrome 136+ drops
5588
5179
  // --remote-debugging-port when it IS the default profile, so pointing there means Chrome opens
5589
- // and port 9222 never answers the failure the bot reports as "Chrome debug not connected".
5180
+ // and port 9222 never answers - the failure the bot reports as "Chrome debug not connected".
5590
5181
  function debugChromeProfileDir() {
5591
5182
  if (process.platform === 'win32') {
5592
5183
  const localAppData = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
@@ -5598,7 +5189,7 @@ function debugChromeProfileDir() {
5598
5189
 
5599
5190
  // Seed it from the real profile once, so the bot inherits the operator's cookies, logins,
5600
5191
  // history and extensions instead of browsing as a brand-new profile (the clearest bot signal
5601
- // a site can read). Caches are skipped Chrome rebuilds those, and copying them turns a few
5192
+ // a site can read). Caches are skipped - Chrome rebuilds those, and copying them turns a few
5602
5193
  // hundred MB into several GB. Best-effort: a profile that fails to copy still opens, just
5603
5194
  // signed out.
5604
5195
  async function copyChromeProfileTree(src, dst) {
@@ -5666,8 +5257,8 @@ async function startChromeDebug() {
5666
5257
  }
5667
5258
  const port = 9222;
5668
5259
  // Launch against a dedicated profile seeded from the operator's real one. A throwaway
5669
- // profile is the clearest bot signal a site can read no cookies, no logins, no history,
5670
- // new on every run and it also means the bot cannot use pages the operator is already
5260
+ // profile is the clearest bot signal a site can read - no cookies, no logins, no history,
5261
+ // new on every run - and it also means the bot cannot use pages the operator is already
5671
5262
  // signed in to; the real profile itself cannot be used because Chrome 136+ drops the debug
5672
5263
  // port on it. The port is not what gets flagged: Chrome started this way carries no
5673
5264
  // --enable-automation, so navigator.webdriver stays false and there is no banner.
@@ -5678,7 +5269,7 @@ async function startChromeDebug() {
5678
5269
  if (process.env.OPENCLAW_CHROME_SEED_PROFILE === '1') {
5679
5270
  await seedDebugChromeProfile(defaultChromeProfileDir(), userDataDir, sendLog);
5680
5271
  } else if (!existsSync(join(userDataDir, 'Default'))) {
5681
- sendLog('[chrome] Mở Chrome với profile điều khiển trống đăng nhập 1 lần trong cửa sổ vừa mở. Muốn dùng sẵn đăng nhập của Chrome thường thì đặt OPENCLAW_CHROME_SEED_PROFILE=1 (sẽ chép cookie/đăng nhập/lịch sử sang profile đó).');
5272
+ sendLog('[chrome] Mở Chrome với profile điều khiển trống - đăng nhập 1 lần trong cửa sổ vừa mở. Muốn dùng sẵn đăng nhập của Chrome thường thì đặt OPENCLAW_CHROME_SEED_PROFILE=1 (sẽ chép cookie/đăng nhập/lịch sử sang profile đó).');
5682
5273
  }
5683
5274
  const args = [
5684
5275
  `--remote-debugging-port=${port}`,
@@ -5710,7 +5301,7 @@ async function waitForDockerDaemon(timeoutMs) {
5710
5301
 
5711
5302
  // OpenClaw's own engines range, copied verbatim from its package.json:
5712
5303
  // ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0"
5713
- // The gaps matter Node 23.x and 25.025.8 are NOT usable so "install the latest Node" is the
5304
+ // The gaps matter - Node 23.x and 25.0-25.8 are NOT usable - so "install the latest Node" is the
5714
5305
  // wrong instinct here. When we have to install, we install 24 LTS, which sits inside a supported
5715
5306
  // window and is what every working host in the fleet already runs.
5716
5307
  const NODE_TARGET_MAJOR = 24;
@@ -5725,7 +5316,7 @@ function nodeVersionSupported(raw) {
5725
5316
  return false;
5726
5317
  }
5727
5318
 
5728
- // Native mode runs openclaw straight off the host, so the host's Node IS the runtime unlike
5319
+ // Native mode runs openclaw straight off the host, so the host's Node IS the runtime - unlike
5729
5320
  // docker mode, where the image carried its own. A customer machine that never needed Node before
5730
5321
  // (or carries one outside openclaw's supported range) must get a usable one before anything else,
5731
5322
  // otherwise the install dies deep inside `npm i -g openclaw` with an unreadable engine error.
@@ -5738,7 +5329,7 @@ async function ensureNodeInstalled() {
5738
5329
  const why = current.ok
5739
5330
  ? `Node ${current.output.trim()} nằm ngoài dải OpenClaw hỗ trợ`
5740
5331
  : 'Máy chưa có Node';
5741
- sendLog(`[node] ${why} đang cài Node ${NODE_TARGET_MAJOR} LTS...`);
5332
+ sendLog(`[node] ${why} - đang cài Node ${NODE_TARGET_MAJOR} LTS...`);
5742
5333
 
5743
5334
  if (process.platform === 'linux') {
5744
5335
  const root = typeof process.getuid === 'function' && process.getuid() === 0;
@@ -5748,7 +5339,7 @@ async function ensureNodeInstalled() {
5748
5339
  await run('sh', ['-c',
5749
5340
  `curl -fsSL https://deb.nodesource.com/setup_${NODE_TARGET_MAJOR}.x | ${sudo}bash - && ${sudo}apt-get install -y nodejs`,
5750
5341
  ]).catch(async () => {
5751
- sendLog('[node] apt không dùng được thử nvm...');
5342
+ sendLog('[node] apt không dùng được - thử nvm...');
5752
5343
  await run('sh', ['-c',
5753
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}`,
5754
5345
  ]);
@@ -5796,7 +5387,7 @@ async function ensureDockerInstalled(osChoice) {
5796
5387
  const root = typeof process.getuid === 'function' && process.getuid() === 0;
5797
5388
  const sudo = root ? '' : 'sudo ';
5798
5389
  if (!cliOk.ok) {
5799
- sendLog('[docker] Chưa có Docker đang tự cài Docker Engine mới nhất qua script chính thức get.docker.com (13 phút)...');
5390
+ sendLog('[docker] Chưa có Docker - đang tự cài Docker Engine mới nhất qua script chính thức get.docker.com (1-3 phút)...');
5800
5391
  await run('sh', ['-c', `curl -fsSL https://get.docker.com -o /tmp/get-docker.sh && ${sudo}sh /tmp/get-docker.sh`]);
5801
5392
  if (!root) await run('sh', ['-c', 'sudo usermod -aG docker "$USER" || true']).catch(() => {});
5802
5393
  }
@@ -5821,7 +5412,7 @@ async function ensureDockerInstalled(osChoice) {
5821
5412
  sendLog('[docker] Mở Docker Desktop và chờ daemon khởi động...');
5822
5413
  await run('open', ['-a', 'Docker']).catch(() => {});
5823
5414
  if (!(await waitForDockerDaemon(120000))) {
5824
- throw httpError(500, 'Đã cài Docker Desktop hãy mở Docker Desktop, hoàn tất cấp quyền lần đầu, đợi biểu tượng cá voi báo "running" rồi cài lại.');
5415
+ throw httpError(500, 'Đã cài Docker Desktop - hãy mở Docker Desktop, hoàn tất cấp quyền lần đầu, đợi biểu tượng cá voi báo "running" rồi cài lại.');
5825
5416
  }
5826
5417
  sendLog('[docker] Docker đã sẵn sàng.');
5827
5418
  return;
@@ -5844,7 +5435,7 @@ async function ensureDockerInstalled(osChoice) {
5844
5435
  sendLog('[docker] Mở Docker Desktop và chờ daemon khởi động...');
5845
5436
  await run('cmd', ['/c', 'start', '', '%ProgramFiles%\\Docker\\Docker\\Docker Desktop.exe']).catch(() => {});
5846
5437
  if (!(await waitForDockerDaemon(120000))) {
5847
- throw httpError(500, 'Đã cài Docker Desktop Windows có thể cần bật WSL2 và khởi động lại máy. Hãy mở Docker Desktop, đợi "running" (hoặc reboot nếu được yêu cầu) rồi cài lại.');
5438
+ throw httpError(500, 'Đã cài Docker Desktop - Windows có thể cần bật WSL2 và khởi động lại máy. Hãy mở Docker Desktop, đợi "running" (hoặc reboot nếu được yêu cầu) rồi cài lại.');
5848
5439
  }
5849
5440
  sendLog('[docker] Docker đã sẵn sàng.');
5850
5441
  return;
@@ -5862,7 +5453,7 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
5862
5453
  state.os = osChoice;
5863
5454
  state.startedAt = new Date().toISOString();
5864
5455
  try {
5865
- // Native binds the host directly, so it needs ports nothing else holds but only when something
5456
+ // Native binds the host directly, so it needs ports nothing else holds - but only when something
5866
5457
  // actually holds them. Ask the host rather than assuming: a fresh machine keeps openclaw's and
5867
5458
  // 9router's real defaults, and a machine that already runs a docker project (or an SSH tunnel to
5868
5459
  // a remote bot) steps to the next free pair instead.
@@ -5877,16 +5468,16 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
5877
5468
  }
5878
5469
  sendLog('OpenClaw local installer started');
5879
5470
  sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
5880
- // Make sure Docker is present (auto-install on Linux/VPS) before doing any work fail fast
5471
+ // Make sure Docker is present (auto-install on Linux/VPS) before doing any work - fail fast
5881
5472
  // with a clear message rather than deep inside `docker compose up`. Native mode has no
5882
5473
  // container, so it skips this entirely (that is much of the point of choosing it).
5883
- // Native has no image to carry a runtime, so the host's Node IS the runtime get a supported
5474
+ // Native has no image to carry a runtime, so the host's Node IS the runtime - get a supported
5884
5475
  // one on the machine before npm touches anything. Docker still needs the daemon instead.
5885
5476
  if (mode === 'native') await ensureNodeInstalled();
5886
5477
  else await ensureDockerInstalled(osChoice);
5887
5478
  await writeCoreProject({ projectDir, osChoice, mode, gatewayPort, routerPort, userTimezone });
5888
5479
  await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
5889
- // The global runtime just changed every version gate after this point (notably
5480
+ // The global runtime just changed - every version gate after this point (notably
5890
5481
  // prepareNativeStateHome) must read the NEW version, not the one this host booted with.
5891
5482
  invalidateHostOpenclawVersion();
5892
5483
  await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
@@ -5989,7 +5580,7 @@ async function listMarkdownFiles(projectDir, agentId = '') {
5989
5580
 
5990
5581
  async function saveState(rootProjectDir) {
5991
5582
  // Selecting, adding or removing a project all end up here, and all of them make the cached
5992
- // project list wrong drop it so the next request rebuilds instead of showing the old set.
5583
+ // project list wrong - drop it so the next request rebuilds instead of showing the old set.
5993
5584
  probeCacheClear('projects:');
5994
5585
  const file = join(rootProjectDir, STATE_FILE);
5995
5586
  await fsp.writeFile(file, JSON.stringify({
@@ -6068,7 +5659,7 @@ function isRestrictedSystemDir(dirPath) {
6068
5659
  }
6069
5660
 
6070
5661
  // Project roots of OpenClaw bot containers currently running under Docker. This is the
6071
- // strongest, OS/-environment-agnostic signal that a real project lives on this machine
5662
+ // strongest, OS/-environment-agnostic signal that a real project lives on this machine -
6072
5663
  // so a fresh `npx github:…` run (e.g. on a VPS where bots are already running) targets the
6073
5664
  // live project instead of defaulting to an empty ~/openclaw-setup folder.
6074
5665
  async function discoverDockerBotProjectRoots() {
@@ -6100,19 +5691,19 @@ async function discoverDockerBotProjectRoots() {
6100
5691
 
6101
5692
  // Native installs have no container to inspect, so we can't detect them the way Docker bots are
6102
5693
  // found. Instead scan for the `.openclaw/native.json` marker one level under the home dir and the
6103
- // launcher's parent that covers the folders users actually pick (e.g. ~/openclaw-native, D:\bot)
5694
+ // launcher's parent - that covers the folders users actually pick (e.g. ~/openclaw-native, D:\bot)
6104
5695
  // without a full filesystem walk. Mirrors discoverDockerBotProjectRoots so discoverProjects can
6105
5696
  // surface native projects even when this install has no saved state for them.
6106
5697
  // A directory whose .openclaw merely HOLDS another project's state is not a project of its
6107
5698
  // own. openclaw 2026.8.x native layout puts the real state at ~/.openclaw with the project
6108
- // dir symlinked to it without this filter the account home shows up as a phantom "root"
5699
+ // dir symlinked to it - without this filter the account home shows up as a phantom "root"
6109
5700
  // project tab after every refresh (measured on vps_c-thu). The native marker's `label` was
6110
5701
  // written from the ORIGINAL project dir, so a mismatch identifies the phantom.
6111
5702
  /**
6112
5703
  * Two paths can be the same project: a native install links the account home and the project dir
6113
5704
  * together, so `~/.openclaw` and `<project>/.openclaw` resolve to one directory on disk. Scanning
6114
5705
  * finds both and the dashboard then lists the SAME bots twice under two project names (seen on
6115
- * win_kha as "bot" and "VT 2025", 4 identical bots online in each 10/09/2026).
5706
+ * win_kha as "bot" and "VT 2025", 4 identical bots online in each - 10/09/2026).
6116
5707
  *
6117
5708
  * Dedupe on where `.openclaw` really lands, and keep the entry that IS the real directory: the
6118
5709
  * link side is an artefact of the install layout, not somewhere the customer put a project.
@@ -6140,7 +5731,7 @@ function isPhantomStateDirProject(dir) {
6140
5731
  try {
6141
5732
  const meta = readNativeMeta(dir);
6142
5733
  if (!meta || !meta.label) return false;
6143
- // Compare against the label DERIVED from the directory name nativeServiceLabel()
5734
+ // Compare against the label DERIVED from the directory name - nativeServiceLabel()
6144
5735
  // itself returns meta.label first, which would make this check always pass.
6145
5736
  const derived = `ai.openclaw.gateway.${slugify(basename(dir || 'openclaw'), 'bot')}`;
6146
5737
  return meta.label !== derived;
@@ -6246,7 +5837,7 @@ async function ensureProjectsLoaded(rootProjectDir) {
6246
5837
  }
6247
5838
 
6248
5839
  // The project list costs docker/native probes per project. It changes when someone creates or
6249
- // deletes a project not between two page loads so serve it from a short cache and refresh in
5840
+ // deletes a project - not between two page loads - so serve it from a short cache and refresh in
6250
5841
  // the background: the dashboard opens instantly and is at most a few seconds stale.
6251
5842
  const PROJECTS_TTL_MS = 10000;
6252
5843
  function discoverProjects(rootProjectDir) {
@@ -6265,7 +5856,7 @@ async function computeDiscoverProjects(rootProjectDir) {
6265
5856
  }
6266
5857
  }
6267
5858
 
6268
- // Same idea for native installs detect by marker since there is no container to inspect.
5859
+ // Same idea for native installs - detect by marker since there is no container to inspect.
6269
5860
  for (const dr of await discoverNativeProjectRoots(rootProjectDir)) {
6270
5861
  if (!state.projects.some(p => resolve(p.projectDir) === resolve(dr))) {
6271
5862
  const meta = await buildProjectMeta(dr).catch(() => null);
@@ -6282,11 +5873,11 @@ async function computeDiscoverProjects(rootProjectDir) {
6282
5873
  }
6283
5874
 
6284
5875
  // Drop phantom state-dir "projects" that slipped into the saved list (e.g. the account
6285
- // home after a 2026.8.x native state move) and keep them out of the persisted state.
5876
+ // home after a 2026.8.x native state move) - and keep them out of the persisted state.
6286
5877
  state.projects = state.projects.filter((p) => !isPhantomStateDirProject(p.projectDir));
6287
5878
  // The label check above only catches projects that carry a marker with a mismatched label.
6288
5879
  // A linked twin without one still slips through and the dashboard shows the same bots under
6289
- // two project names, both "online" collapse those onto the real directory.
5880
+ // two project names, both "online" - collapse those onto the real directory.
6290
5881
  const keepDirs = new Set(dedupeProjectsByRealState(state.projects.map((p) => p.projectDir)));
6291
5882
  state.projects = state.projects.filter((p) => keepDirs.has(p.projectDir));
6292
5883
 
@@ -6312,7 +5903,7 @@ async function computeDiscoverProjects(rootProjectDir) {
6312
5903
  }
6313
5904
 
6314
5905
  async function resolveProjectDir(rootProjectDir, body = {}) {
6315
- // Every acceptance path below must refuse phantom state-dir "projects" the browser can
5906
+ // Every acceptance path below must refuse phantom state-dir "projects" - the browser can
6316
5907
  // keep sending a remembered projectDir (e.g. "/root") long after it stopped being one,
6317
5908
  // and accepting it re-saves the phantom into state on the next saveState().
6318
5909
  if (body.projectDir && existsSync(join(resolve(String(body.projectDir)), '.openclaw', 'openclaw.json'))
@@ -6372,8 +5963,8 @@ async function connectExistingProject(projectDir, rootProjectDir) {
6372
5963
  if (!existsSync(join(resolved, '.openclaw', 'openclaw.json'))) throw httpError(404, 'openclaw.json not found in selected project');
6373
5964
  if (isPhantomStateDirProject(resolved)) throw httpError(400, `${resolved} chỉ là nơi chứa state của project khác (symlink 2026.8.x), không phải project riêng`);
6374
5965
  // Switch the active project + return its bots FAST (a plain file read). The heavy runtime
6375
- // probing detectRuntime runs `openclaw gateway status` + `config get` (slow CLI / docker
6376
- // exec) and used to run TWICE here (syncRuntimeState + buildProjectMeta), ~6s total is
5966
+ // probing - detectRuntime runs `openclaw gateway status` + `config get` (slow CLI / docker
5967
+ // exec) and used to run TWICE here (syncRuntimeState + buildProjectMeta), ~6s total - is
6377
5968
  // deferred to the background so the UI switches instantly. The frontend's loadStatus/loadSystem
6378
5969
  // refresh live status + versions right after.
6379
5970
  state.projectDir = resolved;
@@ -6502,7 +6093,7 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
6502
6093
 
6503
6094
  const k = `${kind}:${id}`;
6504
6095
 
6505
- // zalo-connect is required by any Zalo bot refuse to disable it while a Zalo binding exists
6096
+ // zalo-connect is required by any Zalo bot - refuse to disable it while a Zalo binding exists
6506
6097
  // (the UI also locks the toggle; this is the backend guard).
6507
6098
  if (kind === 'plugin' && (id === 'zalo-connect' || id === 'openclaw-zalo-connect') && !enabled) {
6508
6099
  const hasZaloBot = (cfg.bindings || []).some((b) => b?.match?.channel === 'zalo-connect');
@@ -6731,7 +6322,7 @@ async function installFeature(projectDir, agentId, kind, id) {
6731
6322
  }
6732
6323
 
6733
6324
  if (isNativeProject(projectDir)) {
6734
- // Native: no container install on the host with the project env (ocCapture) so the
6325
+ // Native: no container - install on the host with the project env (ocCapture) so the
6735
6326
  // skill lands in this project's workspace, then reload the managed gateway service.
6736
6327
  sendLog(`[skill] Installing/updating clawhub:${slug} natively for agent ${agentId}...`);
6737
6328
  const out = await ocCaptureInstall(projectDir, ['skills', 'install', slug, '--agent', agentId, '--force', '--acknowledge-install-policy-warning']);
@@ -6790,7 +6381,7 @@ async function installFeature(projectDir, agentId, kind, id) {
6790
6381
  }
6791
6382
 
6792
6383
  if (kind === 'plugin') {
6793
- // zalo-connect ships on ClawHub (package `openclaw-zalo-connect`) install/update via
6384
+ // zalo-connect ships on ClawHub (package `openclaw-zalo-connect`) - install/update via
6794
6385
  // clawhub:latest like other plugins, so the dashboard "Update" button always fetches the newest
6795
6386
  // published version (no tag pin to bump each release).
6796
6387
  if (id === 'zalo-connect' || id === 'openclaw-zalo-connect') {
@@ -6846,7 +6437,7 @@ async function installFeature(projectDir, agentId, kind, id) {
6846
6437
 
6847
6438
  let composeDir = null;
6848
6439
  if (isNativeProject(projectDir)) {
6849
- // Native: no container to exec into same CLI, run on the host with the project env so it
6440
+ // Native: no container to exec into - same CLI, run on the host with the project env so it
6850
6441
  // installs into this project's .openclaw/extensions instead of the default ~/.openclaw.
6851
6442
  composeDir = null;
6852
6443
  } else if (existsSync(join(projectDir, 'docker-compose.yml'))) {
@@ -6901,7 +6492,7 @@ async function installFeature(projectDir, agentId, kind, id) {
6901
6492
  hostOs: await resolveProjectHostOs(projectDir),
6902
6493
  // The plugin ships these off: editing the Docker build files, running page JavaScript
6903
6494
  // and uploading local files are things it will not do until an operator says so.
6904
- // Installing it from this dashboard IS that operator saying so otherwise browsing
6495
+ // Installing it from this dashboard IS that operator saying so - otherwise browsing
6905
6496
  // would need a hand-edited config right after a one-click install.
6906
6497
  ...browserAutomationOptIns(),
6907
6498
  });
@@ -6941,7 +6532,7 @@ async function installFeature(projectDir, agentId, kind, id) {
6941
6532
  // Browser-automation plugin needs Docker rebuild for Playwright/Chromium deps
6942
6533
  const isBrowserPlugin = id === 'openclaw-browser-automation' || id === 'browser-automation';
6943
6534
  if (isNativeProject(projectDir)) {
6944
- // Native: no container reload the managed gateway service so the plugin loads.
6535
+ // Native: no container - reload the managed gateway service so the plugin loads.
6945
6536
  sendLog('[plugin] Restarting native gateway to apply plugin...');
6946
6537
  await restartNativeRuntime(projectDir).catch((err) => sendLog(`[plugin] restart failed: ${err.message}`));
6947
6538
  } else if (isBrowserPlugin && composeDir) {
@@ -7012,7 +6603,7 @@ async function installFeature(projectDir, agentId, kind, id) {
7012
6603
  hostOs: await resolveProjectHostOs(projectDir),
7013
6604
  // The plugin ships these off: editing the Docker build files, running page JavaScript
7014
6605
  // and uploading local files are things it will not do until an operator says so.
7015
- // Installing it from this dashboard IS that operator saying so otherwise browsing
6606
+ // Installing it from this dashboard IS that operator saying so - otherwise browsing
7016
6607
  // would need a hand-edited config right after a one-click install.
7017
6608
  ...browserAutomationOptIns(),
7018
6609
  });
@@ -7031,7 +6622,7 @@ async function installFeature(projectDir, agentId, kind, id) {
7031
6622
  }
7032
6623
  }
7033
6624
  }
7034
- // A skill/plugin install changes container packages and may restart it drop cached
6625
+ // A skill/plugin install changes container packages and may restart it - drop cached
7035
6626
  // extension versions and runtime status so the next page load re-probes fresh.
7036
6627
  probeCacheClear(`extver:${projectDir}`);
7037
6628
  probeCacheClear(`runtime:${projectDir}`);
@@ -7198,7 +6789,7 @@ async function getFeatureFlags(projectDir, agentId = '') {
7198
6789
  'plugin:openclaw-facebook-crawler': isActuallyInstalled(aliases.crawler),
7199
6790
  'plugin:openclaw-n8n-facebook-poster': isActuallyInstalled(aliases.poster),
7200
6791
  // fb-messenger is auto-added to plugins.allow by the wizard, so the allow-list is NOT
7201
- // proof of install require a real extension dir or an install record instead.
6792
+ // proof of install - require a real extension dir or an install record instead.
7202
6793
  'plugin:openclaw-fb-messenger': extensionDirExists(aliases.fbMessenger)
7203
6794
  || aliases.fbMessenger.some((a) => installedKeys.has(a) || Array.from(installedSpecs).some((spec) => spec.includes(a))),
7204
6795
  'plugin:learning-memory': isActuallyInstalled(aliases.learningMemory),
@@ -7215,7 +6806,7 @@ async function getFeatureFlags(projectDir, agentId = '') {
7215
6806
  'plugin:zalo-connect': await getInstalledPluginVersion(projectDir, aliases.zaloConnect),
7216
6807
  };
7217
6808
  // Docker: the container's extensions volume is the SOURCE OF TRUTH for installed
7218
- // plugin versions clawhub/plugin installs run inside the container, so a host copy
6809
+ // plugin versions - clawhub/plugin installs run inside the container, so a host copy
7219
6810
  // (bind-mounted .openclaw or a stale installs.json) can lag behind after an update.
7220
6811
  // When the container reports a version, it OVERRIDES the host value (not just fills
7221
6812
  // empties) so the card shows the actually-installed version, not a stale one. Native
@@ -7418,25 +7009,18 @@ async function handler(req, res, rootProjectDir) {
7418
7009
  // start-chrome-debug is the old path; kept so an already-open dashboard keeps working.
7419
7010
  return json(res, await startChromeDebug());
7420
7011
  }
7421
- // Host control: read/flip the switch and see which apps this machine offers. The bot does
7422
- // not come through here (the dashboard is loopback-only) it calls the bridge-bound
7423
- // 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.
7424
7015
  if (url.pathname === '/api/host/control' && req.method === 'GET') {
7425
- // Target the SELECTED project (not the launch root), so host-control provisions the bot
7426
- // 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.
7427
7018
  const projectDir = await resolveProjectDir(rootProjectDir, {});
7428
7019
  const cfg = await readHostControlConfig(projectDir);
7429
7020
  return json(res, {
7430
7021
  ok: true,
7431
7022
  enabled: cfg.enabled,
7432
- port: HOST_CONTROL_PORT,
7433
- apps: Object.keys(cfg.apps || {}),
7434
- commands: Object.keys(cfg.commands || {}),
7435
- running: Boolean(_hostControlServer),
7436
7023
  native: isNativeProject(projectDir),
7437
- // What enabling will additionally grant, so the confirm dialog can spell it out.
7438
- grants: Object.keys(detectHostCapabilityCommands()),
7439
- codexApp: detectCodexApp(),
7440
7024
  });
7441
7025
  }
7442
7026
  if (url.pathname === '/api/host/control' && req.method === 'POST') {
@@ -7444,18 +7028,11 @@ async function handler(req, res, rootProjectDir) {
7444
7028
  const projectDir = await resolveProjectDir(rootProjectDir, body);
7445
7029
  const cfg = await readHostControlConfig(projectDir);
7446
7030
  if (typeof body.enabled === 'boolean') cfg.enabled = body.enabled;
7447
- if (body.apps && typeof body.apps === 'object') cfg.apps = body.apps;
7448
- if (body.commands && typeof body.commands === 'object') cfg.commands = body.commands;
7449
- // Turning PC control ON is the operator's explicit ask, so it is also where the screen
7450
- // capture / recording and node-script permissions get granted (opt out with grants:false).
7451
- const granted = cfg.enabled && body.grants !== false ? grantHostCapabilities(cfg) : [];
7452
- if (granted.length) sendLog(`[host-control] Đã cấp thêm quyền chạy: ${granted.join(', ')}.`);
7453
- // Pressing this button means "let the bot drive this machine", so it must deliver the real
7454
- // thing — OpenClaw's own `computer` tool (screenshot → click → type → drag), not just the
7455
- // ability to launch an app. That tool needs three separate pieces switched on together, and
7456
- // any one missing leaves the bot insisting it has no permission:
7457
- // • the tool allowed for agents, • the cua-computer plugin (mandatory on Windows),
7458
- // • a running node host advertising computer.act + screen.snapshot.
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.
7459
7036
  // Doing it here rather than in a side script matters: the node host must live in a real
7460
7037
  // interactive desktop session, and the operator pressing this button IS in one.
7461
7038
  const computerUse = await setComputerUse(projectDir, cfg.enabled).catch((e) => {
@@ -7463,39 +7040,21 @@ async function handler(req, res, rootProjectDir) {
7463
7040
  return { ok: false, error: e.message };
7464
7041
  });
7465
7042
  await fsp.writeFile(hostControlConfigPath(projectDir), JSON.stringify(cfg, null, 2), 'utf8');
7466
- let started = { ok: false, reason: 'disabled' };
7467
- if (cfg.enabled) started = await ensureHostControl(projectDir);
7468
- // Always rewrite the workspace guidance: enabling adds the block (with the token),
7469
- // 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.
7470
7045
  await writeHostControlAccess(projectDir, cfg).catch(() => {});
7471
7046
  sendLog(`[host-control] ${cfg.enabled ? 'Đã BẬT' : 'Đã TẮT'} quyền điều khiển máy cho bot.`);
7472
7047
  if (cfg.enabled && computerUse?.ok) sendLog('[host-control] Bot có thể chụp màn hình, click chuột, gõ phím trên máy này.');
7473
- // Make sure the Codex desktop app can actually do GUI work, so `codex exec` is enough for
7474
- // the bot: install computer-use into the app and repair its MCP registration. Nothing is
7475
- // installed into the OpenClaw project and the gateway never restarts.
7476
- let codex = null;
7477
- if (cfg.enabled && body.codex !== false && (cfg.commands || {}).codex) {
7478
- const app = detectCodexApp();
7479
- codex = await ensureCodexComputerUsePlugin(app, detectCodexMarketplace())
7480
- .then((r) => ({ ...r, app }))
7481
- .catch((err) => ({ error: err.message, app }));
7482
- // The wrapper carries the sandbox flag, so a bot cannot get the invocation wrong.
7483
- codex.taskScript = await writeCodexTaskScript(projectDir, (cfg.commands || {}).codex).catch(() => '');
7484
- }
7485
7048
  return json(res, {
7486
7049
  ok: true,
7487
7050
  enabled: cfg.enabled,
7488
- started,
7489
- apps: Object.keys(cfg.apps || {}),
7490
- commands: Object.keys(cfg.commands || {}),
7491
- granted,
7492
7051
  native: isNativeProject(projectDir),
7493
- codex,
7052
+ computerUse,
7494
7053
  });
7495
7054
  }
7496
7055
  // Take the operator to the OS privacy pane PC control needs (screen recording, accessibility).
7497
7056
  // The OS alone can grant these; `probe` additionally triggers the macOS screen-capture prompt
7498
- // for this node binary the same interpreter the native bot runs under.
7057
+ // for this node binary - the same interpreter the native bot runs under.
7499
7058
  if (url.pathname === '/api/host/permissions' && req.method === 'POST') {
7500
7059
  const body = await readJson(req).catch(() => ({}));
7501
7060
  const kind = String(body.kind || 'screen').toLowerCase();
@@ -7518,32 +7077,32 @@ async function handler(req, res, rootProjectDir) {
7518
7077
  try {
7519
7078
  if (isGit) {
7520
7079
  // Clone/dev install: pull the latest (committed dist comes with it).
7521
- sendLog('[update-setup] Git install detected pulling latest from GitHub…');
7080
+ sendLog('[update-setup] Git install detected - pulling latest from GitHub…');
7522
7081
  await run('git', ['pull', '--ff-only'], { cwd: installerDir });
7523
7082
  await run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: installerDir });
7524
- // docs_dev (build tooling) is gitignored, so clones can't rebuild and
7083
+ // docs_dev (build tooling) is gitignored, so clones can't rebuild - and
7525
7084
  // don't need to: dist/ is committed. Only rebuild when tooling is present.
7526
7085
  if (existsSync(resolve(installerDir, 'docs_dev'))) {
7527
7086
  await run('npm', ['run', 'build'], { cwd: installerDir }).catch((e) =>
7528
7087
  sendLog(`[update-setup] build skipped: ${e.message}`));
7529
7088
  }
7530
7089
  } else if (isGlobalNpm) {
7531
- // Pull the latest from npm in place, then exit the service manager (or the
7090
+ // Pull the latest from npm in place, then exit - the service manager (or the
7532
7091
  // respawn path for a hand-run UI) relaunches onto the freshly installed dist.
7533
7092
  // Cài vào ĐÚNG prefix của bản đang chạy, không phải prefix mà `npm` trong PATH
7534
7093
  // của service trỏ tới. Máy khách hay có HAI npm global (nvm + npm hệ thống): đo trên
7535
- // vps_thuy-le 07/09 bản đang chạy ở `/usr/lib/node_modules` (5.16.5) mà `npm i -g`
7094
+ // vps_thuy-le 07/09 - bản đang chạy ở `/usr/lib/node_modules` (5.16.5) mà `npm i -g`
7536
7095
  // lại cài vào `/root/.nvm/versions/node/v24.20.0/lib/node_modules` (5.16.7). Log báo
7537
- // "updated successfully", service restart, mà giao diện vẫn bản cũ không ai hiểu nổi.
7096
+ // "updated successfully", service restart, mà giao diện vẫn bản cũ - không ai hiểu nổi.
7538
7097
  // installerDir = <prefix>/lib/node_modules/create-openclaw-bot ⇒ lùi 3 cấp là prefix.
7539
7098
  const npmPrefix = resolve(installerDir, '..', '..', '..');
7540
7099
  const prefixArgs = /[\\/]lib[\\/]node_modules[\\/]create-openclaw-bot[\\/]?$/.test(installerDir)
7541
7100
  ? ['--prefix', npmPrefix]
7542
7101
  : [];
7543
- sendLog(`[update-setup] Global npm install detected npm i -g create-openclaw-bot@latest${prefixArgs.length ? ` (prefix ${npmPrefix})` : ''}…`);
7102
+ sendLog(`[update-setup] Global npm install detected - npm i -g create-openclaw-bot@latest${prefixArgs.length ? ` (prefix ${npmPrefix})` : ''}…`);
7544
7103
  await run('npm', ['i', '-g', 'create-openclaw-bot@latest', '--no-audit', '--no-fund', ...prefixArgs], { cwd: installerDir });
7545
7104
  } else {
7546
- // Ephemeral `npx github:…` install: nothing to pull in place the relaunch
7105
+ // Ephemeral `npx github:…` install: nothing to pull in place - the relaunch
7547
7106
  // re-runs `npx github:…`, which fetches the latest from GitHub.
7548
7107
  sendLog('[update-setup] Fetching the latest from GitHub on relaunch…');
7549
7108
  }
@@ -7563,7 +7122,7 @@ async function handler(req, res, rootProjectDir) {
7563
7122
  if (result.warning) sendLog(`⚠️ ${result.warning}`);
7564
7123
  // A first Zalo bot changes the project's docker infra needs (the entrypoint must
7565
7124
  // install the pinned zalo-connect plugin BEFORE the gateway starts). Force-resync so
7566
- // the recreate below ships the zaloBackend-aware entrypoint without this, the
7125
+ // the recreate below ships the zaloBackend-aware entrypoint - without this, the
7567
7126
  // login flow has to install mid-boot and restart the container, which can
7568
7127
  // interrupt OpenClaw's first-run migrations and wedge its state lease.
7569
7128
  if (result.channel === 'zalo-personal') {
@@ -7657,14 +7216,14 @@ async function handler(req, res, rootProjectDir) {
7657
7216
  }
7658
7217
  if (req.method === 'PUT') {
7659
7218
  // Allow the same text types the file tree marks editable (it exposes .json/.js/.yml/…,
7660
- // not just .md the old .md-only guard made "Save" silently fail on those files).
7219
+ // not just .md - the old .md-only guard made "Save" silently fail on those files).
7661
7220
  const writableExt = new Set(['.md', '.txt', '.json', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.yml', '.yaml', '.env', '.sh', '.bat', '.ps1', '.html', '.css']);
7662
7221
  if (!writableExt.has(extname(name).toLowerCase())) throw httpError(400, `Loại file này không hỗ trợ sửa từ UI (${extname(name) || 'không có đuôi'})`);
7663
7222
  const body = await readJson(req);
7664
7223
  const projectDir = await resolveProjectDir(rootProjectDir, body);
7665
7224
  const file = safeJoin(projectDir, name);
7666
7225
  const content = String(body.content || '');
7667
- // Don't let a typo brick openclaw.json & friends reject invalid JSON with a clear error.
7226
+ // Don't let a typo brick openclaw.json & friends - reject invalid JSON with a clear error.
7668
7227
  if (extname(name).toLowerCase() === '.json') {
7669
7228
  try { JSON.parse(content); } catch (e) { throw httpError(400, `JSON không hợp lệ: ${e.message}`); }
7670
7229
  }
@@ -7733,7 +7292,7 @@ function openUrl(url) {
7733
7292
  function restartInstaller() {
7734
7293
  // Emit the phrase the UI watches for (see appendLogLine) BEFORE we tear down the
7735
7294
  // server, so the browser tab starts polling and then reloads onto the new UI once
7736
- // it's back up on the SAME host/port instead of hanging on a dead server.
7295
+ // it's back up on the SAME host/port - instead of hanging on a dead server.
7737
7296
  sendLog('[update-setup] Setup Wizard updated successfully! Restarting UI to apply the new version...');
7738
7297
 
7739
7298
  const underSystemd = !!(process.env.INVOCATION_ID || process.env.JOURNAL_STREAM);
@@ -7747,11 +7306,11 @@ function restartInstaller() {
7747
7306
  try { activeServerInstance.close(); } catch {}
7748
7307
  }
7749
7308
 
7750
- // Under a service manager (systemd, pm2, …) just exit it relaunches us with
7309
+ // Under a service manager (systemd, pm2, …) just exit - it relaunches us with
7751
7310
  // the freshly pulled code. Re-spawning ourselves would escape the unit and
7752
7311
  // collide on the port.
7753
7312
  if (underSystemd) {
7754
- sendLog('[update-setup] Service-managed install exiting so the supervisor relaunches the new version.');
7313
+ sendLog('[update-setup] Service-managed install - exiting so the supervisor relaunches the new version.');
7755
7314
  setTimeout(() => process.exit(0), 400);
7756
7315
  return;
7757
7316
  }
@@ -7765,13 +7324,13 @@ function restartInstaller() {
7765
7324
 
7766
7325
  let bin, spawnArgs, opts;
7767
7326
  if (isNpx) {
7768
- // Ephemeral `npx github:…` run re-fetch the latest from GitHub and relaunch.
7327
+ // Ephemeral `npx github:…` run - re-fetch the latest from GitHub and relaunch.
7769
7328
  const win = process.platform === 'win32';
7770
7329
  bin = win ? 'npx.cmd' : 'npx';
7771
7330
  spawnArgs = ['-y', 'github:tuanminhhole/openclaw-setup', ...uiArgs];
7772
7331
  opts = { detached: true, stdio: 'inherit', shell: win };
7773
7332
  } else {
7774
- // Local clone / file install re-run this entry (git pull already updated it).
7333
+ // Local clone / file install - re-run this entry (git pull already updated it).
7775
7334
  bin = process.argv[0];
7776
7335
  spawnArgs = [process.argv[1], ...uiArgs];
7777
7336
  opts = { detached: true, stdio: 'inherit', shell: false };
@@ -7795,7 +7354,7 @@ function restartInstaller() {
7795
7354
 
7796
7355
  /**
7797
7356
  * One-time convenience: drop a short `openclaw-ui` command into the user's shell
7798
- * profile so reopening the wizard later is a single word no long manual setup.
7357
+ * profile so reopening the wizard later is a single word - no long manual setup.
7799
7358
  * OS/shell-aware, idempotent, and fully best-effort (never throws, never blocks
7800
7359
  * startup). Only runs for npx-installed users (the cache dir must exist).
7801
7360
  */
@@ -7803,7 +7362,7 @@ function ensureReopenShortcut() {
7803
7362
  try {
7804
7363
  const home = os.homedir();
7805
7364
  const cliPath = join(home, '.openclaw-setup', 'node_modules', 'create-openclaw-bot', 'dist', 'cli.js');
7806
- if (!existsSync(cliPath)) return; // running from a cloned repo (dev) nothing to shortcut
7365
+ if (!existsSync(cliPath)) return; // running from a cloned repo (dev) - nothing to shortcut
7807
7366
  const MARK = '# >>> openclaw-ui (auto-added by OpenClaw Setup) >>>';
7808
7367
  const END = '# <<< openclaw-ui <<<';
7809
7368
 
@@ -7818,7 +7377,7 @@ function ensureReopenShortcut() {
7818
7377
  const block = `\n${MARK}\nfunction openclaw-ui { $env:OPENCLAW_SETUP_WIZARD="true"; node "${cliPath.replace(/\\/g, '\\\\')}" }\n${END}\n`;
7819
7378
  fs.mkdirSync(dirname(profile), { recursive: true });
7820
7379
  fs.appendFileSync(profile, block, 'utf8');
7821
- console.log("✓ Shortcut installed open a NEW PowerShell and type: openclaw-ui");
7380
+ console.log("✓ Shortcut installed - open a NEW PowerShell and type: openclaw-ui");
7822
7381
  } else {
7823
7382
  const shell = process.env.SHELL || '';
7824
7383
  const rcName = shell.includes('zsh') ? '.zshrc' : shell.includes('bash') ? '.bashrc' : '.profile';
@@ -7827,7 +7386,7 @@ function ensureReopenShortcut() {
7827
7386
  if (content.includes(MARK)) { console.log("💡 Reopen anytime with: openclaw-ui"); return; }
7828
7387
  const block = `\n${MARK}\nalias openclaw-ui='OPENCLAW_SETUP_WIZARD=true node "${cliPath}"'\n${END}\n`;
7829
7388
  fs.appendFileSync(rc, block, 'utf8');
7830
- console.log(`✓ Shortcut added to ~/${rcName} open a NEW terminal (or run 'source ~/${rcName}') and type: openclaw-ui`);
7389
+ console.log(`✓ Shortcut added to ~/${rcName} - open a NEW terminal (or run 'source ~/${rcName}') and type: openclaw-ui`);
7831
7390
  }
7832
7391
  } catch { /* best-effort: a shortcut failure must never break startup */ }
7833
7392
  }
@@ -7864,10 +7423,10 @@ function isLocalPortListening(port, host = '127.0.0.1', timeout = 400) {
7864
7423
  sock.once('error', () => done(false));
7865
7424
  });
7866
7425
  }
7867
- // On a headless server there's no local browser print an SSH-tunnel command so the
7426
+ // On a headless server there's no local browser - print an SSH-tunnel command so the
7868
7427
  // operator can reach the dashboard AND the Open-web UIs from their own machine. This is
7869
7428
  // the discoverable answer for ANY user on a VPS (no manual ssh-config knowledge needed).
7870
- // CHỈ forward những port đang THỰC SỰ listen trên host gateway (18789) / 9Router (20128)
7429
+ // CHỈ forward những port đang THỰC SỰ listen trên host - gateway (18789) / 9Router (20128)
7871
7430
  // thường nằm trong Docker, không bind ra host, nên nếu forward cứng sẽ đẻ ra hàng loạt
7872
7431
  // "channel: open failed: connect failed: Connection refused" vô nghĩa ở phía client.
7873
7432
  async function printRemoteAccessHint(uiPort) {
@@ -7890,7 +7449,7 @@ async function printRemoteAccessHint(uiPort) {
7890
7449
  // itself: the gateway can no longer bind 18789, and the self-loop accepts connections while
7891
7450
  // answering nothing, so the port looks alive and the whole install hangs on "Waiting for gateway".
7892
7451
  // Cost a customer VPS install on 2026-08-28.
7893
- console.log(' ⚠️ Run that line on YOUR OWN machine NOT in this shell. On the server it');
7452
+ console.log(' ⚠️ Run that line on YOUR OWN machine - NOT in this shell. On the server it');
7894
7453
  console.log(` steals port ${ports.join('/')} from the bot and the install never finishes.`);
7895
7454
  console.log('');
7896
7455
  }
@@ -7907,12 +7466,46 @@ async function detectExistingSetupUi(host, port) {
7907
7466
  }
7908
7467
  }
7909
7468
 
7469
+ /**
7470
+ * On Windows, explain a port we could not bind instead of silently moving.
7471
+ *
7472
+ * Hyper-V / WinNAT reserves blocks of TCP ports for its own dynamic use, and anything inside a
7473
+ * block fails to bind with no useful error. The blocks are re-randomised AT EVERY BOOT, so a port
7474
+ * that worked yesterday is simply gone today - which is what happened on a customer machine:
7475
+ * 51739-51838 swallowed the dashboard's 51789, the UI hopped to 51839, and the operator's SSH
7476
+ * tunnel (pointing at 51789) went dead with nothing anywhere saying why.
7477
+ *
7478
+ * Returns a printable explanation, or '' when the port is not inside a reserved block.
7479
+ */
7480
+ async function windowsReservedPortNote(port) {
7481
+ if (process.platform !== 'win32') return '';
7482
+ const r = await runCapture('netsh', ['interface', 'ipv4', 'show', 'excludedportrange', 'protocol=tcp'],
7483
+ { timeout: 15000 }).catch(() => null);
7484
+ if (!r || r.code !== 0) return '';
7485
+ for (const line of String(r.stdout || '').split('\n')) {
7486
+ const m = line.trim().match(/^(\d+)\s+(\d+)/);
7487
+ if (!m) continue;
7488
+ const start = Number(m[1]);
7489
+ const end = Number(m[2]);
7490
+ if (port < start || port > end) continue;
7491
+ return [
7492
+ `Windows đang giữ dải cổng ${start}-${end}, trong đó có ${port}, nên không mở được cổng này.`,
7493
+ 'Dải đó do Hyper-V/WinNAT cấp phát lại MỖI LẦN khởi động máy, nên hôm qua dùng được mà hôm nay thì không.',
7494
+ `Giữ chỗ ${port} vĩnh viễn bằng PowerShell chạy quyền Administrator:`,
7495
+ ' net stop winnat',
7496
+ ` netsh int ipv4 add excludedportrange protocol=tcp startport=${port} numberofports=1 store=persistent`,
7497
+ ' net start winnat',
7498
+ ].join('\n');
7499
+ }
7500
+ return '';
7501
+ }
7502
+
7910
7503
  export async function startLocalInstaller({ host = '127.0.0.1', preferredPort = 51789, openBrowser = true, projectDir = process.cwd() } = {}) {
7911
7504
  const port = await findPort(host, preferredPort);
7912
7505
  if (port !== preferredPort && (await detectExistingSetupUi(host, preferredPort))) {
7913
7506
  // Another Setup UI already owns the preferred port (a systemd service, an earlier npx run…).
7914
7507
  // Hopping to :51790 here is exactly how operators end up with SSH tunnels and printed hints
7915
- // pointing at a port nothing serves so reuse the running instance instead of starting a
7508
+ // pointing at a port nothing serves - so reuse the running instance instead of starting a
7916
7509
  // second one, and keep this process alive so an `ssh -L … "npx create-openclaw-bot"`
7917
7510
  // one-liner still holds the tunnel open. If the other instance ever goes away, take the
7918
7511
  // port over so the URL keeps working without the operator re-running anything.
@@ -7921,20 +7514,25 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
7921
7514
  activeUiPort = preferredPort;
7922
7515
  activeUiProjectDir = projectDir;
7923
7516
  console.log(`OpenClaw Setup UI is already running: ${url}`);
7924
- console.log('Reusing the running instance nothing new was started. Keep this window open if it holds your SSH tunnel.');
7517
+ console.log('Reusing the running instance - nothing new was started. Keep this window open if it holds your SSH tunnel.');
7925
7518
  ensureReopenShortcut();
7926
7519
  if (openBrowser) openUrl(url);
7927
7520
  printRemoteAccessHint(preferredPort).catch(() => {});
7928
7521
  const takeover = setInterval(async () => {
7929
7522
  if ((await findPort(host, preferredPort)) !== preferredPort) return; // still busy
7930
7523
  clearInterval(takeover);
7931
- console.log(`Port ${preferredPort} freed up starting a Setup UI there to keep ${url} working.`);
7524
+ console.log(`Port ${preferredPort} freed up - starting a Setup UI there to keep ${url} working.`);
7932
7525
  startLocalInstaller({ host, preferredPort, openBrowser: false, projectDir }).catch(() => {});
7933
7526
  }, 5000);
7934
7527
  return;
7935
7528
  }
7936
7529
  if (port !== preferredPort) {
7937
- console.log(`⚠ Port ${preferredPort} is busy with something that is not a Setup UI using ${port} instead.`);
7530
+ console.log(`⚠ Port ${preferredPort} is busy with something that is not a Setup UI - using ${port} instead.`);
7531
+ // A port the operator has bookmarked or tunnelled to is worth one extra check before we give
7532
+ // up on it: on Windows the cause is usually invisible, and naming it turns a lost afternoon
7533
+ // into one command.
7534
+ const note = await windowsReservedPortNote(preferredPort).catch(() => '');
7535
+ if (note) console.log(note);
7938
7536
  }
7939
7537
  activeUiHost = host;
7940
7538
  activeUiPort = port;
@@ -7947,9 +7545,6 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
7947
7545
  ensureReopenShortcut();
7948
7546
  if (openBrowser) openUrl(url);
7949
7547
  printRemoteAccessHint(port).catch(() => {});
7950
- // Bring the host-control service back up when the operator left it enabled, so the bot's
7951
- // saved instructions keep working across installer restarts.
7952
- ensureHostControl(projectDir).catch(() => {});
7953
7548
  // Warm the probes the first page load would otherwise wait on (project list, runtime versions,
7954
7549
  // public IP, Zalo status). They run while the browser is still starting, so the dashboard opens
7955
7550
  // against a warm cache instead of paying for docker and CLI round-trips on first paint.
@@ -7961,4 +7556,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
7961
7556
  ]).catch(() => {});
7962
7557
  }
7963
7558
 
7964
- 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, nodeVersionSupported, migrateDockerProjectToNative, dedupeProjectsByRealState, hardenNativeServiceRestarts, clearNativeServiceFailure, describePortHolder, reportNativeGatewayBlockage };
7559
+ 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 };