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.
- package/README.md +62 -62
- package/README.vi.md +57 -57
- package/dist/cli.js +4 -4
- package/dist/server/local-server.js +276 -216
- package/dist/setup/data/channels.js +7 -7
- package/dist/setup/data/header.js +1 -1
- package/dist/setup/data/index.js +1 -1
- package/dist/setup/data/plugins.js +3 -3
- package/dist/setup/data/providers.js +7 -7
- package/dist/setup/shared/bot-config-gen.js +34 -34
- package/dist/setup/shared/common-gen.js +6 -6
- package/dist/setup/shared/docker-gen.js +23 -23
- package/dist/setup/shared/host-ui-ps1.js +1 -1
- package/dist/setup/shared/install-gen.js +9 -9
- package/dist/setup/shared/windows-launcher-gen.js +22 -8
- package/dist/setup/shared/workspace-gen.js +144 -144
- package/dist/web/app.js +112 -33
- package/dist/web/styles.css +6 -6
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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")
|
|
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
|
|
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
|
|
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
|
|
379
|
-
// name
|
|
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
|
|
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**
|
|
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.*
|
|
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)
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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)}"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}"
|
|
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*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3533
|
-
* fails with `Unsupported channel "zalo-connect"`
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
3719
|
-
throw new Error(`${homeState} đã chứa state của một project khác
|
|
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
|
|
3738
|
-
* 1. `computer` (+ `screen`) allowed in tools.alsoAllow
|
|
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
|
|
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"`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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})
|
|
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
|
|
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
|
-
//
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4324
|
-
// the env completed below
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}\`
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
5004
|
-
* already runs on the operator's machine and already opens apps for it
|
|
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
|
|
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
|
|
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`
|
|
5091
|
-
'- `computer`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
5261
|
-
// new on every run
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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"
|
|
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"
|
|
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
|
|
5967
|
-
// exec) and used to run TWICE here (syncRuntimeState + buildProjectMeta), ~6s total
|
|
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
|
|
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
|
|
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`)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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ũ
|
|
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
|
|
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
|
-
//
|
|
7106
|
-
//
|
|
7107
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
7327
|
-
|
|
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 = ['
|
|
7331
|
-
opts = { detached: true, stdio:
|
|
7351
|
+
spawnArgs = ['--yes', 'create-openclaw-bot@latest', ...uiArgs];
|
|
7352
|
+
opts = { detached: true, stdio: relaunchStdio, shell: win };
|
|
7332
7353
|
} else {
|
|
7333
|
-
// Local clone / file install
|
|
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:
|
|
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
|
|
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)
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|