create-openclaw-bot 5.17.1 → 5.17.3

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,
@@ -764,8 +764,8 @@ function resolveBinPath(cmd) {
764
764
  *
765
765
  * Load-bearing for anything picked out of an nvm prefix: `openclaw` there is a shebang script
766
766
  * (`#!/usr/bin/env node`), so running it by absolute path still resolves `node` from the CHILD's
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.
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.
769
769
  */
770
770
  function binEnv(bin, extra = {}) {
771
771
  const env = { ...process.env, ...extra };
@@ -864,7 +864,7 @@ function globalNodeModulesDirs() {
864
864
  }
865
865
 
866
866
  // `9router --version` boots the whole CLI and takes ~4 SECONDS on a normal machine. /api/system
867
- // 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
868
868
  // dashboard felt slow for one version string. The version is right there in package.json.
869
869
  function readGlobalPackageVersion(name) {
870
870
  for (const dir of globalNodeModulesDirs()) {
@@ -888,7 +888,7 @@ async function getCurrentRuntimeVersions() {
888
888
  nineRouter: readGlobalPackageVersion('9router'),
889
889
  node: process.version || '',
890
890
  };
891
- // 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
892
892
  // about, mostly. Still cached, so an odd layout costs the slow probe once, not every request.
893
893
  const needCli = !fromDisk.openclaw || !fromDisk.nineRouter;
894
894
  if (needCli) {
@@ -1230,7 +1230,7 @@ async function detectRuntime(projectDir) {
1230
1230
  // Ask projectDeployMode, don't re-derive from the compose file: a project migrated to native
1231
1231
  // KEEPS its docker/ folder on purpose (that is the way back), so "compose file exists" stops
1232
1232
  // meaning "runs on docker" the moment migration lands. Getting this wrong made the dashboard
1233
- // report OpenClaw and 9Router OFFLINE on a machine whose native gateway was answering 200
1233
+ // report OpenClaw and 9Router OFFLINE on a machine whose native gateway was answering 200 -
1234
1234
  // it was probing the stopped containers. Seen on win_kha, 10/09/2026.
1235
1235
  mode: projectDeployMode(projectDir),
1236
1236
  cliGatewayStatus,
@@ -1244,7 +1244,7 @@ async function detectRuntime(projectDir) {
1244
1244
 
1245
1245
  // Projects whose one-time migration + Docker-infra sync has already run this server lifetime.
1246
1246
  // The legacy-path migration, 9router-key resolution and Docker-file regeneration only need to
1247
- // 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
1248
1248
  // (cached) still refreshes ports/mode cheaply on each call so state stays current.
1249
1249
  const _runtimeSynced = new Set();
1250
1250
  async function syncRuntimeState(projectDir, { full = false } = {}) {
@@ -1276,7 +1276,7 @@ async function syncRuntimeState(projectDir, { full = false } = {}) {
1276
1276
  state.mode = state.mode || rt.mode;
1277
1277
  state.syncSource = rt.syncSource || 'config';
1278
1278
  state.installed = true;
1279
- // 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
1280
1280
  // inside syncDockerInfra already no-ops on matching versions, but skipping the call entirely
1281
1281
  // avoids the repeated file reads on every page load.
1282
1282
  if (firstSync && rt.mode === 'docker') {
@@ -1300,7 +1300,7 @@ async function removeEmptyWorkspaceAttestations(projectDir) {
1300
1300
  * Native counterpart of migrateContainerPaths. A native bot runs on the host with cwd = the
1301
1301
  * project dir, so any Docker/legacy container path baked into openclaw.json (e.g. an agent
1302
1302
  * `workspace` of "/home/node/project/.openclaw/workspace-x", left over from a bot created by an
1303
- * 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 -
1304
1304
  * the gateway then fails every turn with `ENOENT: mkdir '/home/node'` and the bot never replies.
1305
1305
  * Strip the container prefix so the path becomes project-relative (what bot-config-gen now emits).
1306
1306
  */
@@ -1313,7 +1313,7 @@ async function migrateNativePaths(projectDir) {
1313
1313
  // writes the workspace under projectDir/.openclaw/<name>. A relative value can't satisfy both:
1314
1314
  // ".openclaw/workspace-x" → runtime doubles it to .openclaw/.openclaw/workspace-x (blank persona)
1315
1315
  // "workspace-x" → setup's own resolver looks in projectDir/workspace-x
1316
- // 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
1317
1317
  // "/home/node/project/.openclaw/workspace-x". Normalise every agent's workspace to it.
1318
1318
  const wsRoot = join(projectDir, '.openclaw');
1319
1319
  let changed = false;
@@ -1486,7 +1486,7 @@ function ensureConfigShape(cfg) {
1486
1486
  // `agents.list` in the FILE (measured on vps_c-thu: doctor moves list→entries, then the
1487
1487
  // whole UI shows 0 bots because everything here reads .list). Bridge the two shapes:
1488
1488
  // hydrate a hidden .list view from entries, and expose .entries as a getter built from
1489
- // .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
1490
1490
  // path in this file keeps working on .list unchanged. Configs that still use a real
1491
1491
  // list (openclaw ≤2026.7 projects) keep the old behavior untouched.
1492
1492
  const rawEntries = (cfg.agents.entries && typeof cfg.agents.entries === 'object' && !Array.isArray(cfg.agents.entries))
@@ -1537,7 +1537,7 @@ function ensureConfigShape(cfg) {
1537
1537
  cfg.channels = cfg.channels || {};
1538
1538
  cfg.bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
1539
1539
  cfg.plugins = cfg.plugins || { entries: { 'memory-core': { config: { dreaming: { enabled: false } } } } };
1540
- // Preserve plugins.allow needed for external plugins such as Zalo Connect.
1540
+ // Preserve plugins.allow - needed for external plugins such as Zalo Connect.
1541
1541
  if (!cfg.plugins.allow) cfg.plugins.allow = [];
1542
1542
  cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
1543
1543
  return cfg;
@@ -1562,7 +1562,7 @@ function zaloBackendForConfig(cfg) {
1562
1562
  }
1563
1563
 
1564
1564
  function ensureZaloConnectChannel(cfg) {
1565
- // Secure defaults DM pairing, no groups enabled
1565
+ // Secure defaults - DM pairing, no groups enabled
1566
1566
  // until the owner picks them. Existing zalo-connect config is preserved as-is.
1567
1567
  cfg.channels['zalo-connect'] = cfg.channels['zalo-connect'] || buildZaloConnectChannelConfig();
1568
1568
  cfg.channels['zalo-connect'].enabled = true;
@@ -1608,7 +1608,7 @@ function ensureZaloModPluginConfig(entry, cfg) {
1608
1608
  entry.config.dashboardPort = gwPort + 1;
1609
1609
  }
1610
1610
  // Seed the default bot's identity under bots.default (per-bot shape). zalo-mod
1611
- // 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
1612
1612
  // botName/zaloDisplayNames (they get stripped by the plugin's normalizer anyway).
1613
1613
  const firstAgentName = cfg.agents?.list?.[0]?.name;
1614
1614
  if (firstAgentName) {
@@ -1788,7 +1788,7 @@ async function verifyConfigOrRollback(projectDir) {
1788
1788
  // also mean it looked in the wrong place ("Config file not found") or that the CLI itself broke,
1789
1789
  // and restoring a backup over a perfectly good config would be worse than the bug this guards.
1790
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.
1791
+ // - agents.entries.<id>: Unrecognized key: "role"". Match the shapes openclaw actually prints.
1792
1792
  if (!/config is invalid|invalid config|unrecognized key|invalid input|invalid option|expected/i.test(out)) {
1793
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
1794
  return null;
@@ -1816,7 +1816,7 @@ function validateOpenclawConfig(cfg) {
1816
1816
  }
1817
1817
  if (!cfg.channels || typeof cfg.channels !== 'object') throw httpError(500, 'openclaw.json missing channels');
1818
1818
 
1819
- // 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
1820
1820
  // listener. An older edit path hardcoded `accountId: 'default'`, so an edited bot could end up
1821
1821
  // squatting the first bot's account while its own account sat unbound (no listener, and the
1822
1822
  // other bot answered in its place). Only repair where the intent is unambiguous: the agent
@@ -1832,7 +1832,7 @@ function validateOpenclawConfig(cfg) {
1832
1832
  if (owner.get(accountId) === agentId || !zaloAccounts[agentId]) continue;
1833
1833
  binding.match.accountId = agentId;
1834
1834
  owner.set(agentId, agentId);
1835
- 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}".`);
1836
1836
  }
1837
1837
  }
1838
1838
 
@@ -1884,7 +1884,7 @@ function bindingChannelId(channel = '') {
1884
1884
  }
1885
1885
 
1886
1886
  // OpenClaw ≥2026.8 materialises a DEFAULT agent `main` ("Trợ lý OpenClaw") the first time a
1887
- // 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
1888
1888
  // mode, where `docker compose up` runs while the project still has zero bots (writeCoreProject
1889
1889
  // seeds agents with an empty list). The entry is legitimate OpenClaw state and is left alone in
1890
1890
  // openclaw.json, but it is NOT a bot the operator created: showing it next to the real bot made
@@ -1972,7 +1972,7 @@ async function deleteBotInProject(projectDir, agentId) {
1972
1972
  }
1973
1973
  if (cfg.channels?.telegram?.accounts?.[agentId]) delete cfg.channels.telegram.accounts[agentId];
1974
1974
 
1975
- // 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
1976
1976
  // (e.g. a Telegram channel whose only bot was just removed). An enabled channel with no account
1977
1977
  // keeps erroring in `channels status` ("not configured") and shows a broken card.
1978
1978
  const stillReferenced = new Set((cfg.bindings || []).map((b) => b.match?.channel).filter(Boolean));
@@ -2028,7 +2028,7 @@ function portStatus(port) {
2028
2028
  * listener. Docker tolerates that (compose publishes into loopback and fails loudly on a clash);
2029
2029
  * native binds the host directly, so it has to ask the host.
2030
2030
  *
2031
- * `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.
2032
2032
  */
2033
2033
  async function findFreeHostPort(start, { reserveNext = false, limit = 100 } = {}) {
2034
2034
  for (let port = start; port < start + limit; port++) {
@@ -2137,7 +2137,7 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
2137
2137
  cfg.agents.list.push({
2138
2138
  id: agentId,
2139
2139
  name: botName,
2140
- // 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
2141
2141
  // and native. See buildOpenclawJson() for the full rationale.
2142
2142
  workspace: `.openclaw/${workspaceDir}`,
2143
2143
  agentDir: `agents/${agentId}/agent`,
@@ -2234,7 +2234,7 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
2234
2234
  // the token); don't pollute other channels' bot-meta.json with an empty appId.
2235
2235
  if (channel === 'fb-messenger') botMeta.appId = fbAppId;
2236
2236
  await writeBotMeta(projectDir, workspaceDir, botMeta);
2237
- // 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 -
2238
2238
  // its TOOLS.md was just written fresh and would otherwise have no host-control block at all.
2239
2239
  const hostCfg = await readHostControlConfig(projectDir).catch(() => null);
2240
2240
  if (hostCfg?.enabled) await writeHostControlAccess(projectDir, hostCfg).catch(() => {});
@@ -2267,7 +2267,7 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
2267
2267
  // The zalo-connect branch below used to hardcode 'default' (and this lookup only covered
2268
2268
  // telegram), so editing ANY Zalo bot re-pointed it at the FIRST bot's Zalo account: two
2269
2269
  // bindings claimed `default`, the earlier one won, and the edited bot's own account was left
2270
- // 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
2271
2271
  // other bot. Seen live on a 5-bot install (bot answered under the first bot's name).
2272
2272
  const previousBindings = (cfg.bindings || []).filter((b) => b.agentId === agentId);
2273
2273
  const previousAccountId = (ch) => previousBindings.find((b) => b.match?.channel === ch)?.match?.accountId || null;
@@ -2423,7 +2423,7 @@ async function waitForDockerContainer(name, timeoutMs = 30000) {
2423
2423
  * The banner prints on EVERY invocation and quotes the offending config keys verbatim, so a project
2424
2424
  * whose zalo-connect plugin is missing has `channels.zalo-connect: unknown channel id: zalo-connect`
2425
2425
  * in the output of *any* command. A readiness check that greps stdout for a channel id therefore
2426
- * 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
2427
2427
  * warnings before matching so only real command output counts.
2428
2428
  */
2429
2429
  function stripCliWarnings(text = '') {
@@ -2473,7 +2473,7 @@ async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 900
2473
2473
  await new Promise((r) => setTimeout(r, 5000));
2474
2474
  }
2475
2475
  if (!ready) {
2476
- 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.');
2477
2477
  }
2478
2478
  return ready;
2479
2479
  }
@@ -2493,7 +2493,7 @@ async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, chan
2493
2493
  // cannot possibly be loaded, so return right away and let the caller install it instead of
2494
2494
  // burning the whole timeout waiting for something that will never appear.
2495
2495
  if (!existsSync(extDir)) {
2496
- 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.');
2497
2497
  return false;
2498
2498
  }
2499
2499
  if (await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500)) {
@@ -2506,7 +2506,7 @@ async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, chan
2506
2506
  }
2507
2507
  await new Promise((r) => setTimeout(r, 5000));
2508
2508
  }
2509
- 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.');
2510
2510
  return ready;
2511
2511
  }
2512
2512
 
@@ -2521,13 +2521,13 @@ async function startZaloLogin(projectDir, agentId = "") {
2521
2521
  (!agentId || b.agentId === agentId) && b.match?.channel === "zalo-connect"
2522
2522
  );
2523
2523
  // A roster with more than one agent makes OpenClaw ≥2026.8 refuse any channel operation that
2524
- // cannot name its owner: `AgentSelectionRequiredError Multiple agents are configured, but
2524
+ // cannot name its owner: `AgentSelectionRequiredError - Multiple agents are configured, but
2525
2525
  // this operation has no explicit owner`. That is exactly what a Docker project looks like once
2526
2526
  // the gateway has materialised its default `main` agent alongside the real bot, and the QR
2527
2527
  // login dies with it (measured on vps_tracy-hong, 03/09).
2528
2528
  //
2529
2529
  // `openclaw channels login` has no --agent flag (2026.8.1: only --channel/--account/--verbose),
2530
- // 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
2531
2531
  // named the way OpenClaw's own error hint says: through a binding. Pin one to the real bot
2532
2532
  // before the QR starts. Additive: an existing binding is never rewritten, and the phantom
2533
2533
  // `main` is never the target.
@@ -2538,7 +2538,7 @@ async function startZaloLogin(projectDir, agentId = "") {
2538
2538
  binding = { agentId: target, match: { channel: 'zalo-connect', accountId: 'default' } };
2539
2539
  cfg.bindings = [...(cfg.bindings || []), binding];
2540
2540
  await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
2541
- 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.`);
2542
2542
  }
2543
2543
  }
2544
2544
  return startZaloConnectLogin(projectDir, binding?.match?.accountId || "default");
@@ -2549,7 +2549,7 @@ async function startZaloLogin(projectDir, agentId = "") {
2549
2549
  // container's tmpdir, announced with "QR image saved at: /tmp/zalo-connect-qr-<id>.png".
2550
2550
  // We watch stdout for that line, read the PNG out of the container, and push it to
2551
2551
  // the UI modal as a data URL ([zalo-connect:qr] log tag). Reconnect NEVER reinstalls the
2552
- // 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
2553
2553
  // pinned spec (never `latest`).
2554
2554
  async function startZaloConnectLogin(projectDir, accountId = 'default') {
2555
2555
  if (zaloLoginInFlight) {
@@ -2573,14 +2573,14 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2573
2573
  const gatewayReady = await waitForNativeGatewayZaloReady(projectDir, 180000);
2574
2574
  if (!gatewayReady) {
2575
2575
  // ensureNativePlugins is the single place that knows what a native project owes itself, and
2576
- // 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
2577
2577
  // on a healthy project cost nothing.
2578
2578
  const installed = await ensureNativePlugins(projectDir);
2579
2579
  if (installed.includes(ZALO_PLUGIN_ID)) {
2580
2580
  await restartNativeRuntime(projectDir).catch((err) => sendLog(`[native] restart skipped/failed: ${err.message}`));
2581
2581
  await waitForNativeGatewayZaloReady(projectDir, 180000);
2582
2582
  } else if (!existsSync(join(projectDir, '.openclaw', 'extensions', 'zalo-connect'))) {
2583
- 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".');
2584
2584
  }
2585
2585
  }
2586
2586
  } else {
@@ -2592,23 +2592,23 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2592
2592
  // gateway is up but the plugin is genuinely absent (projects created before the
2593
2593
  // backend-aware entrypoint existed).
2594
2594
  const containerUp = await waitForDockerContainer(botContainer, 90000);
2595
- 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...`);
2596
2596
  const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000);
2597
2597
  if (!gatewayReady) {
2598
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' }));
2599
2599
  if (String(check.stdout || '').trim() === 'MISSING') {
2600
- sendLog(`[zalo-connect] Plugin missing installing ${ZALO_CONNECT_PLUGIN_SPEC}...`);
2600
+ sendLog(`[zalo-connect] Plugin missing - installing ${ZALO_CONNECT_PLUGIN_SPEC}...`);
2601
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`;
2602
2602
  const inst = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', installCmd], { cwd: projectDir, shell: false });
2603
2603
  const instOut = `${inst.stdout}\n${inst.stderr}`;
2604
2604
  for (const line of instOut.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
2605
2605
  if (/installed plugin/i.test(instOut)) {
2606
- // 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
2607
2607
  // its boot (we only reach this branch when it answered the exec above).
2608
2608
  await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
2609
2609
  await waitForGatewayZaloReady(botContainer, projectDir, 180000);
2610
2610
  } else {
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.');
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.');
2612
2612
  }
2613
2613
  }
2614
2614
  }
@@ -2626,7 +2626,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2626
2626
  const pushQr = async (pngPath) => {
2627
2627
  let b64 = '';
2628
2628
  if (native) {
2629
- // 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.
2630
2630
  try {
2631
2631
  const st = await fsp.stat(pngPath);
2632
2632
  if (st.size > 100) b64 = (await fsp.readFile(pngPath)).toString('base64');
@@ -2686,7 +2686,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
2686
2686
  zaloLoginInFlight = false;
2687
2687
  } else if (code !== 0 && !qrSent && !wasCancelled && attempt < MAX_ATTEMPTS) {
2688
2688
  const delay = RETRY_DELAYS[attempt] || 15000;
2689
- 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...`);
2690
2690
  setTimeout(runAttempt, delay);
2691
2691
  } else {
2692
2692
  if (!qrSent && !wasCancelled) sendLog('[zalo-connect] Login ended without a QR. Click "Đăng nhập Zalo" to retry.');
@@ -2824,7 +2824,7 @@ async function computeZaloHealth(projectDir) {
2824
2824
  meta.installedVersion = JSON.parse(await fsp.readFile(manifestHost, 'utf8')).version || null;
2825
2825
  } catch {
2826
2826
  // Only Docker projects keep the manifest inside a container. A native project has no
2827
- // 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
2828
2828
  // a `docker exec` that could never succeed, on a request the dashboard makes constantly.
2829
2829
  if (!native) {
2830
2830
  try {
@@ -2938,7 +2938,7 @@ function getBotServiceName(projectDir) {
2938
2938
  }
2939
2939
 
2940
2940
  // ═══════════════════════════════════════════════════════════════════════════════
2941
- // Native runtime openclaw + 9router straight on the host, no Docker
2941
+ // Native runtime - openclaw + 9router straight on the host, no Docker
2942
2942
  // ═══════════════════════════════════════════════════════════════════════════════
2943
2943
  // Two things replace the container:
2944
2944
  // 1. `docker exec <container> openclaw …` → `openclaw …` carrying the project env.
@@ -2953,14 +2953,14 @@ function getBotServiceName(projectDir) {
2953
2953
  const NATIVE_MARKER = 'native.json';
2954
2954
  // Native uses the same ports as everything else: openclaw's 18789 and 9router's 20128. It used to
2955
2955
  // jump a hundred above them unconditionally so it could sit next to a docker project, but that fired
2956
- // 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
2957
2957
  // tunnel command, bookmark and doc pointed at a port the user never chose. findFreeHostPort() now
2958
2958
  // handles coexistence by asking the host what is actually taken, which the fixed offset never did.
2959
2959
  const NATIVE_DEFAULT_GATEWAY_PORT = 18789;
2960
2960
  const NATIVE_DEFAULT_ROUTER_PORT = 20128;
2961
2961
  // The gateway's startup migrations hold a lease on the state directory for FIVE MINUTES, and a boot
2962
2962
  // that collides with it exits immediately instead of waiting. Any health wait shorter than that
2963
- // 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
2964
2964
  // could not outlast it even in the best case.
2965
2965
  const NATIVE_GATEWAY_HEALTH_TIMEOUT_MS = 420000;
2966
2966
 
@@ -2985,7 +2985,7 @@ function isNativeProject(projectDir) {
2985
2985
  return projectDeployMode(projectDir) === 'native';
2986
2986
  }
2987
2987
 
2988
- /** 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. */
2989
2989
  function nativeServiceLabel(projectDir) {
2990
2990
  const meta = readNativeMeta(projectDir);
2991
2991
  if (meta && meta.label) return meta.label;
@@ -2997,7 +2997,7 @@ function nativeServiceLabel(projectDir) {
2997
2997
  function nativeEnv(projectDir, extra = {}) {
2998
2998
  const dir = projectDir || state.projectDir || '';
2999
2999
  // openclaw 2026.8.x fs-safe refuses atomic writes through a symlinked state dir
3000
- // ("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
3001
3001
  // projects on 2026.8.x keep real state at ~/.openclaw with the project dir symlinked
3002
3002
  // (daemon install rejects a custom OPENCLAW_HOME), so this path IS a symlink there.
3003
3003
  let home = join(dir, '.openclaw');
@@ -3061,7 +3061,7 @@ function ocCapture(projectDir, args, opts = {}) {
3061
3061
  return runCapture(a.cmd, a.args, { shell: false, ...a.opts, ...opts, env: { ...(a.opts.env || {}), ...(opts.env || {}) } });
3062
3062
  }
3063
3063
 
3064
- // 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,
3065
3065
  // skills: --acknowledge-install-policy-warning. 2026.7 and older only know
3066
3066
  // --acknowledge-clawhub-risk. Callers pass the NEW flag; when the CLI rejects it
3067
3067
  // ("does not recognize option") retry once with the legacy flag so an updated setup can
@@ -3111,7 +3111,7 @@ async function waitForNativeGatewayHealthy(projectDir, timeoutMs = 120000) {
3111
3111
  * The first gateway boot runs OpenClaw's startup migrations under a state-directory lease, and a
3112
3112
  * second gateway that tries to start meanwhile exits 1 with this message rather than waiting. The
3113
3113
  * docker path sidesteps it by never poking a booting container (see startZaloConnectLogin); when we
3114
- * 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
3115
3115
  * instead of retrying blind into systemd's StartLimitBurst (5 per 60s, after which the unit is
3116
3116
  * abandoned for good).
3117
3117
  */
@@ -3127,7 +3127,7 @@ async function ocDaemon(projectDir, verb, extraArgs = []) {
3127
3127
  const args = ['daemon', verb, ...extraArgs];
3128
3128
  sendLog(`$ openclaw ${args.join(' ')}`);
3129
3129
  // openclaw 2026.8.x refuses EVERY `daemon *` verb (not just install) while OPENCLAW_HOME is
3130
- // 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
3131
3131
  // post-plugin-install restart failed on a fresh native host). On the 2026.8 layout the
3132
3132
  // state already lives at ~/.openclaw, so run daemon verbs with the plain account HOME and
3133
3133
  // no OPENCLAW_* overrides; STOP even suggests --force for the operator gateway, so pass it.
@@ -3156,11 +3156,11 @@ async function ocDaemon(projectDir, verb, extraArgs = []) {
3156
3156
  *
3157
3157
  * Health is confirmed over /health at the end rather than trusted from the CLI's exit code: the
3158
3158
  * CLI gives up verifying after ~13s while the generated unit allows 30s to start, so a slow but
3159
- * 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
3160
3160
  * stop+start that raced the migration lease all over again.
3161
3161
  */
3162
3162
  /**
3163
- * 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
3164
3164
  * entrypoint embeds contextDefaultsScript. Native projects have no entrypoint, so an existing
3165
3165
  * native bot never received those fixes (a bot created before 5.16.0 kept the smart-route
3166
3166
  * contextWindow at 200000 and stayed exposed to the compaction deadlock). Replaying the exact
@@ -3182,9 +3182,9 @@ async function runNativeConfigMigrations(projectDir) {
3182
3182
  * OpenClaw 2026.9 từ chối khởi động khi workspace còn ở dạng cũ:
3183
3183
  * "Gateway failed to start: Legacy workspace setup state requires migration for
3184
3184
  * …/workspace-<agent>; run openclaw doctor --fix."
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ỉ
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ỉ
3186
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ò.
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
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 -
3188
3188
  * gateway vẫn được thử khởi động, cùng lắm là báo đúng lỗi cũ.
3189
3189
  */
3190
3190
  async function runOpenclawDoctorFixIfNeeded(projectDir) {
@@ -3243,7 +3243,7 @@ async function restartWindowsGateway(projectDir) {
3243
3243
  }
3244
3244
 
3245
3245
  async function restartNativeRuntime(projectDir) {
3246
- // 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
3247
3247
  // calls are no-ops once the service env is complete, stray files are adopted, and the config
3248
3248
  // already carries the migrated defaults.
3249
3249
  await adoptStrayNativeHome(projectDir).catch(() => {});
@@ -3282,14 +3282,14 @@ async function restartNativeRuntime(projectDir) {
3282
3282
  const deadline = migrationLeaseDeadline(res.text);
3283
3283
  if (deadline) {
3284
3284
  const waitMs = Math.max(0, Math.min(deadline - Date.now(), 300000)) + 3000;
3285
- 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.`);
3286
3286
  await new Promise((r) => setTimeout(r, waitMs));
3287
3287
  res = await ocDaemon(projectDir, 'restart');
3288
3288
  if (res.code !== 0) res = await stopStart();
3289
3289
  }
3290
3290
  // With the start limit lifted (hardenNativeServiceRestarts) systemd really does keep restarting a
3291
3291
  // crash-looping unit every RestartSec, so a gateway blocked by a lease we never saw still comes up
3292
- // 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.
3293
3293
  if (!(await waitForNativeGatewayHealthy(projectDir, NATIVE_GATEWAY_HEALTH_TIMEOUT_MS))) {
3294
3294
  throw new Error('gateway did not answer /health after restart');
3295
3295
  }
@@ -3300,7 +3300,7 @@ async function restartNativeRuntime(projectDir) {
3300
3300
  * Make the generated service carry everything nativeEnv() promises.
3301
3301
  *
3302
3302
  * `openclaw daemon install` propagates only a fixed allow-list into the service it writes:
3303
- * 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
3304
3304
  * a launchd env-wrapper. Anything resolving paths from OPENCLAW_HOME then falls back to `~/.openclaw`
3305
3305
  * and writes OUTSIDE the project. zalo-connect is the visible casualty: it stages inbound files and
3306
3306
  * its Zalo session credentials under the wrong home, so a PDF sent to the bot lands somewhere the
@@ -3364,7 +3364,7 @@ async function syncNativeServiceEnv(projectDir) {
3364
3364
  * Reunite a native project with the files an unset OPENCLAW_HOME scattered into `~/.openclaw`.
3365
3365
  *
3366
3366
  * This MUST run before syncNativeServiceEnv takes effect: once OPENCLAW_HOME is finally correct, the
3367
- * 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
3368
3368
  * the home directory it finds nothing and demands a fresh QR login. Copy (never move) so a failed
3369
3369
  * run leaves the working original in place; skip anything the project already has.
3370
3370
  */
@@ -3394,7 +3394,7 @@ async function adoptStrayNativeHome(projectDir) {
3394
3394
 
3395
3395
  /**
3396
3396
  * `openclaw daemon install` has no `--system` flag, so on Linux the gateway becomes a systemd USER
3397
- * 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
3398
3398
  * desktop the graphical session holds it open, which is why this never showed up on macOS or a
3399
3399
  * Linux desktop; on a VPS the bot dies the moment the operator closes SSH and never comes back
3400
3400
  * after a reboot. Linger is what makes a user unit behave like the `restart: always` container it
@@ -3409,7 +3409,7 @@ async function ensureSystemdLinger() {
3409
3409
  if (/Linger=yes/i.test(cur.stdout || '')) return true;
3410
3410
  const out = await runCapture('loginctl', ['enable-linger', user], { shell: false, timeout: 20000 });
3411
3411
  if (out.code === 0) {
3412
- 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.`);
3413
3413
  return true;
3414
3414
  }
3415
3415
  sendLog(`[native] WARNING: could not enable systemd linger for "${user}" (${(out.stderr || out.stdout || '').trim() || `exit ${out.code}`}).`);
@@ -3422,7 +3422,7 @@ async function ensureSystemdLinger() {
3422
3422
  *
3423
3423
  * `openclaw daemon install` writes `StartLimitBurst=5` / `StartLimitIntervalSec=60` next to
3424
3424
  * `Restart=always`. The gateway's startup migrations take a lease on the state directory that lasts
3425
- * 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
3426
3426
  * whole limit in 30 seconds, systemd logs "Start request repeated too quickly", and the unit stays
3427
3427
  * dead for good even though the lease frees itself minutes later. Verified on a fresh Ubuntu 24.04
3428
3428
  * VPS (2026-08-28): the setup UI sat on "Waiting for gateway on 18789..." until it timed out, while
@@ -3437,7 +3437,7 @@ async function hardenNativeServiceRestarts(projectDir) {
3437
3437
  const dir = join(os.homedir(), '.config', 'systemd', 'user', `${unit}.d`);
3438
3438
  const file = join(dir, '99-openclaw-setup.conf');
3439
3439
  const body = [
3440
- '# Written by openclaw-setup regenerated on every install, do not edit.',
3440
+ '# Written by openclaw-setup - regenerated on every install, do not edit.',
3441
3441
  '# Startup migrations hold the state lease for ~5 minutes and a colliding boot exits at once, so',
3442
3442
  '# the stock StartLimitBurst=5/StartLimitIntervalSec=60 abandons the unit for good within 30s.',
3443
3443
  '# No start limit = systemd keeps retrying every RestartSec until the lease frees itself.',
@@ -3457,7 +3457,7 @@ async function hardenNativeServiceRestarts(projectDir) {
3457
3457
 
3458
3458
  /**
3459
3459
  * A unit parked at "Start request repeated too quickly" refuses every later `start` until its
3460
- * 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
3461
3461
  * runs before each start and also repairs a project abandoned by an earlier install.
3462
3462
  */
3463
3463
  async function clearNativeServiceFailure(projectDir) {
@@ -3475,7 +3475,7 @@ async function clearNativeServiceFailure(projectDir) {
3475
3475
  *
3476
3476
  * The trap this exists for: an operator runs the port-forward command out of `ssh <host>-setup`
3477
3477
  * (`ssh -L 18789:127.0.0.1:18789 root@<host>`) while already logged INTO that host instead of from
3478
- * 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
3479
3479
  * that accepts every connection and answers nothing. The gateway can then never bind its port, and
3480
3480
  * health probes hang instead of failing, so the port looks alive and is useless. Seen on a customer
3481
3481
  * VPS 2026-08-28. `ss` (iproute2) is used on Linux rather than `lsof`, which a minimal Ubuntu lacks.
@@ -3503,7 +3503,7 @@ async function describePortHolder(port) {
3503
3503
  *
3504
3504
  * Three causes, all indistinguishable from "still booting" without this: an ssh port-forward
3505
3505
  * self-loop holding the port (describePortHolder), the migration lease plus systemd's start limit
3506
- * 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
3507
3507
  * which the unit's own journal is the only thing that ever says so.
3508
3508
  */
3509
3509
  async function reportNativeGatewayBlockage(projectDir, port) {
@@ -3511,7 +3511,7 @@ async function reportNativeGatewayBlockage(projectDir, port) {
3511
3511
  if (holder) {
3512
3512
  sendLog(`[native] Port ${port} is already held by → ${holder}`);
3513
3513
  if (/(^|\/|\s)ssh(\s|$)/.test(holder) && new RegExp(`-L\\s*\\d*:?${port}:`).test(holder)) {
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.`);
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.`);
3515
3515
  }
3516
3516
  }
3517
3517
  if (process.platform !== 'linux' || !isNativeProject(projectDir)) return;
@@ -3529,14 +3529,14 @@ async function reportNativeGatewayBlockage(projectDir, port) {
3529
3529
  * A container reinstalls its missing plugins on every boot; a native project has no entrypoint, so
3530
3530
  * nothing ever put zalo-connect or learning-memory on disk. The generated config declares both
3531
3531
  * anyway (bot-config-gen writes plugins.entries + allow + slots.contextEngine), so without this the
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
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
3534
3534
  * engine at all. Same set and same skip-if-present cheapness as ensure_plugin.
3535
3535
  */
3536
3536
  async function ensureNativePlugins(projectDir, { restart = false } = {}) {
3537
3537
  if (!isNativeProject(projectDir)) return [];
3538
3538
  // Same cleanup the container entrypoint does (docker-gen.js): an interrupted `plugins install`
3539
- // 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 -
3540
3540
  // so the gateway logs "duplicate plugin id detected" every boot and a stale build competes with the
3541
3541
  // real one for the same id. Native has no entrypoint, so it has to happen here.
3542
3542
  const extRoot = join(projectDir, '.openclaw', 'extensions');
@@ -3552,7 +3552,7 @@ async function ensureNativePlugins(projectDir, { restart = false } = {}) {
3552
3552
  const wanted = new Set(['learning-memory']);
3553
3553
  if (cfg?.channels?.[ZALO_CHANNEL_ID] || cfg?.plugins?.entries?.[ZALO_PLUGIN_ID]) wanted.add(ZALO_PLUGIN_ID);
3554
3554
  // openclaw >=2026.8 unbundled duckduckgo AND refuses to report ready while the config
3555
- // 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,
3556
3556
  // native must too (measured 03/09/2026: fresh install crash-looped on this).
3557
3557
  if (cfg?.plugins?.entries?.duckduckgo) wanted.add('duckduckgo');
3558
3558
  const installed = [];
@@ -3565,7 +3565,7 @@ async function ensureNativePlugins(projectDir, { restart = false } = {}) {
3565
3565
  const text = `${out.stdout || ''}\n${out.stderr || ''}`;
3566
3566
  for (const line of text.split(/\r?\n/).map((l) => l.trimEnd()).filter(Boolean)) sendLog(`[native] ${line}`);
3567
3567
  if (existsSync(dir) || /installed plugin/i.test(text)) installed.push(id);
3568
- 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.`);
3569
3569
  }
3570
3570
  if (installed.length && restart) {
3571
3571
  sendLog(`[native] Restarting gateway to load: ${installed.join(', ')}`);
@@ -3614,7 +3614,7 @@ async function probeHttpOk(url, timeoutMs = 2000) {
3614
3614
  /**
3615
3615
  * Start 9router for a native project. Bound to loopback on purpose: openclaw talks to it over
3616
3616
  * localhost (see get9RouterBaseUrl), so exposing the LLM proxy on 0.0.0.0 would only create an
3617
- * 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.
3618
3618
  */
3619
3619
  async function startNative9Router(projectDir, { restart = false } = {}) {
3620
3620
  const meta = readNativeMeta(projectDir) || {};
@@ -3629,7 +3629,7 @@ async function startNative9Router(projectDir, { restart = false } = {}) {
3629
3629
  return routerPort;
3630
3630
  }
3631
3631
  // Linux: run 9router as a systemd USER unit, not a detached child. startDetached leaves the
3632
- // 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)
3633
3633
  // silently killed 9router and the bot lost its model (measured on vps_c-thu, 02/09/2026).
3634
3634
  // macOS/Windows keep the detached process: no deployed native host runs there yet, and each
3635
3635
  // would need its own service wrapper (launchd/Task Scheduler).
@@ -3680,12 +3680,12 @@ async function installNative9RouterUnit(projectDir, routerPort, dataDir) {
3680
3680
 
3681
3681
  /**
3682
3682
  * openclaw 2026.8.x `daemon install` only manages the service when the state dir is the
3683
- * 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
3684
3684
  * layer refuses atomic writes through a symlinked state dir ("parent must be a real
3685
3685
  * directory"). Measured on vps_c-thu 02/09/2026. So for 2026.8+ the REAL state lives at
3686
3686
  * ~/.openclaw and <project>/.openclaw becomes a symlink to it (junction on Windows, so no
3687
3687
  * admin rights needed). nativeEnv() realpaths the symlink, so every other CLI call keeps
3688
- * 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.
3689
3689
  * Returns the env for `daemon install` (plain HOME, no OPENCLAW_* overrides), or null when
3690
3690
  * the runtime is older than 2026.8 and nothing should change.
3691
3691
  */
@@ -3706,7 +3706,7 @@ async function prepareNativeStateHome(projectDir) {
3706
3706
  try {
3707
3707
  if (resolve(fs.realpathSync(projState)) === resolve(homeState)) return installEnv;
3708
3708
  } catch {}
3709
- 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`);
3710
3710
  }
3711
3711
  const homeExists = existsSync(homeState) || (() => { try { fs.lstatSync(homeState); return true; } catch { return false; } })();
3712
3712
  if (homeExists) {
@@ -3715,8 +3715,8 @@ async function prepareNativeStateHome(projectDir) {
3715
3715
  // Reverse layout from an earlier hand-fix attempt: drop the link, the real dir moves in below.
3716
3716
  fs.unlinkSync(homeState);
3717
3717
  } else if (existsSync(join(homeState, 'openclaw.json'))) {
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`);
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`);
3720
3720
  } else {
3721
3721
  // Stray/partial dir (stale CLI runs create these): park it, keep nothing in the way.
3722
3722
  const parked = `${homeState}.bak-stray-${Date.now()}`;
@@ -3734,8 +3734,8 @@ async function prepareNativeStateHome(projectDir) {
3734
3734
  * Turn OpenClaw's real computer-use on or off for a project.
3735
3735
  *
3736
3736
  * Three pieces must line up, and a bot with only two of them reports "I don't have permission"
3737
- * without saying which one is missing that ambiguity cost a whole debugging session:
3738
- * 1. `computer` (+ `screen`) allowed in tools.alsoAllow otherwise the agent never receives
3737
+ * without saying which one is missing - that ambiguity cost a whole debugging session:
3738
+ * 1. `computer` (+ `screen`) allowed in tools.alsoAllow - otherwise the agent never receives
3739
3739
  * the tool at all, no matter what else is configured;
3740
3740
  * 2. the `cua-computer` plugin, which is MANDATORY on Windows and ships disabled. It also has
3741
3741
  * to be on plugins.allow, or enabling is refused with "blocked by allowlist";
@@ -3743,7 +3743,7 @@ async function prepareNativeStateHome(projectDir) {
3743
3743
  * model-facing surface; the node is what actually touches the screen, which is why a
3744
3744
  * correctly configured tool still does nothing on its own.
3745
3745
  *
3746
- * 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
3747
3747
  * an SSH session it either dies with the session or cannot see a desktop at all. Launching it
3748
3748
  * from here works because the operator pressing the button is sitting at that desktop.
3749
3749
  */
@@ -3773,7 +3773,7 @@ async function setComputerUse(projectDir, enable) {
3773
3773
  // A fourth gate nobody sees until they hit it: the gateway keeps a per-platform allowlist of
3774
3774
  // node commands. `computer.act` counts as a dangerous default and `screen.snapshot` as a
3775
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
3776
+ // `"screen.snapshot" is not in the allowlist for platform "windows"` - even though the plugin
3777
3777
  // is enabled and the node is paired and approved. Only gateway.nodes.commands.allow puts them
3778
3778
  // back (it is applied after the dangerous-command filter).
3779
3779
  cfg.gateway = (cfg.gateway && typeof cfg.gateway === 'object') ? cfg.gateway : {};
@@ -3807,7 +3807,7 @@ async function setComputerUse(projectDir, enable) {
3807
3807
  }
3808
3808
 
3809
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
3810
+ // gateway to apply" when a plugin is enabled - skip this and the switch reports success while
3811
3811
  // the bot still has no computer tool, which is exactly the kind of silent half-success that
3812
3812
  // sends the owner back to us.
3813
3813
  sendLog('[computer-use] Đã bật tool computer + plugin cua-computer. Đang khởi động lại bot để nạp...');
@@ -3847,7 +3847,7 @@ async function setComputerUse(projectDir, enable) {
3847
3847
  if (!caps) {
3848
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
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.
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
3851
  sendLog(`[computer-use] Node đã chạy nhưng chưa khai báo được khả năng màn hình. ${nodeCapsLastReason}`);
3852
3852
  return {
3853
3853
  ok: true,
@@ -3914,7 +3914,7 @@ async function prepareNodeHostHome(projectDir, gatewayPort) {
3914
3914
  async function startNodeHost(projectDir, gatewayPort) {
3915
3915
  if (await nodeHostRunning()) { sendLog('[computer-use] Node điều khiển đã chạy sẵn.'); return; }
3916
3916
  const token = gatewayAuthToken(projectDir);
3917
- if (!token) sendLog('[computer-use] Không đọc được gateway token node có thể bị từ chối kết nối.');
3917
+ if (!token) sendLog('[computer-use] Không đọc được gateway token - node có thể bị từ chối kết nối.');
3918
3918
  // `--no-tls`: the gateway here is plain ws:// on loopback. Without it the node tries TLS and
3919
3919
  // the handshake never completes.
3920
3920
  const a = ocArgv(projectDir, ['node', 'run', '--host', '127.0.0.1', '--port', String(gatewayPort), '--no-tls']);
@@ -3938,7 +3938,7 @@ async function startNodeHost(projectDir, gatewayPort) {
3938
3938
  const vbs = join(projectDir, 'run-hidden.vbs');
3939
3939
  const cmd = join(projectDir, 'node-host.cmd');
3940
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.');
3941
+ sendLog('[computer-use] thiếu node-host.cmd - không khởi động được node điều khiển.');
3942
3942
  return;
3943
3943
  }
3944
3944
  startDetached('wscript.exe', [vbs, cmd], { cwd: projectDir });
@@ -3959,7 +3959,7 @@ async function startNodeHost(projectDir, gatewayPort) {
3959
3959
  await new Promise((r) => setTimeout(r, 2000));
3960
3960
  if (await nodeHostRunning()) { sendLog('[computer-use] Node điều khiển đã kết nối.'); return; }
3961
3961
  }
3962
- sendLog('[computer-use] Node điều khiển chưa lên sau 30s kiểm tra lại bằng `openclaw node status`.');
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
3963
  }
3964
3964
 
3965
3965
  /**
@@ -4079,11 +4079,11 @@ async function writeWindowsLaunchers(projectDir, gatewayPort, routerPort) {
4079
4079
  * file after an atomic rename. That check cannot pass through a Docker Desktop bind mount on
4080
4080
  * Windows, so the moment a docker image is rebuilt onto 2026.9.x the gateway can no longer write
4081
4081
  * its own config: every boot is unclean, the restart-loop breaker trips, and the bot is down with
4082
- * a misleading "ENOENT: fstat". Measured end to end on win_kha, 09/09/2026 native on the very
4082
+ * a misleading "ENOENT: fstat". Measured end to end on win_kha, 09/09/2026 - native on the very
4083
4083
  * same NTFS path works, so the fault is the bind-mount layer, not the disk.
4084
4084
  *
4085
4085
  * The data lives in two different places and only one of them is visible on the host:
4086
- * bind-mounted paths are already on disk, but named volumes are not state (the sqlite DB with
4086
+ * bind-mounted paths are already on disk, but named volumes are not - state (the sqlite DB with
4087
4087
  * every session), the plugin tree, and 9router's login live inside volumes. Copy those out FIRST;
4088
4088
  * everything after this point is reversible, the containers and image are left untouched.
4089
4089
  */
@@ -4092,11 +4092,11 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
4092
4092
  if (!existsSync(composeFile)) return false;
4093
4093
  sendLog('[migrate] Chuyển project từ Docker sang native...');
4094
4094
 
4095
- // 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
4096
4096
  // very volumes holding the customer's sessions.
4097
4097
  await run('docker', ['compose', '-f', composeFile, 'stop']).catch(() => {});
4098
4098
  // Stopping is not enough: the containers carry a restart policy, so the next reboot brings them
4099
- // back and they grab 18789/18790 before the native gateway can bind the native process then
4099
+ // back and they grab 18789/18790 before the native gateway can bind - the native process then
4100
4100
  // dies with EADDRINUSE seconds after reporting "ready", which reads like a random crash.
4101
4101
  // Measured on win_kha after its first reboot, 10/09/2026. Clear the policy, keep the containers.
4102
4102
  const psAll = await runCapture('docker', ['compose', '-f', composeFile, 'ps', '-a', '--format', '{{.Name}}'], { shell: false });
@@ -4119,7 +4119,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
4119
4119
  let hostDir = null;
4120
4120
  if (dest.startsWith(`${CONTAINER_HOME}/`)) hostDir = join(projectDir, dest.slice(CONTAINER_HOME.length + 1));
4121
4121
  else if (dest === '/root/.9router' || dest.endsWith('/.9router')) hostDir = join(projectDir, '.9router');
4122
- if (!hostDir) { sendLog(`[migrate] bỏ qua volume ${volName} (${dest}) không map được về project`); continue; }
4122
+ if (!hostDir) { sendLog(`[migrate] bỏ qua volume ${volName} (${dest}) - không map được về project`); continue; }
4123
4123
  await fsp.mkdir(hostDir, { recursive: true });
4124
4124
  // Copy with a throwaway container: the volume has no host path we can read directly.
4125
4125
  const r = await runCapture('docker', [
@@ -4127,7 +4127,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
4127
4127
  'node:22-slim', 'sh', '-c', 'cp -a /__src/. /__dst/ 2>/dev/null; ls -A /__dst | head -1',
4128
4128
  ], { shell: false });
4129
4129
  if (r.stdout && r.stdout.trim()) { copied++; sendLog(`[migrate] ${volName} → ${hostDir}`); }
4130
- else sendLog(`[migrate] ${volName} rỗng hoặc không chép được kiểm tra tay: ${hostDir}`);
4130
+ else sendLog(`[migrate] ${volName} rỗng hoặc không chép được - kiểm tra tay: ${hostDir}`);
4131
4131
  }
4132
4132
  }
4133
4133
  sendLog(`[migrate] đã đưa ${copied} volume xuống đĩa.`);
@@ -4149,7 +4149,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
4149
4149
  // /home/node/project so ".openclaw/workspace-x" resolved correctly. Native has no such
4150
4150
  // guarantee: the customer double-clicks a launcher from wherever they filed it, and the
4151
4151
  // agent then looks for its workspace under THAT folder and dies with WORKSPACE_VANISHED
4152
- // while agents that happen to carry an absolute path keep working, so only some bots
4152
+ // - while agents that happen to carry an absolute path keep working, so only some bots
4153
4153
  // break. Exactly what happened when the launchers were moved into a Desktop subfolder
4154
4154
  // (win_kha 10/09/2026). Pin every workspace to the project.
4155
4155
  {
@@ -4168,7 +4168,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
4168
4168
 
4169
4169
  // 3a-bis. Docker-only HOSTNAMES. `http://9router:20128` resolves through compose's internal
4170
4170
  // DNS and nowhere else, so on native every model call dies with
4171
- // `getaddrinfo ENOTFOUND 9router` the bot still reacts (that is the channel, no model
4171
+ // `getaddrinfo ENOTFOUND 9router` - the bot still reacts (that is the channel, no model
4172
4172
  // needed) but can never reply, which reads as "bot ignores me". Same story for
4173
4173
  // host.docker.internal and the 192.168.65.x Desktop bridge. Measured win_kha 10/09/2026.
4174
4174
  text = text
@@ -4188,7 +4188,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
4188
4188
  sendLog('[migrate] gateway.bind → loopback (native bind thẳng lên host, 0.0.0.0 là mở ra mạng)');
4189
4189
  }
4190
4190
  // 3c. The browser-automation plugin rewrites the browser block on every boot, so simply
4191
- // deleting the bad key below is not enough it grows back and the config is invalid
4191
+ // deleting the bad key below is not enough - it grows back and the config is invalid
4192
4192
  // again by the next restart (same shape as the old toolResultMaxChars reinfection).
4193
4193
  // Take the pen away from it; patchDocker is pointless here now too.
4194
4194
  const baEntry = cfg.plugins && cfg.plugins.entries && cfg.plugins.entries['browser-automation'];
@@ -4212,7 +4212,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
4212
4212
  }
4213
4213
  }
4214
4214
 
4215
- // 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
4216
4216
  // the long-standing "duplicate plugin id" warnings came from. A leading dot does NOT hide them
4217
4217
  // on Windows, so move them out of the tree entirely.
4218
4218
  const extDir = join(projectDir, '.openclaw', 'extensions');
@@ -4227,7 +4227,7 @@ async function migrateDockerProjectToNative(projectDir, { osChoice = '' } = {})
4227
4227
  }
4228
4228
 
4229
4229
  // 5. zalo-connect wrote its Zalo sessions next to the container's HOME, which was itself
4230
- // <project>/.openclaw leaving them one level too deep. Lift them so 3.1.5's one-time
4230
+ // <project>/.openclaw - leaving them one level too deep. Lift them so 3.1.5's one-time
4231
4231
  // migration finds them; without this every account comes back "not authenticated".
4232
4232
  const nested = join(projectDir, '.openclaw', '.openclaw');
4233
4233
  if (existsSync(nested)) {
@@ -4263,8 +4263,8 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
4263
4263
 
4264
4264
  // Windows has no usable service story for this (see windows-launcher-gen.js): the Scheduled
4265
4265
  // Task openclaw installs is bound to a login session, so it dies with the SSH session and is
4266
- // refused outright when set to run at boot. Give the owner double-click launchers instead
4267
- // the same shape as the .command files on macOS and put the three they actually press on
4266
+ // refused outright when set to run at boot. Give the owner double-click launchers instead -
4267
+ // the same shape as the .command files on macOS - and put the three they actually press on
4268
4268
  // the Desktop where they can find them.
4269
4269
  if (process.platform === 'win32') {
4270
4270
  await writeWindowsLaunchers(projectDir, gwPort, rtPort).catch((e) => sendLog(`[native] launcher: ${e.message}`));
@@ -4293,12 +4293,12 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
4293
4293
  await new Promise((r) => setTimeout(r, 8000));
4294
4294
  await applyResolved9RouterApiKey(projectDir).catch(() => {});
4295
4295
 
4296
- // 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
4297
4297
  // gateway for the same reason: a gateway that boots with its plugins already on disk loads them
4298
4298
  // straight away, needs no follow-up restart, and prints no "plugin not found" warnings.
4299
4299
  // Config migrations FIRST: `openclaw plugins install` validates the config, so a config
4300
4300
  // still carrying pre-2026.8 keys makes every plugin install fail before migrations ran
4301
- // (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).
4302
4302
  await runNativeConfigMigrations(projectDir).catch(() => {});
4303
4303
 
4304
4304
  await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin bootstrap skipped: ${e.message}`));
@@ -4320,8 +4320,8 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
4320
4320
  await hardenNativeServiceRestarts(projectDir).catch((e) => sendLog(`[native] service hardening skipped: ${e.message}`));
4321
4321
  await clearNativeServiceFailure(projectDir).catch(() => {});
4322
4322
  // `daemon install` already STARTED the unit, and that first boot begins the state migrations that
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
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
4325
4325
  // following boot exited 1 until it expired, and systemd's start limit abandoned the unit long
4326
4326
  // before that. The setup UI then waited out its whole timeout on a service that was never coming
4327
4327
  // back (real Ubuntu 24.04 VPS, 2026-08-28). So let this boot finish UNDISTURBED first, and only
@@ -4337,7 +4337,7 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
4337
4337
  if (envAdded.length || !firstBootOk) {
4338
4338
  sendLog(envAdded.length
4339
4339
  ? '[native] restarting gateway to load the completed service env'
4340
- : '[native] first boot never answered /health restarting once to recover');
4340
+ : '[native] first boot never answered /health - restarting once to recover');
4341
4341
  await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart after install: ${e.message}`));
4342
4342
  }
4343
4343
  sendLog(`[native] gateway service "${label}" running on 127.0.0.1:${gwPort}, 9router on 127.0.0.1:${rtPort}`);
@@ -4389,11 +4389,11 @@ async function syncDockerInfra(projectDir, force = false) {
4389
4389
  const compose = await readComposeText(projectDir);
4390
4390
 
4391
4391
  // If the compose was hand-customized (reverse-proxy/Traefik labels, an external network
4392
- // 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
4393
4393
  // docker-gen rewrite would wipe that routing (this once silently broke a live webhook).
4394
4394
  // Leave everything untouched; the version stamp stays old but each check just no-ops.
4395
4395
  if (/^\s*traefik\.|external:\s*true|openclaw-setup:\s*custom|openclaw-setup:keep/im.test(compose)) {
4396
- 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.');
4397
4397
  return false;
4398
4398
  }
4399
4399
 
@@ -4443,7 +4443,7 @@ async function syncDockerInfra(projectDir, force = false) {
4443
4443
 
4444
4444
  sendLog(`[sync] Updating Docker infrastructure files (v${existingVersion} \u2192 v${SETUP_VERSION})`);
4445
4445
  await fsp.writeFile(join(dockerDir, 'Dockerfile'), docker.dockerfile, 'utf8');
4446
- // 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
4447
4447
  // full regen only re-emits the default volumes, so without this the bot loses granted drives.
4448
4448
  let carriedMounts = [];
4449
4449
  try {
@@ -4460,7 +4460,7 @@ async function syncDockerInfra(projectDir, force = false) {
4460
4460
  let cc = await fsp.readFile(join(dockerDir, 'docker-compose.yml'), 'utf8');
4461
4461
  if (!cc.includes(`:${dp}`)) {
4462
4462
  const gpStr = String(gatewayPort);
4463
- // 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
4464
4464
  // "127.0.0.1:<gw>:<gw>", so keying off the container port (":<gw>" before the quote) is the
4465
4465
  // only reliable anchor. The old `(?:\d+:)?` variant never matched the "127.0.0.1:" prefix.
4466
4466
  cc = cc.replace(
@@ -4492,13 +4492,13 @@ async function syncDockerInfra(projectDir, force = false) {
4492
4492
  }
4493
4493
 
4494
4494
  async function recreateDockerBot(projectDir) {
4495
- // 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
4496
4496
  // reloading config after a bot/plugin change is just a service restart. Callers stay unchanged.
4497
4497
  if (isNativeProject(projectDir)) {
4498
4498
  // Never restart a gateway that is still on its first boot: OpenClaw runs startup migrations
4499
4499
  // under a state lease, a restart mid-migration exits 1, and systemd's start limit can then
4500
4500
  // abandon the unit. This is the same trap the docker path avoids by waiting for the container
4501
- // before touching it (see startZaloConnectLogin) wait for /health first.
4501
+ // before touching it (see startZaloConnectLogin) - wait for /health first.
4502
4502
  await waitForNativeGatewayHealthy(projectDir, NATIVE_GATEWAY_HEALTH_TIMEOUT_MS);
4503
4503
  // The bot that was just created/edited may have added the Zalo channel or the context engine to
4504
4504
  // openclaw.json; put those plugins on disk now so this one reload loads them too.
@@ -4538,13 +4538,13 @@ async function updateRuntime(target, projectDir) {
4538
4538
  probeCacheClear();
4539
4539
  return { ok: true, target, spec, mode: 'native' };
4540
4540
  }
4541
- // 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
4542
4542
  // exactly the step that lands it on openclaw >=2026.9 and breaks writing through the bind mount
4543
4543
  // (see migrateDockerProjectToNative). So the update IS the migration: move the data onto native
4544
4544
  // first, then update the native way. Doing it here rather than on every page load keeps it a
4545
4545
  // deliberate act by the customer, not something that reshapes their machine behind their back.
4546
4546
  if (!isNativeProject(projectDir) && projectDir && existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'))) {
4547
- sendLog('[update] Project đang chạy Docker chuyển sang native trước khi cập nhật.');
4547
+ sendLog('[update] Project đang chạy Docker - chuyển sang native trước khi cập nhật.');
4548
4548
  await ensureNodeInstalled();
4549
4549
  await migrateDockerProjectToNative(projectDir, { osChoice: state.os || '' });
4550
4550
  await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
@@ -4595,7 +4595,7 @@ async function restartDockerBotContainer(projectDir = state.projectDir) {
4595
4595
  sendLog(`[docker] Restarting ${containerName} container...`);
4596
4596
  await run('docker', ['restart', containerName], { shell: false });
4597
4597
  await waitForDockerContainer(containerName);
4598
- // 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.
4599
4599
  probeCacheClear(`runtime:${projectDir}`);
4600
4600
  return true;
4601
4601
  }
@@ -4647,7 +4647,7 @@ async function addBotMount(projectDir, hostPath, mountName = '') {
4647
4647
  // forward slashes on every OS, incl. `C:/Users/...`), drop trailing separators. This avoids
4648
4648
  // YAML backslash issues and keeps the path uniform.
4649
4649
  let cleanPath = String(hostPath || '').trim().replace(/\\+/g, '/').replace(/\/+$/, '');
4650
- // 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
4651
4651
  // above turns "D:/" into "D:". Restore the slash so mounting a whole drive (e.g. D:\) works.
4652
4652
  if (/^[a-zA-Z]:$/.test(cleanPath)) cleanPath += '/';
4653
4653
  if (!cleanPath) throw httpError(400, 'Đường dẫn ổ đĩa/thư mục đang trống');
@@ -4685,7 +4685,7 @@ async function addBotMount(projectDir, hostPath, mountName = '') {
4685
4685
  }
4686
4686
 
4687
4687
  // Sync a managed "granted mounts" block into every agent's AGENTS.md from the /mnt/* mounts in
4688
- // 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).
4689
4689
  async function updateGrantedMountsInAgents(projectDir) {
4690
4690
  const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
4691
4691
  const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
@@ -4714,7 +4714,7 @@ async function updateGrantedMountsInAgents(projectDir) {
4714
4714
  const END = '<!-- granted-mounts:end -->';
4715
4715
  const block = mounts.length
4716
4716
  ? `${START}\n## 💽 Thư mục/ổ đĩa được cấp quyền (toàn project)\n`
4717
- + 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')
4718
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}`
4719
4719
  : '';
4720
4720
  const blockRe = new RegExp(`\\n*${START}[\\s\\S]*?${END}\\n*`);
@@ -4854,7 +4854,7 @@ function whichSync(name) {
4854
4854
  const out = execFileSync(finder, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
4855
4855
  const hits = String(out).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
4856
4856
  if (process.platform !== 'win32') return hits[0] || '';
4857
- // `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
4858
4858
  // ("spawn ...\\npm\\claude ENOENT"), which is how an allow-listed CLI ended up unusable for the
4859
4859
  // bot. Prefer something Windows can actually execute.
4860
4860
  const rank = (f) => {
@@ -4871,7 +4871,7 @@ function whichSync(name) {
4871
4871
 
4872
4872
  /**
4873
4873
  * What to actually spawn for an allow-listed command. Windows needs the indirection:
4874
- * - 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;
4875
4875
  * - a `.cmd`/`.bat` shim cannot be spawned without a shell on current Node, so read it and run
4876
4876
  * what it points at (`…\pkg\bin\x.exe`, or node + a cli.js) directly.
4877
4877
  * Keeping shell:false matters: the bot supplies the arguments, and a shell would let one of them
@@ -4915,7 +4915,7 @@ function resolveHostExecutable(bin) {
4915
4915
  /**
4916
4916
  * macOS/Windows privacy panes for the permissions PC control needs. The OS never lets an app
4917
4917
  * grant these for you (that is the point of TCC), so the best we can do is take the operator
4918
- * 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.
4919
4919
  */
4920
4920
  function openPrivacyPane(kind) {
4921
4921
  const macPanes = {
@@ -5000,8 +5000,8 @@ function spawnDetached(command, args) {
5000
5000
 
5001
5001
  /**
5002
5002
  * Desktop actions for the bot: see the screen, move and click, type, read the clipboard, list and
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.
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.
5005
5005
  *
5006
5006
  * No native modules: the approach follows the dependency-free tools (and Anthropic's own
5007
5007
  * computer-use reference, which drives xdotool + a screenshot binary):
@@ -5012,7 +5012,7 @@ function spawnDetached(command, args) {
5012
5012
  * do not fork per platform.
5013
5013
  *
5014
5014
  * Windows note: input injection and screen capture need a real desktop session. When the installer
5015
- * 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
5016
5016
  * leaking a raw Win32Exception.
5017
5017
  */
5018
5018
  const HOST_UI_ACTIONS = new Set([
@@ -5026,7 +5026,7 @@ function hostUiScriptPath(projectDir) {
5026
5026
 
5027
5027
  async function ensureHostUiScript(projectDir) {
5028
5028
  const path = hostUiScriptPath(projectDir);
5029
- const stamp = `# OpenClaw host UI helper version ${HOST_UI_PS1_VERSION}`;
5029
+ const stamp = `# OpenClaw host UI helper - version ${HOST_UI_PS1_VERSION}`;
5030
5030
  try {
5031
5031
  if (existsSync(path) && (await fsp.readFile(path, 'utf8')).startsWith(stamp)) return path;
5032
5032
  } catch (_) {}
@@ -5087,8 +5087,8 @@ async function writeHostControlAccess(projectDir, cfg) {
5087
5087
  'Chủ đã cho phép bạn dùng máy này. Bạn có hai công cụ dưới đây và hãy dùng THẲNG chúng - đừng',
5088
5088
  'gọi HTTP, đừng tự dựng script PowerShell, đừng đi tìm dịch vụ phụ nào khác:',
5089
5089
  '',
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.',
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.',
5092
5092
  '',
5093
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
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,',
@@ -5149,7 +5149,7 @@ async function ensureChromeRelay() {
5149
5149
  // traffic to the relay. Open the port scoped to the PRIVATE bridge IP only (not reachable
5150
5150
  // from the internet). Best-effort; `ufw allow` skips duplicates on re-runs.
5151
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`])
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'));
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'));
5153
5153
  resolveP(true);
5154
5154
  });
5155
5155
  });
@@ -5158,9 +5158,9 @@ async function ensureChromeRelay() {
5158
5158
  // Launch real host Chrome in remote-debugging mode (port 9222) so the browser-automation plugin
5159
5159
  // can drive the user's actual Chrome (logged-in profile) instead of headless Chromium. The bot
5160
5160
  // reaches it via CDP (host.docker.internal:9222 from the container). Detached: keeps running after
5161
- // 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
5162
5162
  // client sends no Origin header, so the wildcard only widened who could drive the browser.
5163
- // 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
5164
5164
  // back copy-paste commands so the user runs Chrome on THEIR machine + a reverse SSH tunnel.
5165
5165
  // Where Chrome keeps the operator's own profile, per OS. Chrome must not already be running
5166
5166
  // on it when we attach the debug port, which is why the callers close Chrome first.
@@ -5177,7 +5177,7 @@ function defaultChromeProfileDir() {
5177
5177
 
5178
5178
  // The profile Chrome is actually launched with. Never the directory above: Chrome 136+ drops
5179
5179
  // --remote-debugging-port when it IS the default profile, so pointing there means Chrome opens
5180
- // 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".
5181
5181
  function debugChromeProfileDir() {
5182
5182
  if (process.platform === 'win32') {
5183
5183
  const localAppData = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
@@ -5189,7 +5189,7 @@ function debugChromeProfileDir() {
5189
5189
 
5190
5190
  // Seed it from the real profile once, so the bot inherits the operator's cookies, logins,
5191
5191
  // history and extensions instead of browsing as a brand-new profile (the clearest bot signal
5192
- // 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
5193
5193
  // hundred MB into several GB. Best-effort: a profile that fails to copy still opens, just
5194
5194
  // signed out.
5195
5195
  async function copyChromeProfileTree(src, dst) {
@@ -5257,8 +5257,8 @@ async function startChromeDebug() {
5257
5257
  }
5258
5258
  const port = 9222;
5259
5259
  // Launch against a dedicated profile seeded from the operator's real one. A throwaway
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
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
5262
5262
  // signed in to; the real profile itself cannot be used because Chrome 136+ drops the debug
5263
5263
  // port on it. The port is not what gets flagged: Chrome started this way carries no
5264
5264
  // --enable-automation, so navigator.webdriver stays false and there is no banner.
@@ -5269,7 +5269,7 @@ async function startChromeDebug() {
5269
5269
  if (process.env.OPENCLAW_CHROME_SEED_PROFILE === '1') {
5270
5270
  await seedDebugChromeProfile(defaultChromeProfileDir(), userDataDir, sendLog);
5271
5271
  } else if (!existsSync(join(userDataDir, 'Default'))) {
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 đó).');
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 đó).');
5273
5273
  }
5274
5274
  const args = [
5275
5275
  `--remote-debugging-port=${port}`,
@@ -5301,7 +5301,7 @@ async function waitForDockerDaemon(timeoutMs) {
5301
5301
 
5302
5302
  // OpenClaw's own engines range, copied verbatim from its package.json:
5303
5303
  // ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0"
5304
- // 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
5305
5305
  // wrong instinct here. When we have to install, we install 24 LTS, which sits inside a supported
5306
5306
  // window and is what every working host in the fleet already runs.
5307
5307
  const NODE_TARGET_MAJOR = 24;
@@ -5316,7 +5316,7 @@ function nodeVersionSupported(raw) {
5316
5316
  return false;
5317
5317
  }
5318
5318
 
5319
- // 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
5320
5320
  // docker mode, where the image carried its own. A customer machine that never needed Node before
5321
5321
  // (or carries one outside openclaw's supported range) must get a usable one before anything else,
5322
5322
  // otherwise the install dies deep inside `npm i -g openclaw` with an unreadable engine error.
@@ -5329,7 +5329,7 @@ async function ensureNodeInstalled() {
5329
5329
  const why = current.ok
5330
5330
  ? `Node ${current.output.trim()} nằm ngoài dải OpenClaw hỗ trợ`
5331
5331
  : 'Máy chưa có Node';
5332
- sendLog(`[node] ${why} đang cài Node ${NODE_TARGET_MAJOR} LTS...`);
5332
+ sendLog(`[node] ${why} - đang cài Node ${NODE_TARGET_MAJOR} LTS...`);
5333
5333
 
5334
5334
  if (process.platform === 'linux') {
5335
5335
  const root = typeof process.getuid === 'function' && process.getuid() === 0;
@@ -5339,7 +5339,7 @@ async function ensureNodeInstalled() {
5339
5339
  await run('sh', ['-c',
5340
5340
  `curl -fsSL https://deb.nodesource.com/setup_${NODE_TARGET_MAJOR}.x | ${sudo}bash - && ${sudo}apt-get install -y nodejs`,
5341
5341
  ]).catch(async () => {
5342
- sendLog('[node] apt không dùng được thử nvm...');
5342
+ sendLog('[node] apt không dùng được - thử nvm...');
5343
5343
  await run('sh', ['-c',
5344
5344
  `export NVM_DIR="$HOME/.nvm"; curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash && . "$NVM_DIR/nvm.sh" && nvm install ${NODE_TARGET_MAJOR} && nvm alias default ${NODE_TARGET_MAJOR}`,
5345
5345
  ]);
@@ -5387,7 +5387,7 @@ async function ensureDockerInstalled(osChoice) {
5387
5387
  const root = typeof process.getuid === 'function' && process.getuid() === 0;
5388
5388
  const sudo = root ? '' : 'sudo ';
5389
5389
  if (!cliOk.ok) {
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 (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)...');
5391
5391
  await run('sh', ['-c', `curl -fsSL https://get.docker.com -o /tmp/get-docker.sh && ${sudo}sh /tmp/get-docker.sh`]);
5392
5392
  if (!root) await run('sh', ['-c', 'sudo usermod -aG docker "$USER" || true']).catch(() => {});
5393
5393
  }
@@ -5412,7 +5412,7 @@ async function ensureDockerInstalled(osChoice) {
5412
5412
  sendLog('[docker] Mở Docker Desktop và chờ daemon khởi động...');
5413
5413
  await run('open', ['-a', 'Docker']).catch(() => {});
5414
5414
  if (!(await waitForDockerDaemon(120000))) {
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.');
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.');
5416
5416
  }
5417
5417
  sendLog('[docker] Docker đã sẵn sàng.');
5418
5418
  return;
@@ -5435,7 +5435,7 @@ async function ensureDockerInstalled(osChoice) {
5435
5435
  sendLog('[docker] Mở Docker Desktop và chờ daemon khởi động...');
5436
5436
  await run('cmd', ['/c', 'start', '', '%ProgramFiles%\\Docker\\Docker\\Docker Desktop.exe']).catch(() => {});
5437
5437
  if (!(await waitForDockerDaemon(120000))) {
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.');
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.');
5439
5439
  }
5440
5440
  sendLog('[docker] Docker đã sẵn sàng.');
5441
5441
  return;
@@ -5453,7 +5453,7 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
5453
5453
  state.os = osChoice;
5454
5454
  state.startedAt = new Date().toISOString();
5455
5455
  try {
5456
- // 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
5457
5457
  // actually holds them. Ask the host rather than assuming: a fresh machine keeps openclaw's and
5458
5458
  // 9router's real defaults, and a machine that already runs a docker project (or an SSH tunnel to
5459
5459
  // a remote bot) steps to the next free pair instead.
@@ -5468,16 +5468,16 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
5468
5468
  }
5469
5469
  sendLog('OpenClaw local installer started');
5470
5470
  sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
5471
- // 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
5472
5472
  // with a clear message rather than deep inside `docker compose up`. Native mode has no
5473
5473
  // container, so it skips this entirely (that is much of the point of choosing it).
5474
- // 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
5475
5475
  // one on the machine before npm touches anything. Docker still needs the daemon instead.
5476
5476
  if (mode === 'native') await ensureNodeInstalled();
5477
5477
  else await ensureDockerInstalled(osChoice);
5478
5478
  await writeCoreProject({ projectDir, osChoice, mode, gatewayPort, routerPort, userTimezone });
5479
5479
  await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
5480
- // 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
5481
5481
  // prepareNativeStateHome) must read the NEW version, not the one this host booted with.
5482
5482
  invalidateHostOpenclawVersion();
5483
5483
  await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
@@ -5580,7 +5580,7 @@ async function listMarkdownFiles(projectDir, agentId = '') {
5580
5580
 
5581
5581
  async function saveState(rootProjectDir) {
5582
5582
  // Selecting, adding or removing a project all end up here, and all of them make the cached
5583
- // 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.
5584
5584
  probeCacheClear('projects:');
5585
5585
  const file = join(rootProjectDir, STATE_FILE);
5586
5586
  await fsp.writeFile(file, JSON.stringify({
@@ -5659,7 +5659,7 @@ function isRestrictedSystemDir(dirPath) {
5659
5659
  }
5660
5660
 
5661
5661
  // Project roots of OpenClaw bot containers currently running under Docker. This is the
5662
- // 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 -
5663
5663
  // so a fresh `npx github:…` run (e.g. on a VPS where bots are already running) targets the
5664
5664
  // live project instead of defaulting to an empty ~/openclaw-setup folder.
5665
5665
  async function discoverDockerBotProjectRoots() {
@@ -5691,19 +5691,19 @@ async function discoverDockerBotProjectRoots() {
5691
5691
 
5692
5692
  // Native installs have no container to inspect, so we can't detect them the way Docker bots are
5693
5693
  // found. Instead scan for the `.openclaw/native.json` marker one level under the home dir and the
5694
- // 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)
5695
5695
  // without a full filesystem walk. Mirrors discoverDockerBotProjectRoots so discoverProjects can
5696
5696
  // surface native projects even when this install has no saved state for them.
5697
5697
  // A directory whose .openclaw merely HOLDS another project's state is not a project of its
5698
5698
  // own. openclaw 2026.8.x native layout puts the real state at ~/.openclaw with the project
5699
- // 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"
5700
5700
  // project tab after every refresh (measured on vps_c-thu). The native marker's `label` was
5701
5701
  // written from the ORIGINAL project dir, so a mismatch identifies the phantom.
5702
5702
  /**
5703
5703
  * Two paths can be the same project: a native install links the account home and the project dir
5704
5704
  * together, so `~/.openclaw` and `<project>/.openclaw` resolve to one directory on disk. Scanning
5705
5705
  * finds both and the dashboard then lists the SAME bots twice under two project names (seen on
5706
- * win_kha as "bot" and "VT 2025", 4 identical bots online in each 10/09/2026).
5706
+ * win_kha as "bot" and "VT 2025", 4 identical bots online in each - 10/09/2026).
5707
5707
  *
5708
5708
  * Dedupe on where `.openclaw` really lands, and keep the entry that IS the real directory: the
5709
5709
  * link side is an artefact of the install layout, not somewhere the customer put a project.
@@ -5731,7 +5731,7 @@ function isPhantomStateDirProject(dir) {
5731
5731
  try {
5732
5732
  const meta = readNativeMeta(dir);
5733
5733
  if (!meta || !meta.label) return false;
5734
- // Compare against the label DERIVED from the directory name nativeServiceLabel()
5734
+ // Compare against the label DERIVED from the directory name - nativeServiceLabel()
5735
5735
  // itself returns meta.label first, which would make this check always pass.
5736
5736
  const derived = `ai.openclaw.gateway.${slugify(basename(dir || 'openclaw'), 'bot')}`;
5737
5737
  return meta.label !== derived;
@@ -5837,7 +5837,7 @@ async function ensureProjectsLoaded(rootProjectDir) {
5837
5837
  }
5838
5838
 
5839
5839
  // The project list costs docker/native probes per project. It changes when someone creates or
5840
- // 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
5841
5841
  // the background: the dashboard opens instantly and is at most a few seconds stale.
5842
5842
  const PROJECTS_TTL_MS = 10000;
5843
5843
  function discoverProjects(rootProjectDir) {
@@ -5856,7 +5856,7 @@ async function computeDiscoverProjects(rootProjectDir) {
5856
5856
  }
5857
5857
  }
5858
5858
 
5859
- // 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.
5860
5860
  for (const dr of await discoverNativeProjectRoots(rootProjectDir)) {
5861
5861
  if (!state.projects.some(p => resolve(p.projectDir) === resolve(dr))) {
5862
5862
  const meta = await buildProjectMeta(dr).catch(() => null);
@@ -5873,11 +5873,11 @@ async function computeDiscoverProjects(rootProjectDir) {
5873
5873
  }
5874
5874
 
5875
5875
  // Drop phantom state-dir "projects" that slipped into the saved list (e.g. the account
5876
- // 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.
5877
5877
  state.projects = state.projects.filter((p) => !isPhantomStateDirProject(p.projectDir));
5878
5878
  // The label check above only catches projects that carry a marker with a mismatched label.
5879
5879
  // A linked twin without one still slips through and the dashboard shows the same bots under
5880
- // two project names, both "online" collapse those onto the real directory.
5880
+ // two project names, both "online" - collapse those onto the real directory.
5881
5881
  const keepDirs = new Set(dedupeProjectsByRealState(state.projects.map((p) => p.projectDir)));
5882
5882
  state.projects = state.projects.filter((p) => keepDirs.has(p.projectDir));
5883
5883
 
@@ -5903,7 +5903,7 @@ async function computeDiscoverProjects(rootProjectDir) {
5903
5903
  }
5904
5904
 
5905
5905
  async function resolveProjectDir(rootProjectDir, body = {}) {
5906
- // 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
5907
5907
  // keep sending a remembered projectDir (e.g. "/root") long after it stopped being one,
5908
5908
  // and accepting it re-saves the phantom into state on the next saveState().
5909
5909
  if (body.projectDir && existsSync(join(resolve(String(body.projectDir)), '.openclaw', 'openclaw.json'))
@@ -5963,8 +5963,8 @@ async function connectExistingProject(projectDir, rootProjectDir) {
5963
5963
  if (!existsSync(join(resolved, '.openclaw', 'openclaw.json'))) throw httpError(404, 'openclaw.json not found in selected project');
5964
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`);
5965
5965
  // Switch the active project + return its bots FAST (a plain file read). The heavy runtime
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
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
5968
5968
  // deferred to the background so the UI switches instantly. The frontend's loadStatus/loadSystem
5969
5969
  // refresh live status + versions right after.
5970
5970
  state.projectDir = resolved;
@@ -6093,7 +6093,7 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
6093
6093
 
6094
6094
  const k = `${kind}:${id}`;
6095
6095
 
6096
- // 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
6097
6097
  // (the UI also locks the toggle; this is the backend guard).
6098
6098
  if (kind === 'plugin' && (id === 'zalo-connect' || id === 'openclaw-zalo-connect') && !enabled) {
6099
6099
  const hasZaloBot = (cfg.bindings || []).some((b) => b?.match?.channel === 'zalo-connect');
@@ -6322,7 +6322,7 @@ async function installFeature(projectDir, agentId, kind, id) {
6322
6322
  }
6323
6323
 
6324
6324
  if (isNativeProject(projectDir)) {
6325
- // 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
6326
6326
  // skill lands in this project's workspace, then reload the managed gateway service.
6327
6327
  sendLog(`[skill] Installing/updating clawhub:${slug} natively for agent ${agentId}...`);
6328
6328
  const out = await ocCaptureInstall(projectDir, ['skills', 'install', slug, '--agent', agentId, '--force', '--acknowledge-install-policy-warning']);
@@ -6381,7 +6381,7 @@ async function installFeature(projectDir, agentId, kind, id) {
6381
6381
  }
6382
6382
 
6383
6383
  if (kind === 'plugin') {
6384
- // 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
6385
6385
  // clawhub:latest like other plugins, so the dashboard "Update" button always fetches the newest
6386
6386
  // published version (no tag pin to bump each release).
6387
6387
  if (id === 'zalo-connect' || id === 'openclaw-zalo-connect') {
@@ -6437,7 +6437,7 @@ async function installFeature(projectDir, agentId, kind, id) {
6437
6437
 
6438
6438
  let composeDir = null;
6439
6439
  if (isNativeProject(projectDir)) {
6440
- // 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
6441
6441
  // installs into this project's .openclaw/extensions instead of the default ~/.openclaw.
6442
6442
  composeDir = null;
6443
6443
  } else if (existsSync(join(projectDir, 'docker-compose.yml'))) {
@@ -6492,7 +6492,7 @@ async function installFeature(projectDir, agentId, kind, id) {
6492
6492
  hostOs: await resolveProjectHostOs(projectDir),
6493
6493
  // The plugin ships these off: editing the Docker build files, running page JavaScript
6494
6494
  // and uploading local files are things it will not do until an operator says so.
6495
- // 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
6496
6496
  // would need a hand-edited config right after a one-click install.
6497
6497
  ...browserAutomationOptIns(),
6498
6498
  });
@@ -6532,7 +6532,7 @@ async function installFeature(projectDir, agentId, kind, id) {
6532
6532
  // Browser-automation plugin needs Docker rebuild for Playwright/Chromium deps
6533
6533
  const isBrowserPlugin = id === 'openclaw-browser-automation' || id === 'browser-automation';
6534
6534
  if (isNativeProject(projectDir)) {
6535
- // 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.
6536
6536
  sendLog('[plugin] Restarting native gateway to apply plugin...');
6537
6537
  await restartNativeRuntime(projectDir).catch((err) => sendLog(`[plugin] restart failed: ${err.message}`));
6538
6538
  } else if (isBrowserPlugin && composeDir) {
@@ -6603,7 +6603,7 @@ async function installFeature(projectDir, agentId, kind, id) {
6603
6603
  hostOs: await resolveProjectHostOs(projectDir),
6604
6604
  // The plugin ships these off: editing the Docker build files, running page JavaScript
6605
6605
  // and uploading local files are things it will not do until an operator says so.
6606
- // 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
6607
6607
  // would need a hand-edited config right after a one-click install.
6608
6608
  ...browserAutomationOptIns(),
6609
6609
  });
@@ -6622,7 +6622,7 @@ async function installFeature(projectDir, agentId, kind, id) {
6622
6622
  }
6623
6623
  }
6624
6624
  }
6625
- // 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
6626
6626
  // extension versions and runtime status so the next page load re-probes fresh.
6627
6627
  probeCacheClear(`extver:${projectDir}`);
6628
6628
  probeCacheClear(`runtime:${projectDir}`);
@@ -6789,7 +6789,7 @@ async function getFeatureFlags(projectDir, agentId = '') {
6789
6789
  'plugin:openclaw-facebook-crawler': isActuallyInstalled(aliases.crawler),
6790
6790
  'plugin:openclaw-n8n-facebook-poster': isActuallyInstalled(aliases.poster),
6791
6791
  // fb-messenger is auto-added to plugins.allow by the wizard, so the allow-list is NOT
6792
- // 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.
6793
6793
  'plugin:openclaw-fb-messenger': extensionDirExists(aliases.fbMessenger)
6794
6794
  || aliases.fbMessenger.some((a) => installedKeys.has(a) || Array.from(installedSpecs).some((spec) => spec.includes(a))),
6795
6795
  'plugin:learning-memory': isActuallyInstalled(aliases.learningMemory),
@@ -6806,7 +6806,7 @@ async function getFeatureFlags(projectDir, agentId = '') {
6806
6806
  'plugin:zalo-connect': await getInstalledPluginVersion(projectDir, aliases.zaloConnect),
6807
6807
  };
6808
6808
  // Docker: the container's extensions volume is the SOURCE OF TRUTH for installed
6809
- // 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
6810
6810
  // (bind-mounted .openclaw or a stale installs.json) can lag behind after an update.
6811
6811
  // When the container reports a version, it OVERRIDES the host value (not just fills
6812
6812
  // empties) so the card shows the actually-installed version, not a stale one. Native
@@ -7054,7 +7054,7 @@ async function handler(req, res, rootProjectDir) {
7054
7054
  }
7055
7055
  // Take the operator to the OS privacy pane PC control needs (screen recording, accessibility).
7056
7056
  // The OS alone can grant these; `probe` additionally triggers the macOS screen-capture prompt
7057
- // 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.
7058
7058
  if (url.pathname === '/api/host/permissions' && req.method === 'POST') {
7059
7059
  const body = await readJson(req).catch(() => ({}));
7060
7060
  const kind = String(body.kind || 'screen').toLowerCase();
@@ -7077,34 +7077,38 @@ async function handler(req, res, rootProjectDir) {
7077
7077
  try {
7078
7078
  if (isGit) {
7079
7079
  // Clone/dev install: pull the latest (committed dist comes with it).
7080
- sendLog('[update-setup] Git install detected pulling latest from GitHub…');
7080
+ sendLog('[update-setup] Git install detected - pulling latest from GitHub…');
7081
7081
  await run('git', ['pull', '--ff-only'], { cwd: installerDir });
7082
7082
  await run('npm', ['install', '--omit=dev', '--no-audit', '--no-fund'], { cwd: installerDir });
7083
- // 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
7084
7084
  // don't need to: dist/ is committed. Only rebuild when tooling is present.
7085
7085
  if (existsSync(resolve(installerDir, 'docs_dev'))) {
7086
7086
  await run('npm', ['run', 'build'], { cwd: installerDir }).catch((e) =>
7087
7087
  sendLog(`[update-setup] build skipped: ${e.message}`));
7088
7088
  }
7089
7089
  } else if (isGlobalNpm) {
7090
- // 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
7091
7091
  // respawn path for a hand-run UI) relaunches onto the freshly installed dist.
7092
7092
  // Cài vào ĐÚNG prefix của bản đang chạy, không phải prefix mà `npm` trong PATH
7093
7093
  // của service trỏ tới. Máy khách hay có HAI npm global (nvm + npm hệ thống): đo trên
7094
- // 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`
7095
7095
  // lại cài vào `/root/.nvm/versions/node/v24.20.0/lib/node_modules` (5.16.7). Log báo
7096
- // "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.
7097
7097
  // installerDir = <prefix>/lib/node_modules/create-openclaw-bot ⇒ lùi 3 cấp là prefix.
7098
7098
  const npmPrefix = resolve(installerDir, '..', '..', '..');
7099
7099
  const prefixArgs = /[\\/]lib[\\/]node_modules[\\/]create-openclaw-bot[\\/]?$/.test(installerDir)
7100
7100
  ? ['--prefix', npmPrefix]
7101
7101
  : [];
7102
- 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})` : ''}…`);
7103
7103
  await run('npm', ['i', '-g', 'create-openclaw-bot@latest', '--no-audit', '--no-fund', ...prefixArgs], { cwd: installerDir });
7104
7104
  } else {
7105
- // Ephemeral `npx github:…` install: nothing to pull in place the relaunch
7106
- // re-runs `npx github:…`, which fetches the latest from GitHub.
7107
- sendLog('[update-setup] Fetching the latest from GitHub on relaunch…');
7105
+ // An npx install: warm the package cache HERE, while the UI is still up and the
7106
+ // browser is still watching the log. Leaving it to the relaunch meant the download
7107
+ // happened with no server running and nothing to report progress to, so a slow link
7108
+ // looked exactly like a crash.
7109
+ sendLog('[update-setup] Tải bản mới từ npm…');
7110
+ await run('npm', ['cache', 'add', 'create-openclaw-bot@latest'], { cwd: installerDir })
7111
+ .catch((e) => sendLog(`[update-setup] npm cache add: ${e.message}`));
7108
7112
  }
7109
7113
  restartInstaller();
7110
7114
  } catch (err) {
@@ -7122,7 +7126,7 @@ async function handler(req, res, rootProjectDir) {
7122
7126
  if (result.warning) sendLog(`⚠️ ${result.warning}`);
7123
7127
  // A first Zalo bot changes the project's docker infra needs (the entrypoint must
7124
7128
  // install the pinned zalo-connect plugin BEFORE the gateway starts). Force-resync so
7125
- // the recreate below ships the zaloBackend-aware entrypoint without this, the
7129
+ // the recreate below ships the zaloBackend-aware entrypoint - without this, the
7126
7130
  // login flow has to install mid-boot and restart the container, which can
7127
7131
  // interrupt OpenClaw's first-run migrations and wedge its state lease.
7128
7132
  if (result.channel === 'zalo-personal') {
@@ -7216,14 +7220,14 @@ async function handler(req, res, rootProjectDir) {
7216
7220
  }
7217
7221
  if (req.method === 'PUT') {
7218
7222
  // Allow the same text types the file tree marks editable (it exposes .json/.js/.yml/…,
7219
- // not just .md the old .md-only guard made "Save" silently fail on those files).
7223
+ // not just .md - the old .md-only guard made "Save" silently fail on those files).
7220
7224
  const writableExt = new Set(['.md', '.txt', '.json', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.yml', '.yaml', '.env', '.sh', '.bat', '.ps1', '.html', '.css']);
7221
7225
  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'})`);
7222
7226
  const body = await readJson(req);
7223
7227
  const projectDir = await resolveProjectDir(rootProjectDir, body);
7224
7228
  const file = safeJoin(projectDir, name);
7225
7229
  const content = String(body.content || '');
7226
- // Don't let a typo brick openclaw.json & friends reject invalid JSON with a clear error.
7230
+ // Don't let a typo brick openclaw.json & friends - reject invalid JSON with a clear error.
7227
7231
  if (extname(name).toLowerCase() === '.json') {
7228
7232
  try { JSON.parse(content); } catch (e) { throw httpError(400, `JSON không hợp lệ: ${e.message}`); }
7229
7233
  }
@@ -7292,7 +7296,7 @@ function openUrl(url) {
7292
7296
  function restartInstaller() {
7293
7297
  // Emit the phrase the UI watches for (see appendLogLine) BEFORE we tear down the
7294
7298
  // server, so the browser tab starts polling and then reloads onto the new UI once
7295
- // it's back up on the SAME host/port instead of hanging on a dead server.
7299
+ // it's back up on the SAME host/port - instead of hanging on a dead server.
7296
7300
  sendLog('[update-setup] Setup Wizard updated successfully! Restarting UI to apply the new version...');
7297
7301
 
7298
7302
  const underSystemd = !!(process.env.INVOCATION_ID || process.env.JOURNAL_STREAM);
@@ -7306,11 +7310,11 @@ function restartInstaller() {
7306
7310
  try { activeServerInstance.close(); } catch {}
7307
7311
  }
7308
7312
 
7309
- // Under a service manager (systemd, pm2, …) just exit it relaunches us with
7313
+ // Under a service manager (systemd, pm2, …) just exit - it relaunches us with
7310
7314
  // the freshly pulled code. Re-spawning ourselves would escape the unit and
7311
7315
  // collide on the port.
7312
7316
  if (underSystemd) {
7313
- sendLog('[update-setup] Service-managed install exiting so the supervisor relaunches the new version.');
7317
+ sendLog('[update-setup] Service-managed install - exiting so the supervisor relaunches the new version.');
7314
7318
  setTimeout(() => process.exit(0), 400);
7315
7319
  return;
7316
7320
  }
@@ -7323,17 +7327,34 @@ function restartInstaller() {
7323
7327
  ];
7324
7328
 
7325
7329
  let bin, spawnArgs, opts;
7326
- if (isNpx) {
7327
- // Ephemeral `npx github:…` run re-fetch the latest from GitHub and relaunch.
7330
+ // Inheriting the parent's stdio is what killed this on Windows. The dashboard is started by
7331
+ // the hidden launcher (wscript -> cmd), whose console is torn down the moment this process
7332
+ // exits - and the child inherits it, so the replacement died with its parent and the tab sat
7333
+ // on "Không thể kết nối lại với Setup UI". A relaunch must not depend on the parent's
7334
+ // console at all. Measured on a customer machine.
7335
+ const relaunchStdio = 'ignore';
7336
+ const winLauncher = process.platform === 'win32'
7337
+ ? [join(activeUiProjectDir || '', 'run-hidden.vbs'), join(activeUiProjectDir || '', 'setup-ui.cmd')]
7338
+ : null;
7339
+ if (winLauncher && winLauncher.every((f) => existsSync(f))) {
7340
+ // Best case on Windows: hand back to the very launcher the operator uses. It already knows
7341
+ // the host, port and project dir, and it puts the UI in their desktop session.
7342
+ bin = 'wscript.exe';
7343
+ spawnArgs = winLauncher;
7344
+ opts = { detached: true, stdio: relaunchStdio, shell: false, windowsHide: true };
7345
+ } else if (isNpx) {
7346
+ // An npx install came from npm, so update from npm. Pulling `github:…` instead rebuilt the
7347
+ // repo from source on every update: minutes of clone plus install, long past the point the
7348
+ // browser gives up reconnecting, and a different artifact from the one they installed.
7328
7349
  const win = process.platform === 'win32';
7329
7350
  bin = win ? 'npx.cmd' : 'npx';
7330
- spawnArgs = ['-y', 'github:tuanminhhole/openclaw-setup', ...uiArgs];
7331
- opts = { detached: true, stdio: 'inherit', shell: win };
7351
+ spawnArgs = ['--yes', 'create-openclaw-bot@latest', ...uiArgs];
7352
+ opts = { detached: true, stdio: relaunchStdio, shell: win };
7332
7353
  } else {
7333
- // Local clone / file install re-run this entry (git pull already updated it).
7354
+ // Local clone / file install - re-run this entry (git pull already updated it).
7334
7355
  bin = process.argv[0];
7335
7356
  spawnArgs = [process.argv[1], ...uiArgs];
7336
- opts = { detached: true, stdio: 'inherit', shell: false };
7357
+ opts = { detached: true, stdio: relaunchStdio, shell: false };
7337
7358
  }
7338
7359
 
7339
7360
  // Brief delay to let the port fully release before the child binds it.
@@ -7354,7 +7375,7 @@ function restartInstaller() {
7354
7375
 
7355
7376
  /**
7356
7377
  * One-time convenience: drop a short `openclaw-ui` command into the user's shell
7357
- * profile so reopening the wizard later is a single word no long manual setup.
7378
+ * profile so reopening the wizard later is a single word - no long manual setup.
7358
7379
  * OS/shell-aware, idempotent, and fully best-effort (never throws, never blocks
7359
7380
  * startup). Only runs for npx-installed users (the cache dir must exist).
7360
7381
  */
@@ -7362,7 +7383,7 @@ function ensureReopenShortcut() {
7362
7383
  try {
7363
7384
  const home = os.homedir();
7364
7385
  const cliPath = join(home, '.openclaw-setup', 'node_modules', 'create-openclaw-bot', 'dist', 'cli.js');
7365
- if (!existsSync(cliPath)) return; // running from a cloned repo (dev) nothing to shortcut
7386
+ if (!existsSync(cliPath)) return; // running from a cloned repo (dev) - nothing to shortcut
7366
7387
  const MARK = '# >>> openclaw-ui (auto-added by OpenClaw Setup) >>>';
7367
7388
  const END = '# <<< openclaw-ui <<<';
7368
7389
 
@@ -7377,7 +7398,7 @@ function ensureReopenShortcut() {
7377
7398
  const block = `\n${MARK}\nfunction openclaw-ui { $env:OPENCLAW_SETUP_WIZARD="true"; node "${cliPath.replace(/\\/g, '\\\\')}" }\n${END}\n`;
7378
7399
  fs.mkdirSync(dirname(profile), { recursive: true });
7379
7400
  fs.appendFileSync(profile, block, 'utf8');
7380
- console.log("✓ Shortcut installed open a NEW PowerShell and type: openclaw-ui");
7401
+ console.log("✓ Shortcut installed - open a NEW PowerShell and type: openclaw-ui");
7381
7402
  } else {
7382
7403
  const shell = process.env.SHELL || '';
7383
7404
  const rcName = shell.includes('zsh') ? '.zshrc' : shell.includes('bash') ? '.bashrc' : '.profile';
@@ -7386,7 +7407,7 @@ function ensureReopenShortcut() {
7386
7407
  if (content.includes(MARK)) { console.log("💡 Reopen anytime with: openclaw-ui"); return; }
7387
7408
  const block = `\n${MARK}\nalias openclaw-ui='OPENCLAW_SETUP_WIZARD=true node "${cliPath}"'\n${END}\n`;
7388
7409
  fs.appendFileSync(rc, block, 'utf8');
7389
- console.log(`✓ Shortcut added to ~/${rcName} open a NEW terminal (or run 'source ~/${rcName}') and type: openclaw-ui`);
7410
+ console.log(`✓ Shortcut added to ~/${rcName} - open a NEW terminal (or run 'source ~/${rcName}') and type: openclaw-ui`);
7390
7411
  }
7391
7412
  } catch { /* best-effort: a shortcut failure must never break startup */ }
7392
7413
  }
@@ -7423,10 +7444,10 @@ function isLocalPortListening(port, host = '127.0.0.1', timeout = 400) {
7423
7444
  sock.once('error', () => done(false));
7424
7445
  });
7425
7446
  }
7426
- // On a headless server there's no local browser print an SSH-tunnel command so the
7447
+ // On a headless server there's no local browser - print an SSH-tunnel command so the
7427
7448
  // operator can reach the dashboard AND the Open-web UIs from their own machine. This is
7428
7449
  // the discoverable answer for ANY user on a VPS (no manual ssh-config knowledge needed).
7429
- // CHỈ forward những port đang THỰC SỰ listen trên host gateway (18789) / 9Router (20128)
7450
+ // CHỈ forward những port đang THỰC SỰ listen trên host - gateway (18789) / 9Router (20128)
7430
7451
  // thường nằm trong Docker, không bind ra host, nên nếu forward cứng sẽ đẻ ra hàng loạt
7431
7452
  // "channel: open failed: connect failed: Connection refused" vô nghĩa ở phía client.
7432
7453
  async function printRemoteAccessHint(uiPort) {
@@ -7449,7 +7470,7 @@ async function printRemoteAccessHint(uiPort) {
7449
7470
  // itself: the gateway can no longer bind 18789, and the self-loop accepts connections while
7450
7471
  // answering nothing, so the port looks alive and the whole install hangs on "Waiting for gateway".
7451
7472
  // Cost a customer VPS install on 2026-08-28.
7452
- console.log(' ⚠️ Run that line on YOUR OWN machine NOT in this shell. On the server it');
7473
+ console.log(' ⚠️ Run that line on YOUR OWN machine - NOT in this shell. On the server it');
7453
7474
  console.log(` steals port ${ports.join('/')} from the bot and the install never finishes.`);
7454
7475
  console.log('');
7455
7476
  }
@@ -7466,12 +7487,46 @@ async function detectExistingSetupUi(host, port) {
7466
7487
  }
7467
7488
  }
7468
7489
 
7490
+ /**
7491
+ * On Windows, explain a port we could not bind instead of silently moving.
7492
+ *
7493
+ * Hyper-V / WinNAT reserves blocks of TCP ports for its own dynamic use, and anything inside a
7494
+ * block fails to bind with no useful error. The blocks are re-randomised AT EVERY BOOT, so a port
7495
+ * that worked yesterday is simply gone today - which is what happened on a customer machine:
7496
+ * 51739-51838 swallowed the dashboard's 51789, the UI hopped to 51839, and the operator's SSH
7497
+ * tunnel (pointing at 51789) went dead with nothing anywhere saying why.
7498
+ *
7499
+ * Returns a printable explanation, or '' when the port is not inside a reserved block.
7500
+ */
7501
+ async function windowsReservedPortNote(port) {
7502
+ if (process.platform !== 'win32') return '';
7503
+ const r = await runCapture('netsh', ['interface', 'ipv4', 'show', 'excludedportrange', 'protocol=tcp'],
7504
+ { timeout: 15000 }).catch(() => null);
7505
+ if (!r || r.code !== 0) return '';
7506
+ for (const line of String(r.stdout || '').split('\n')) {
7507
+ const m = line.trim().match(/^(\d+)\s+(\d+)/);
7508
+ if (!m) continue;
7509
+ const start = Number(m[1]);
7510
+ const end = Number(m[2]);
7511
+ if (port < start || port > end) continue;
7512
+ return [
7513
+ `Windows đang giữ dải cổng ${start}-${end}, trong đó có ${port}, nên không mở được cổng này.`,
7514
+ '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.',
7515
+ `Giữ chỗ ${port} vĩnh viễn bằng PowerShell chạy quyền Administrator:`,
7516
+ ' net stop winnat',
7517
+ ` netsh int ipv4 add excludedportrange protocol=tcp startport=${port} numberofports=1 store=persistent`,
7518
+ ' net start winnat',
7519
+ ].join('\n');
7520
+ }
7521
+ return '';
7522
+ }
7523
+
7469
7524
  export async function startLocalInstaller({ host = '127.0.0.1', preferredPort = 51789, openBrowser = true, projectDir = process.cwd() } = {}) {
7470
7525
  const port = await findPort(host, preferredPort);
7471
7526
  if (port !== preferredPort && (await detectExistingSetupUi(host, preferredPort))) {
7472
7527
  // Another Setup UI already owns the preferred port (a systemd service, an earlier npx run…).
7473
7528
  // Hopping to :51790 here is exactly how operators end up with SSH tunnels and printed hints
7474
- // pointing at a port nothing serves so reuse the running instance instead of starting a
7529
+ // pointing at a port nothing serves - so reuse the running instance instead of starting a
7475
7530
  // second one, and keep this process alive so an `ssh -L … "npx create-openclaw-bot"`
7476
7531
  // one-liner still holds the tunnel open. If the other instance ever goes away, take the
7477
7532
  // port over so the URL keeps working without the operator re-running anything.
@@ -7480,20 +7535,25 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
7480
7535
  activeUiPort = preferredPort;
7481
7536
  activeUiProjectDir = projectDir;
7482
7537
  console.log(`OpenClaw Setup UI is already running: ${url}`);
7483
- console.log('Reusing the running instance nothing new was started. Keep this window open if it holds your SSH tunnel.');
7538
+ console.log('Reusing the running instance - nothing new was started. Keep this window open if it holds your SSH tunnel.');
7484
7539
  ensureReopenShortcut();
7485
7540
  if (openBrowser) openUrl(url);
7486
7541
  printRemoteAccessHint(preferredPort).catch(() => {});
7487
7542
  const takeover = setInterval(async () => {
7488
7543
  if ((await findPort(host, preferredPort)) !== preferredPort) return; // still busy
7489
7544
  clearInterval(takeover);
7490
- console.log(`Port ${preferredPort} freed up starting a Setup UI there to keep ${url} working.`);
7545
+ console.log(`Port ${preferredPort} freed up - starting a Setup UI there to keep ${url} working.`);
7491
7546
  startLocalInstaller({ host, preferredPort, openBrowser: false, projectDir }).catch(() => {});
7492
7547
  }, 5000);
7493
7548
  return;
7494
7549
  }
7495
7550
  if (port !== preferredPort) {
7496
- console.log(`⚠ Port ${preferredPort} is busy with something that is not a Setup UI using ${port} instead.`);
7551
+ console.log(`⚠ Port ${preferredPort} is busy with something that is not a Setup UI - using ${port} instead.`);
7552
+ // A port the operator has bookmarked or tunnelled to is worth one extra check before we give
7553
+ // up on it: on Windows the cause is usually invisible, and naming it turns a lost afternoon
7554
+ // into one command.
7555
+ const note = await windowsReservedPortNote(preferredPort).catch(() => '');
7556
+ if (note) console.log(note);
7497
7557
  }
7498
7558
  activeUiHost = host;
7499
7559
  activeUiPort = port;