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