create-openclaw-bot 5.15.1 → 5.15.4
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 +2 -2
- package/README.vi.md +2 -2
- package/dist/server/local-server.js +822 -89
- package/dist/setup/shared/bot-config-gen.js +10 -2
- package/dist/setup/shared/host-ui-ps1.js +179 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
|
-
import fs, { createReadStream, existsSync, promises as fsp } from 'fs';
|
|
2
|
+
import fs, { createReadStream, existsSync, readFileSync, promises as fsp } from 'fs';
|
|
3
3
|
import { createRequire } from 'module';
|
|
4
4
|
import { basename, dirname, extname, join, normalize, resolve } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
@@ -16,6 +16,7 @@ function loadSharedModule(modulePath, globalName) {
|
|
|
16
16
|
const { buildWorkspaceFileMap, buildCronjobSkillMd, buildInfographicGeneratorSkillMd, buildInfographicGeneratorJs } = loadSharedModule('../setup/shared/workspace-gen.js', '__openclawWorkspace');
|
|
17
17
|
const { buildOpenclawJson, buildEnvFileContent, buildExecApprovalsJson, buildZaloConnectChannelConfig } = loadSharedModule('../setup/shared/bot-config-gen.js', '__openclawBotConfig');
|
|
18
18
|
const { buildDockerArtifacts } = loadSharedModule('../setup/shared/docker-gen.js', '__openclawDockerGen');
|
|
19
|
+
const { HOST_UI_PS1, HOST_UI_PS1_VERSION } = loadSharedModule('../setup/shared/host-ui-ps1.js', '__openclawHostUiPs1');
|
|
19
20
|
const { OPENCLAW_NPM_SPEC, NINE_ROUTER_NPM_SPEC, ZALO_CHANNEL_ID, ZALO_PLUGIN_ID, ZALO_CONNECT_VERSION, ZALO_CONNECT_PLUGIN_SPEC, build9RouterProviderConfig, get9RouterBaseUrl } = loadSharedModule('../setup/shared/common-gen.js', '__openclawCommon');
|
|
20
21
|
const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
|
|
21
22
|
|
|
@@ -24,7 +25,7 @@ const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
|
|
|
24
25
|
// scripts) runs a dedicated profile seeded from the operator's real one. Kept outside Chrome's
|
|
25
26
|
// own folders: the block is an exact match on the default directory, and a sibling of it is a
|
|
26
27
|
// needless bet on that staying true.
|
|
27
|
-
const CHROME_SCRIPT_MARKER = '
|
|
28
|
+
const CHROME_SCRIPT_MARKER = 'OPENCLAW_CHROME_PROFILE_V3';
|
|
28
29
|
const CHROME_DEBUG_PROFILE_LEAF_WIN = 'OpenClaw\\chrome-profile';
|
|
29
30
|
const CHROME_DEBUG_PROFILE_LEAF_MAC = 'Library/Application Support/OpenClaw/chrome-profile';
|
|
30
31
|
const CHROME_DEBUG_PROFILE_LEAF_LINUX = '.config/openclaw/chrome-profile';
|
|
@@ -104,6 +105,15 @@ async function pushBrowserScriptsIntoContainer(projectDir, aliases, files, sendL
|
|
|
104
105
|
return pushed;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The browser-automation plugin defaults every high-impact behaviour to off (ClawHub's review reads
|
|
110
|
+
* broad defaults as "rogue agent", fairly). A dashboard install is an explicit, informed action, so
|
|
111
|
+
* the flags are turned on here — and stay visible in openclaw.json for anyone who wants them off.
|
|
112
|
+
*/
|
|
113
|
+
function browserAutomationOptIns() {
|
|
114
|
+
return { patchDocker: true, allowPageScripting: true, allowFileUpload: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
107
117
|
async function patchBrowserAutomationHostPreference(projectDir, aliases = [], sendLog = () => {}) {
|
|
108
118
|
const preferredCdpBlock = `const dns = require('dns').promises;
|
|
109
119
|
const DEFAULT_CDP_URLS = [
|
|
@@ -200,9 +210,20 @@ async function connectPreferredChrome() {
|
|
|
200
210
|
// the same way and does not care.
|
|
201
211
|
'ping -n 3 127.0.0.1 >nul',
|
|
202
212
|
'',
|
|
213
|
+
// Copying the real profile duplicates cookies, logins, history and extensions into a second
|
|
214
|
+
// directory and has to close every Chrome window to do it. That is the operator's call, not
|
|
215
|
+
// a silent default: without OPENCLAW_CHROME_SEED_PROFILE=1 this starts a clean profile and
|
|
216
|
+
// they sign in once, in the window that opens.
|
|
203
217
|
'if not exist "%OPENCLAW_CHROME_PROFILE_DIR%\\Default" (',
|
|
204
218
|
// No parentheses in text inside an if-block: cmd closes the block on the first ")".
|
|
205
|
-
' echo
|
|
219
|
+
' echo Chua co profile dieu khien - se tao MOI va ban dang nhap 1 lan trong cua so vua mo.',
|
|
220
|
+
' echo Muon dung san dang nhap cua Chrome thuong thi dat OPENCLAW_CHROME_SEED_PROFILE=1 roi chay lai.',
|
|
221
|
+
' echo Luu y: viec do CHEP cookie, dang nhap, lich su, extension sang profile dieu khien',
|
|
222
|
+
' echo va phai DONG het cua so Chrome dang mo de chep.',
|
|
223
|
+
')',
|
|
224
|
+
'',
|
|
225
|
+
'if not exist "%OPENCLAW_CHROME_PROFILE_DIR%\\Default" if "%OPENCLAW_CHROME_SEED_PROFILE%"=="1" (',
|
|
226
|
+
' echo Dang dong Chrome de chep profile theo yeu cau cua ban...',
|
|
206
227
|
' taskkill /F /IM chrome.exe >nul 2>&1',
|
|
207
228
|
' ping -n 4 127.0.0.1 >nul',
|
|
208
229
|
' echo Dang chep profile Chrome that sang "%OPENCLAW_CHROME_PROFILE_DIR%" ...',
|
|
@@ -214,7 +235,9 @@ async function connectPreferredChrome() {
|
|
|
214
235
|
'echo Dang mo Chrome - profile: %OPENCLAW_CHROME_PROFILE_DIR%',
|
|
215
236
|
'start "" "%CHROME_BIN%" ^',
|
|
216
237
|
' --remote-debugging-port=9222 ^',
|
|
217
|
-
|
|
238
|
+
// Loopback only, and no --remote-allow-origins=*: a CDP client written in Node sends no
|
|
239
|
+
// Origin header, so the wildcard bought nothing and only widened who could drive Chrome.
|
|
240
|
+
' --remote-debugging-address=127.0.0.1 ^',
|
|
218
241
|
' --user-data-dir="%OPENCLAW_CHROME_PROFILE_DIR%" ^',
|
|
219
242
|
' --profile-directory=Default ^',
|
|
220
243
|
' --no-first-run ^',
|
|
@@ -254,7 +277,8 @@ async function connectPreferredChrome() {
|
|
|
254
277
|
'fi',
|
|
255
278
|
'',
|
|
256
279
|
'# Chrome 136+ refuses --remote-debugging-port on the default profile directory, so run a',
|
|
257
|
-
'# dedicated one
|
|
280
|
+
'# dedicated one. It starts empty: copying the real profile duplicates cookies and logins,',
|
|
281
|
+
'# so it only happens when the operator asks with OPENCLAW_CHROME_SEED_PROFILE=1.',
|
|
258
282
|
': "${OPENCLAW_CHROME_PROFILE_DIR:=$DEFAULT_DEBUG_PROFILE}"',
|
|
259
283
|
'',
|
|
260
284
|
'echo "Using: $CHROME_BIN"',
|
|
@@ -262,21 +286,28 @@ async function connectPreferredChrome() {
|
|
|
262
286
|
'pkill -f -- "--remote-debugging-port=9222" 2>/dev/null || true',
|
|
263
287
|
'sleep 2',
|
|
264
288
|
'',
|
|
265
|
-
'if [ ! -d "$OPENCLAW_CHROME_PROFILE_DIR/Default" ]
|
|
266
|
-
'
|
|
267
|
-
'
|
|
268
|
-
'
|
|
269
|
-
'
|
|
270
|
-
|
|
271
|
-
'
|
|
272
|
-
'
|
|
289
|
+
'if [ ! -d "$OPENCLAW_CHROME_PROFILE_DIR/Default" ]; then',
|
|
290
|
+
' if [ "$OPENCLAW_CHROME_SEED_PROFILE" = "1" ] && [ -d "$REAL_PROFILE/Default" ]; then',
|
|
291
|
+
' echo "Copying your real Chrome profile into $OPENCLAW_CHROME_PROFILE_DIR as requested"',
|
|
292
|
+
' echo "(cookies, logins, history and extensions are duplicated into that directory)."',
|
|
293
|
+
' mkdir -p "$OPENCLAW_CHROME_PROFILE_DIR/Default"',
|
|
294
|
+
' cp -R "$REAL_PROFILE/Default/." "$OPENCLAW_CHROME_PROFILE_DIR/Default/" 2>/dev/null || true',
|
|
295
|
+
' cp -f "$REAL_PROFILE/Local State" "$OPENCLAW_CHROME_PROFILE_DIR/Local State" 2>/dev/null || true',
|
|
296
|
+
` for junk in ${chromeProfileCacheJunk.map((n) => `"${n}"`).join(' ')}; do`,
|
|
297
|
+
' rm -rf "$OPENCLAW_CHROME_PROFILE_DIR/Default/$junk"',
|
|
298
|
+
' done',
|
|
299
|
+
' else',
|
|
300
|
+
' echo "Starting a clean automation profile - sign in once in the window that opens."',
|
|
301
|
+
' echo "To reuse your existing logins instead: OPENCLAW_CHROME_SEED_PROFILE=1 $0"',
|
|
302
|
+
' echo "(that copies cookies, logins, history and extensions into the automation profile)."',
|
|
303
|
+
' fi',
|
|
273
304
|
'fi',
|
|
274
305
|
'mkdir -p "$OPENCLAW_CHROME_PROFILE_DIR"',
|
|
275
306
|
'',
|
|
276
307
|
'echo "Starting Chrome (profile: $OPENCLAW_CHROME_PROFILE_DIR)..."',
|
|
277
308
|
'"$CHROME_BIN" \\',
|
|
278
309
|
' --remote-debugging-port=9222 \\',
|
|
279
|
-
' --remote-
|
|
310
|
+
' --remote-debugging-address=127.0.0.1 \\',
|
|
280
311
|
' --user-data-dir="$OPENCLAW_CHROME_PROFILE_DIR" \\',
|
|
281
312
|
' --profile-directory=Default \\',
|
|
282
313
|
' --no-first-run \\',
|
|
@@ -561,7 +592,18 @@ function detectOs() {
|
|
|
561
592
|
const platform = process.platform;
|
|
562
593
|
if (platform === 'win32') return 'win';
|
|
563
594
|
if (platform === 'darwin') return 'macos';
|
|
564
|
-
if (platform === 'linux')
|
|
595
|
+
if (platform === 'linux') {
|
|
596
|
+
// WSL always has a Windows desktop behind it, so it counts as a desktop. Otherwise a session
|
|
597
|
+
// with no display server is a headless server, and the distinction is not cosmetic: 'vps' is
|
|
598
|
+
// what opens the gateway bind past loopback (bot-config-gen) — pick 'linux-desktop' on a VPS
|
|
599
|
+
// and the dashboard is only reachable through an SSH tunnel, while the browser tooling is set
|
|
600
|
+
// up as though a local Chrome existed.
|
|
601
|
+
if (os.release().toLowerCase().includes('microsoft')) return 'linux-desktop';
|
|
602
|
+
const sessionType = String(process.env.XDG_SESSION_TYPE || '').toLowerCase();
|
|
603
|
+
const hasDisplay = !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY || process.env.XDG_CURRENT_DESKTOP)
|
|
604
|
+
|| sessionType === 'x11' || sessionType === 'wayland';
|
|
605
|
+
return hasDisplay ? 'linux-desktop' : 'vps';
|
|
606
|
+
}
|
|
565
607
|
return 'linux-desktop';
|
|
566
608
|
}
|
|
567
609
|
|
|
@@ -690,17 +732,58 @@ function run(cmd, args, opts = {}) {
|
|
|
690
732
|
});
|
|
691
733
|
}
|
|
692
734
|
|
|
735
|
+
// Where npm puts global packages, derived from the running node binary so we never pay for
|
|
736
|
+
// `npm root -g` (~100ms) just to find a version string.
|
|
737
|
+
function globalNodeModulesDirs() {
|
|
738
|
+
const dirs = [];
|
|
739
|
+
const nodeDir = dirname(process.execPath);
|
|
740
|
+
dirs.push(join(nodeDir, '..', 'lib', 'node_modules')); // unix prefix layout
|
|
741
|
+
dirs.push(join(nodeDir, 'node_modules')); // windows npm prefix
|
|
742
|
+
if (process.env.APPDATA) dirs.push(join(process.env.APPDATA, 'npm', 'node_modules'));
|
|
743
|
+
dirs.push('/usr/local/lib/node_modules', '/usr/lib/node_modules', '/opt/homebrew/lib/node_modules');
|
|
744
|
+
return dirs;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// `9router --version` boots the whole CLI and takes ~4 SECONDS on a normal machine. /api/system
|
|
748
|
+
// used to pay that on every single call — and the UI calls it after every action, so the whole
|
|
749
|
+
// dashboard felt slow for one version string. The version is right there in package.json.
|
|
750
|
+
function readGlobalPackageVersion(name) {
|
|
751
|
+
for (const dir of globalNodeModulesDirs()) {
|
|
752
|
+
const pkg = join(dir, name, 'package.json');
|
|
753
|
+
try {
|
|
754
|
+
if (!existsSync(pkg)) continue;
|
|
755
|
+
const version = String(JSON.parse(readFileSync(pkg, 'utf8')).version || '').trim();
|
|
756
|
+
if (version) return version;
|
|
757
|
+
} catch {}
|
|
758
|
+
}
|
|
759
|
+
return '';
|
|
760
|
+
}
|
|
761
|
+
|
|
693
762
|
async function getCurrentRuntimeVersions() {
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
node: node.ok ? (node.output.split(/\r?\n/)[0] || '').trim() : process.version,
|
|
763
|
+
const ck = 'hostver:runtimes';
|
|
764
|
+
const cached = probeCacheGet(ck);
|
|
765
|
+
if (cached) return cached;
|
|
766
|
+
|
|
767
|
+
const fromDisk = {
|
|
768
|
+
openclaw: readGlobalPackageVersion('openclaw'),
|
|
769
|
+
nineRouter: readGlobalPackageVersion('9router'),
|
|
770
|
+
node: process.version || '',
|
|
703
771
|
};
|
|
772
|
+
// Only shell out for what disk did not answer — a global install in a prefix we do not know
|
|
773
|
+
// about, mostly. Still cached, so an odd layout costs the slow probe once, not every request.
|
|
774
|
+
const needCli = !fromDisk.openclaw || !fromDisk.nineRouter;
|
|
775
|
+
if (needCli) {
|
|
776
|
+
const [openclaw, nineRouter] = await Promise.all([
|
|
777
|
+
fromDisk.openclaw ? null : commandExists('openclaw', ['--version']),
|
|
778
|
+
fromDisk.nineRouter ? null : commandExists('9router', ['--version']),
|
|
779
|
+
]);
|
|
780
|
+
if (openclaw?.ok) fromDisk.openclaw = (openclaw.output.split(/\r?\n/)[0] || '').trim();
|
|
781
|
+
if (nineRouter?.ok) fromDisk.nineRouter = (nineRouter.output.split(/\r?\n/)[0] || '').trim();
|
|
782
|
+
}
|
|
783
|
+
// Versions only change on an install/update, and those paths already call probeCacheClear().
|
|
784
|
+
// The TTL is just a backstop for a package installed behind this server's back.
|
|
785
|
+
probeCacheSet(ck, fromDisk, 10 * 60 * 1000);
|
|
786
|
+
return fromDisk;
|
|
704
787
|
}
|
|
705
788
|
|
|
706
789
|
// Per-project cache for EXPENSIVE runtime/version probes (docker exec + openclaw CLI). These
|
|
@@ -716,8 +799,35 @@ function probeCacheGet(key) {
|
|
|
716
799
|
}
|
|
717
800
|
function probeCacheSet(key, value, ttlMs = 0) { _probeCache.set(key, { value, exp: ttlMs ? Date.now() + ttlMs : 0 }); }
|
|
718
801
|
function probeCacheClear(prefix = '') {
|
|
719
|
-
if (!prefix) { _probeCache.clear(); return; }
|
|
802
|
+
if (!prefix) { _probeCache.clear(); _probeInflight.clear(); return; }
|
|
720
803
|
for (const k of [..._probeCache.keys()]) if (k.startsWith(prefix)) _probeCache.delete(k);
|
|
804
|
+
for (const k of [..._probeInflight.keys()]) if (k.startsWith(prefix)) _probeInflight.delete(k);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// One probe per key at a time. The dashboard fires several requests at once and the startup
|
|
808
|
+
// prefetch runs alongside them, so without this the same docker/CLI round-trip ran two or three
|
|
809
|
+
// times over and every caller waited for the slowest copy. Serves a warm value immediately and
|
|
810
|
+
// refreshes in the background once it is half-stale, so a click never waits on a probe.
|
|
811
|
+
const _probeInflight = new Map();
|
|
812
|
+
function sharedProbe(key, ttlMs, compute) {
|
|
813
|
+
const cached = probeCacheGet(key);
|
|
814
|
+
if (cached) {
|
|
815
|
+
if (Date.now() - cached.at > ttlMs / 2 && !_probeInflight.has(key)) {
|
|
816
|
+
const bg = compute()
|
|
817
|
+
.then((value) => { probeCacheSet(key, { value, at: Date.now() }, ttlMs); return value; })
|
|
818
|
+
.finally(() => _probeInflight.delete(key));
|
|
819
|
+
_probeInflight.set(key, bg);
|
|
820
|
+
bg.catch(() => {});
|
|
821
|
+
}
|
|
822
|
+
return Promise.resolve(cached.value);
|
|
823
|
+
}
|
|
824
|
+
const existing = _probeInflight.get(key);
|
|
825
|
+
if (existing) return existing;
|
|
826
|
+
const run = compute()
|
|
827
|
+
.then((value) => { probeCacheSet(key, { value, at: Date.now() }, ttlMs); return value; })
|
|
828
|
+
.finally(() => _probeInflight.delete(key));
|
|
829
|
+
_probeInflight.set(key, run);
|
|
830
|
+
return run;
|
|
721
831
|
}
|
|
722
832
|
|
|
723
833
|
async function resolveProjectRuntimeVersions(projectDir, mode = state.mode || 'docker') {
|
|
@@ -815,6 +925,11 @@ function runCapture(cmd, args, opts = {}) {
|
|
|
815
925
|
windowsHide: opts.windowsHide ?? true,
|
|
816
926
|
env: { ...process.env, ...(opts.env || {}) },
|
|
817
927
|
});
|
|
928
|
+
// Some callers need to feed stdin (pbcopy/xclip take the clipboard text that way).
|
|
929
|
+
if (opts.input != null) {
|
|
930
|
+
try { child.stdin.write(String(opts.input)); } catch (_) {}
|
|
931
|
+
try { child.stdin.end(); } catch (_) {}
|
|
932
|
+
}
|
|
818
933
|
let timedOut = false;
|
|
819
934
|
const timer = Number.isFinite(opts.timeout) && opts.timeout > 0
|
|
820
935
|
? setTimeout(() => {
|
|
@@ -1657,6 +1772,25 @@ function portStatus(port) {
|
|
|
1657
1772
|
});
|
|
1658
1773
|
}
|
|
1659
1774
|
|
|
1775
|
+
/**
|
|
1776
|
+
* First port at or after `start` that nothing on this host is listening on.
|
|
1777
|
+
*
|
|
1778
|
+
* The install-time allocator only knows about setup-managed projects, so it cannot see a docker
|
|
1779
|
+
* project from another install, an SSH tunnel forwarding a remote bot's ports, or any other
|
|
1780
|
+
* listener. Docker tolerates that (compose publishes into loopback and fails loudly on a clash);
|
|
1781
|
+
* native binds the host directly, so it has to ask the host.
|
|
1782
|
+
*
|
|
1783
|
+
* `reserveNext` also requires port+1 to be free — that is where the zalo-mod dashboard lands.
|
|
1784
|
+
*/
|
|
1785
|
+
async function findFreeHostPort(start, { reserveNext = false, limit = 100 } = {}) {
|
|
1786
|
+
for (let port = start; port < start + limit; port++) {
|
|
1787
|
+
if ((await portStatus(port)) === 'online') continue;
|
|
1788
|
+
if (reserveNext && (await portStatus(port + 1)) === 'online') continue;
|
|
1789
|
+
return port;
|
|
1790
|
+
}
|
|
1791
|
+
return start;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1660
1794
|
async function buildBotStatus() {
|
|
1661
1795
|
if (state.projectDir) await syncRuntimeState(state.projectDir).catch(() => {});
|
|
1662
1796
|
const [gatewayStatus, routerStatus, bots, runtimeVersions] = await Promise.all([
|
|
@@ -2006,6 +2140,35 @@ async function waitForDockerContainer(name, timeoutMs = 30000) {
|
|
|
2006
2140
|
return false;
|
|
2007
2141
|
}
|
|
2008
2142
|
|
|
2143
|
+
/**
|
|
2144
|
+
* Drop OpenClaw's boxed "Config warnings" banner (and any stray warning line) from CLI output.
|
|
2145
|
+
*
|
|
2146
|
+
* The banner prints on EVERY invocation and quotes the offending config keys verbatim, so a project
|
|
2147
|
+
* whose zalo-connect plugin is missing has `channels.zalo-connect: unknown channel id: zalo-connect`
|
|
2148
|
+
* in the output of *any* command. A readiness check that greps stdout for a channel id therefore
|
|
2149
|
+
* reports "channel loaded" precisely when the plugin is absent — the check inverts itself. Strip the
|
|
2150
|
+
* warnings before matching so only real command output counts.
|
|
2151
|
+
*/
|
|
2152
|
+
function stripCliWarnings(text = '') {
|
|
2153
|
+
const kept = [];
|
|
2154
|
+
let inBanner = false;
|
|
2155
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
2156
|
+
if (/◇\s*Config warnings/.test(line)) { inBanner = true; continue; }
|
|
2157
|
+
// The banner is drawn as a box; its bottom edge is the only line starting with ├ or └.
|
|
2158
|
+
if (inBanner) {
|
|
2159
|
+
if (/^\s*[├└]/.test(line)) inBanner = false;
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
2162
|
+
if (/unknown channel id|plugin not found|stale config|no channel plugin is installed/i.test(line)) continue;
|
|
2163
|
+
kept.push(line);
|
|
2164
|
+
}
|
|
2165
|
+
return kept.join('\n');
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// Both keywords are load-bearing, and callers must not narrow them to the id alone: `channels
|
|
2169
|
+
// status` lists a loaded channel by its DISPLAY NAME ("OpenClaw Zalo Connect default: enabled, …"),
|
|
2170
|
+
// so the hyphenated id shows up only in the stale-config warnings stripCliWarnings now removes.
|
|
2171
|
+
// Match on the id alone and the check can never pass once the plugin is actually installed.
|
|
2009
2172
|
async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 90000, channelKeywords = ['zalo-connect', 'openclaw zalo connect']) {
|
|
2010
2173
|
const started = Date.now();
|
|
2011
2174
|
// Use dynamic port from env: OPENCLAW_GATEWAY_PORT → OPENCLAW_PORT → fallback 18789
|
|
@@ -2020,7 +2183,7 @@ async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 900
|
|
|
2020
2183
|
const status = String(out.stdout || '').trim();
|
|
2021
2184
|
if (status === 'READY') {
|
|
2022
2185
|
const pluginCheck = await runCapture('docker', ['exec', botContainer, 'sh', '-c', 'openclaw channels status 2>&1 || true'], { cwd: projectDir, shell: false });
|
|
2023
|
-
const output = ((pluginCheck.stdout || '') + '
|
|
2186
|
+
const output = stripCliWarnings((pluginCheck.stdout || '') + '\n' + (pluginCheck.stderr || '')).toLowerCase();
|
|
2024
2187
|
if (channelKeywords.some((kw) => output.includes(kw))) {
|
|
2025
2188
|
ready = true;
|
|
2026
2189
|
break;
|
|
@@ -2044,13 +2207,21 @@ async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, chan
|
|
|
2044
2207
|
const started = Date.now();
|
|
2045
2208
|
const meta = readNativeMeta(projectDir) || {};
|
|
2046
2209
|
const port = String(meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
|
|
2210
|
+
const extDir = join(projectDir, '.openclaw', 'extensions', 'zalo-connect');
|
|
2047
2211
|
let ready = false;
|
|
2048
2212
|
let attempts = 0;
|
|
2049
2213
|
while (Date.now() - started < timeoutMs) {
|
|
2050
2214
|
attempts++;
|
|
2215
|
+
// Decisive, free, and immune to the warning-banner trap above: with no plugin folder the channel
|
|
2216
|
+
// cannot possibly be loaded, so return right away and let the caller install it instead of
|
|
2217
|
+
// burning the whole timeout waiting for something that will never appear.
|
|
2218
|
+
if (!existsSync(extDir)) {
|
|
2219
|
+
sendLog('[zalo-connect] Plugin folder .openclaw/extensions/zalo-connect is absent — not waiting.');
|
|
2220
|
+
return false;
|
|
2221
|
+
}
|
|
2051
2222
|
if (await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500)) {
|
|
2052
2223
|
const st = await ocCapture(projectDir, ['channels', 'status']).catch(() => ({ stdout: '', stderr: '' }));
|
|
2053
|
-
const output = ((st.stdout || '') + '
|
|
2224
|
+
const output = stripCliWarnings((st.stdout || '') + '\n' + (st.stderr || '')).toLowerCase();
|
|
2054
2225
|
if (channelKeywords.some((kw) => output.includes(kw))) { ready = true; break; }
|
|
2055
2226
|
if (attempts > 2) sendLog('[zalo-connect] Gateway healthy but Zalo Connect is not loaded yet (' + Math.round((Date.now() - started) / 1000) + 's)...');
|
|
2056
2227
|
} else if (attempts > 2 && attempts % 3 === 0) {
|
|
@@ -2063,6 +2234,8 @@ async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, chan
|
|
|
2063
2234
|
}
|
|
2064
2235
|
|
|
2065
2236
|
async function startZaloLogin(projectDir, agentId = "") {
|
|
2237
|
+
// A fresh login changes what channels status reports; do not serve the cached "not connected".
|
|
2238
|
+
probeCacheClear(`zalohealth:${projectDir || ''}`);
|
|
2066
2239
|
const cfgPath = join(projectDir, ".openclaw", "openclaw.json");
|
|
2067
2240
|
if (!existsSync(cfgPath)) throw httpError(404, "openclaw.json not found");
|
|
2068
2241
|
const cfg = JSON.parse(await fsp.readFile(cfgPath, "utf8"));
|
|
@@ -2098,20 +2271,17 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
|
|
|
2098
2271
|
// No container: the gateway runs as a managed service on the host. Wait for it to
|
|
2099
2272
|
// report the zalo-connect channel; if it never does and the plugin folder is absent,
|
|
2100
2273
|
// install it on the host (into this project's .openclaw/extensions) and reload.
|
|
2101
|
-
const gatewayReady = await waitForNativeGatewayZaloReady(projectDir, 180000
|
|
2274
|
+
const gatewayReady = await waitForNativeGatewayZaloReady(projectDir, 180000);
|
|
2102
2275
|
if (!gatewayReady) {
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
} else {
|
|
2113
|
-
sendLog('[zalo-connect] Cài plugin không thành công — thử lại bằng nút "Đăng nhập Zalo".');
|
|
2114
|
-
}
|
|
2276
|
+
// ensureNativePlugins is the single place that knows what a native project owes itself, and
|
|
2277
|
+
// it skips whatever is already on disk — so this covers learning-memory too, and reconnects
|
|
2278
|
+
// on a healthy project cost nothing.
|
|
2279
|
+
const installed = await ensureNativePlugins(projectDir);
|
|
2280
|
+
if (installed.includes(ZALO_PLUGIN_ID)) {
|
|
2281
|
+
await restartNativeRuntime(projectDir).catch((err) => sendLog(`[native] restart skipped/failed: ${err.message}`));
|
|
2282
|
+
await waitForNativeGatewayZaloReady(projectDir, 180000);
|
|
2283
|
+
} else if (!existsSync(join(projectDir, '.openclaw', 'extensions', 'zalo-connect'))) {
|
|
2284
|
+
sendLog('[zalo-connect] Cài plugin không thành công — thử lại bằng nút "Đăng nhập Zalo".');
|
|
2115
2285
|
}
|
|
2116
2286
|
}
|
|
2117
2287
|
} else {
|
|
@@ -2124,7 +2294,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
|
|
|
2124
2294
|
// backend-aware entrypoint existed).
|
|
2125
2295
|
const containerUp = await waitForDockerContainer(botContainer, 90000);
|
|
2126
2296
|
if (!containerUp) sendLog(`[zalo-connect] ${botContainer} chưa chạy sau 90s — vẫn thử tiếp...`);
|
|
2127
|
-
const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000
|
|
2297
|
+
const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000);
|
|
2128
2298
|
if (!gatewayReady) {
|
|
2129
2299
|
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' }));
|
|
2130
2300
|
if (String(check.stdout || '').trim() === 'MISSING') {
|
|
@@ -2137,7 +2307,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
|
|
|
2137
2307
|
// Gateway must reload to pick the plugin up — safe here: the gateway is past
|
|
2138
2308
|
// its boot (we only reach this branch when it answered the exec above).
|
|
2139
2309
|
await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
|
|
2140
|
-
await waitForGatewayZaloReady(botContainer, projectDir, 180000
|
|
2310
|
+
await waitForGatewayZaloReady(botContainer, projectDir, 180000);
|
|
2141
2311
|
} else {
|
|
2142
2312
|
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.');
|
|
2143
2313
|
}
|
|
@@ -2321,7 +2491,17 @@ function buildZaloHealthSnapshot(cfg = {}, statusJson = null, credentialNames =
|
|
|
2321
2491
|
// ── Zalo health snapshot for the dashboard ──────────────────────────────────────
|
|
2322
2492
|
// Runtime JSON is authoritative and account-aware. Text parsing remains only as a
|
|
2323
2493
|
// compatibility fallback for older OpenClaw builds.
|
|
2324
|
-
|
|
2494
|
+
// `openclaw channels status` is a CLI round-trip (docker exec on a container, or the host
|
|
2495
|
+
// gateway) and costs ~3 seconds. The dashboard asks for this on every page load and after every
|
|
2496
|
+
// action, so it is cached for a few seconds and refreshed in the background: a second visit is
|
|
2497
|
+
// instant, and the number on screen is never more than a few seconds stale. Login/restart paths
|
|
2498
|
+
// clear it (probeCacheClear) so a state change shows up immediately.
|
|
2499
|
+
const ZALO_HEALTH_TTL_MS = 4000;
|
|
2500
|
+
function getZaloHealth(projectDir) {
|
|
2501
|
+
return sharedProbe(`zalohealth:${projectDir || ''}`, ZALO_HEALTH_TTL_MS, () => computeZaloHealth(projectDir));
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
async function computeZaloHealth(projectDir) {
|
|
2325
2505
|
const meta = {
|
|
2326
2506
|
supportedVersion: ZALO_CONNECT_VERSION,
|
|
2327
2507
|
installedVersion: null,
|
|
@@ -2338,19 +2518,24 @@ async function getZaloHealth(projectDir) {
|
|
|
2338
2518
|
if (meta.zaloModVersion) meta.zaloModInstalled = true;
|
|
2339
2519
|
|
|
2340
2520
|
const botContainer = getBotContainerName(projectDir);
|
|
2521
|
+
const native = isNativeProject(projectDir);
|
|
2341
2522
|
if (cfg.channels?.['zalo-connect']?.enabled) {
|
|
2342
2523
|
const manifestHost = join(projectDir, '.openclaw', 'extensions', 'zalo-connect', 'openclaw.plugin.json');
|
|
2343
2524
|
try {
|
|
2344
2525
|
meta.installedVersion = JSON.parse(await fsp.readFile(manifestHost, 'utf8')).version || null;
|
|
2345
2526
|
} catch {
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2527
|
+
// Only Docker projects keep the manifest inside a container. A native project has no
|
|
2528
|
+
// container at all, and this fallback used to shell into one anyway — seconds of waiting on
|
|
2529
|
+
// a `docker exec` that could never succeed, on a request the dashboard makes constantly.
|
|
2530
|
+
if (!native) {
|
|
2531
|
+
try {
|
|
2532
|
+
const r = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', 'cat "${OPENCLAW_HOME:-/home/node/project/.openclaw}/extensions/zalo-connect/openclaw.plugin.json" 2>/dev/null'], { cwd: projectDir, shell: false, timeout: 8000 });
|
|
2533
|
+
meta.installedVersion = JSON.parse(String(r.stdout || '{}')).version || null;
|
|
2534
|
+
} catch {}
|
|
2535
|
+
}
|
|
2350
2536
|
}
|
|
2351
2537
|
}
|
|
2352
2538
|
|
|
2353
|
-
const native = isNativeProject(projectDir);
|
|
2354
2539
|
let containerRunning = false;
|
|
2355
2540
|
let statusJson = null;
|
|
2356
2541
|
let textStatus = '';
|
|
@@ -2360,7 +2545,9 @@ async function getZaloHealth(projectDir) {
|
|
|
2360
2545
|
// /health over loopback, then read channel status + credentials directly on the host.
|
|
2361
2546
|
const nmeta = readNativeMeta(projectDir) || {};
|
|
2362
2547
|
const port = String(nmeta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
|
|
2363
|
-
|
|
2548
|
+
// Loopback: a live gateway answers in milliseconds. The old 2.5s budget was pure waiting
|
|
2549
|
+
// whenever the runtime was down, on a request the dashboard makes on every page load.
|
|
2550
|
+
containerRunning = await probeHttpOk(`http://127.0.0.1:${port}/health`, 900);
|
|
2364
2551
|
if (containerRunning) {
|
|
2365
2552
|
try {
|
|
2366
2553
|
const r = await ocCapture(projectDir, ['channels', 'status', '--json'], { timeout: 20000 });
|
|
@@ -2446,10 +2633,13 @@ function getBotServiceName(projectDir) {
|
|
|
2446
2633
|
// single fixed one, so without this a second native project would take over the first's service.
|
|
2447
2634
|
|
|
2448
2635
|
const NATIVE_MARKER = 'native.json';
|
|
2449
|
-
// Native
|
|
2450
|
-
//
|
|
2451
|
-
|
|
2452
|
-
|
|
2636
|
+
// Native uses the same ports as everything else: openclaw's 18789 and 9router's 20128. It used to
|
|
2637
|
+
// jump a hundred above them unconditionally so it could sit next to a docker project, but that fired
|
|
2638
|
+
// even on a machine with nothing running at all — a fresh VPS still landed on 18889/20228, so every
|
|
2639
|
+
// tunnel command, bookmark and doc pointed at a port the user never chose. findFreeHostPort() now
|
|
2640
|
+
// handles coexistence by asking the host what is actually taken, which the fixed offset never did.
|
|
2641
|
+
const NATIVE_DEFAULT_GATEWAY_PORT = 18789;
|
|
2642
|
+
const NATIVE_DEFAULT_ROUTER_PORT = 20128;
|
|
2453
2643
|
|
|
2454
2644
|
function nativeMarkerPath(projectDir) {
|
|
2455
2645
|
return join(projectDir || state.projectDir || '', '.openclaw', NATIVE_MARKER);
|
|
@@ -2521,6 +2711,47 @@ function ocCapture(projectDir, args, opts = {}) {
|
|
|
2521
2711
|
return runCapture(a.cmd, a.args, { shell: false, ...a.opts, ...opts, env: { ...(a.opts.env || {}), ...(opts.env || {}) } });
|
|
2522
2712
|
}
|
|
2523
2713
|
|
|
2714
|
+
/** Probe the managed gateway's own /health until it answers. */
|
|
2715
|
+
async function waitForNativeGatewayHealthy(projectDir, timeoutMs = 120000) {
|
|
2716
|
+
const meta = readNativeMeta(projectDir) || {};
|
|
2717
|
+
const port = String(meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
|
|
2718
|
+
const started = Date.now();
|
|
2719
|
+
let attempts = 0;
|
|
2720
|
+
while (Date.now() - started < timeoutMs) {
|
|
2721
|
+
if (await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500)) return true;
|
|
2722
|
+
attempts++;
|
|
2723
|
+
if (attempts % 5 === 0) sendLog(`[native] Waiting for gateway on ${port}... (${Math.round((Date.now() - started) / 1000)}s)`);
|
|
2724
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
2725
|
+
}
|
|
2726
|
+
sendLog(`[native] Gateway did not answer /health on ${port} within ${Math.round(timeoutMs / 1000)}s.`);
|
|
2727
|
+
return false;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
/**
|
|
2731
|
+
* The first gateway boot runs OpenClaw's startup migrations under a state-directory lease, and a
|
|
2732
|
+
* second gateway that tries to start meanwhile exits 1 with this message rather than waiting. The
|
|
2733
|
+
* docker path sidesteps it by never poking a booting container (see startZaloConnectLogin); when we
|
|
2734
|
+
* do hit it natively, the message carries the exact instant the lease frees — so wait that out
|
|
2735
|
+
* instead of retrying blind into systemd's StartLimitBurst (5 per 60s, after which the unit is
|
|
2736
|
+
* abandoned for good).
|
|
2737
|
+
*/
|
|
2738
|
+
function migrationLeaseDeadline(text = '') {
|
|
2739
|
+
const m = String(text).match(/migrations are already running[\s\S]*?after\s+(\d{4}-\d{2}-\d{2}T[\d:.]+Z)/i);
|
|
2740
|
+
if (!m) return 0;
|
|
2741
|
+
const t = Date.parse(m[1]);
|
|
2742
|
+
return Number.isFinite(t) ? t : 0;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
/** `openclaw daemon <verb>` for a native project: streams output to the UI log AND returns it. */
|
|
2746
|
+
async function ocDaemon(projectDir, verb, extraArgs = []) {
|
|
2747
|
+
const args = ['daemon', verb, ...extraArgs];
|
|
2748
|
+
sendLog(`$ openclaw ${args.join(' ')}`);
|
|
2749
|
+
const out = await runCapture('openclaw', args, { cwd: projectDir, env: nativeEnv(projectDir), shell: false, timeout: 120000 });
|
|
2750
|
+
const text = `${out.stdout || ''}\n${out.stderr || ''}`;
|
|
2751
|
+
for (const line of text.split(/\r?\n/).map((l) => l.trimEnd()).filter(Boolean)) sendLog(line);
|
|
2752
|
+
return { ...out, text };
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2524
2755
|
/**
|
|
2525
2756
|
* Restart the native gateway service.
|
|
2526
2757
|
*
|
|
@@ -2529,20 +2760,105 @@ function ocCapture(projectDir, args, opts = {}) {
|
|
|
2529
2760
|
* newly installed plugins never load, silently). stop+start is what actually works there, and it
|
|
2530
2761
|
* works everywhere else too, so Windows takes that path and other systems keep `restart` with
|
|
2531
2762
|
* stop+start as a fallback.
|
|
2763
|
+
*
|
|
2764
|
+
* Health is confirmed over /health at the end rather than trusted from the CLI's exit code: the
|
|
2765
|
+
* CLI gives up verifying after ~13s while the generated unit allows 30s to start, so a slow but
|
|
2766
|
+
* perfectly healthy gateway reports "restart failed" — which used to send callers down a pointless
|
|
2767
|
+
* stop+start that raced the migration lease all over again.
|
|
2532
2768
|
*/
|
|
2533
2769
|
async function restartNativeRuntime(projectDir) {
|
|
2534
|
-
const env = nativeEnv(projectDir);
|
|
2535
2770
|
const stopStart = async () => {
|
|
2536
|
-
await
|
|
2537
|
-
|
|
2771
|
+
await ocDaemon(projectDir, 'stop');
|
|
2772
|
+
return ocDaemon(projectDir, 'start');
|
|
2538
2773
|
};
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
await
|
|
2542
|
-
}
|
|
2543
|
-
|
|
2544
|
-
|
|
2774
|
+
let res;
|
|
2775
|
+
if (process.platform === 'win32') {
|
|
2776
|
+
res = await stopStart();
|
|
2777
|
+
} else {
|
|
2778
|
+
res = await ocDaemon(projectDir, 'restart');
|
|
2779
|
+
// A lease collision is a "come back in a moment", not a broken service: stop+start would only
|
|
2780
|
+
// collide again, so fall through to the wait below instead.
|
|
2781
|
+
if (res.code !== 0 && !migrationLeaseDeadline(res.text)) {
|
|
2782
|
+
sendLog(`[native] daemon restart exited ${res.code}; falling back to stop+start`);
|
|
2783
|
+
res = await stopStart();
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
const deadline = migrationLeaseDeadline(res.text);
|
|
2787
|
+
if (deadline) {
|
|
2788
|
+
const waitMs = Math.max(0, Math.min(deadline - Date.now(), 300000)) + 3000;
|
|
2789
|
+
sendLog(`[native] Startup migrations hold the state lease — waiting ${Math.ceil(waitMs / 1000)}s before retrying.`);
|
|
2790
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
2791
|
+
res = await ocDaemon(projectDir, 'restart');
|
|
2792
|
+
if (res.code !== 0) res = await stopStart();
|
|
2545
2793
|
}
|
|
2794
|
+
// systemd keeps restarting a crash-looping unit every RestartSec, so a gateway blocked by a lease
|
|
2795
|
+
// we never saw still comes up on its own — give it room before calling the restart a failure.
|
|
2796
|
+
if (!(await waitForNativeGatewayHealthy(projectDir, 180000))) {
|
|
2797
|
+
throw new Error('gateway did not answer /health after restart');
|
|
2798
|
+
}
|
|
2799
|
+
return true;
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
/**
|
|
2803
|
+
* `openclaw daemon install` has no `--system` flag, so on Linux the gateway becomes a systemd USER
|
|
2804
|
+
* unit — and a user manager without linger is torn down when that user's last session exits. On a
|
|
2805
|
+
* desktop the graphical session holds it open, which is why this never showed up on macOS or a
|
|
2806
|
+
* Linux desktop; on a VPS the bot dies the moment the operator closes SSH and never comes back
|
|
2807
|
+
* after a reboot. Linger is what makes a user unit behave like the `restart: always` container it
|
|
2808
|
+
* replaces. Best-effort: a box without loginctl just keeps the old behaviour, loudly.
|
|
2809
|
+
*/
|
|
2810
|
+
async function ensureSystemdLinger() {
|
|
2811
|
+
if (process.platform !== 'linux') return false;
|
|
2812
|
+
let user = '';
|
|
2813
|
+
try { user = process.env.SUDO_USER || os.userInfo().username; } catch { return false; }
|
|
2814
|
+
if (!user) return false;
|
|
2815
|
+
const cur = await runCapture('loginctl', ['show-user', user, '-p', 'Linger'], { shell: false, timeout: 10000 });
|
|
2816
|
+
if (/Linger=yes/i.test(cur.stdout || '')) return true;
|
|
2817
|
+
const out = await runCapture('loginctl', ['enable-linger', user], { shell: false, timeout: 20000 });
|
|
2818
|
+
if (out.code === 0) {
|
|
2819
|
+
sendLog(`[native] systemd linger enabled for "${user}" — the gateway now survives logout and reboot.`);
|
|
2820
|
+
return true;
|
|
2821
|
+
}
|
|
2822
|
+
sendLog(`[native] WARNING: could not enable systemd linger for "${user}" (${(out.stderr || out.stdout || '').trim() || `exit ${out.code}`}).`);
|
|
2823
|
+
sendLog(`[native] The gateway will stop when this user's last session ends. Fix it with: sudo loginctl enable-linger ${user}`);
|
|
2824
|
+
return false;
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
/**
|
|
2828
|
+
* Native counterpart of the docker entrypoint's `ensure_plugin` (docker-gen.js).
|
|
2829
|
+
*
|
|
2830
|
+
* A container reinstalls its missing plugins on every boot; a native project has no entrypoint, so
|
|
2831
|
+
* nothing ever put zalo-connect or learning-memory on disk. The generated config declares both
|
|
2832
|
+
* anyway (bot-config-gen writes plugins.entries + allow + slots.contextEngine), so without this the
|
|
2833
|
+
* gateway boots with "plugin not found" warnings, `channels.zalo-connect` has no owner — Zalo login
|
|
2834
|
+
* fails with `Unsupported channel "zalo-connect"` — and the bot silently runs with no context
|
|
2835
|
+
* engine at all. Same set and same skip-if-present cheapness as ensure_plugin.
|
|
2836
|
+
*/
|
|
2837
|
+
async function ensureNativePlugins(projectDir, { restart = false } = {}) {
|
|
2838
|
+
if (!isNativeProject(projectDir)) return [];
|
|
2839
|
+
let cfg = {};
|
|
2840
|
+
try { cfg = JSON.parse(await fsp.readFile(join(projectDir, '.openclaw', 'openclaw.json'), 'utf8')); } catch {}
|
|
2841
|
+
// learning-memory backs plugins.slots.contextEngine for every bot; zalo-connect only when a bot
|
|
2842
|
+
// actually declares the channel (mirrors docker-gen's `if (zaloBackend === 'zalo-connect')`).
|
|
2843
|
+
const wanted = new Set(['learning-memory']);
|
|
2844
|
+
if (cfg?.channels?.[ZALO_CHANNEL_ID] || cfg?.plugins?.entries?.[ZALO_PLUGIN_ID]) wanted.add(ZALO_PLUGIN_ID);
|
|
2845
|
+
const installed = [];
|
|
2846
|
+
for (const id of wanted) {
|
|
2847
|
+
const dir = join(projectDir, '.openclaw', 'extensions', id);
|
|
2848
|
+
if (existsSync(dir)) continue;
|
|
2849
|
+
const spec = id === ZALO_PLUGIN_ID ? ZALO_CONNECT_PLUGIN_SPEC : pluginInstallSpec(id);
|
|
2850
|
+
sendLog(`[native] plugin ${id} missing; installing ${spec}`);
|
|
2851
|
+
const out = await ocCapture(projectDir, ['plugins', 'install', spec, '--force', '--acknowledge-clawhub-risk'], { timeout: 300000 });
|
|
2852
|
+
const text = `${out.stdout || ''}\n${out.stderr || ''}`;
|
|
2853
|
+
for (const line of text.split(/\r?\n/).map((l) => l.trimEnd()).filter(Boolean)) sendLog(`[native] ${line}`);
|
|
2854
|
+
if (existsSync(dir) || /installed plugin/i.test(text)) installed.push(id);
|
|
2855
|
+
else sendLog(`[native] WARNING: could not install plugin ${id} — the bot will run without it.`);
|
|
2856
|
+
}
|
|
2857
|
+
if (installed.length && restart) {
|
|
2858
|
+
sendLog(`[native] Restarting gateway to load: ${installed.join(', ')}`);
|
|
2859
|
+
await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart after plugin install: ${e.message}`));
|
|
2860
|
+
}
|
|
2861
|
+
return installed;
|
|
2546
2862
|
}
|
|
2547
2863
|
|
|
2548
2864
|
/** Fire-and-forget background process (9router has no service wrapper of its own). */
|
|
@@ -2646,11 +2962,20 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
|
|
|
2646
2962
|
await new Promise((r) => setTimeout(r, 8000));
|
|
2647
2963
|
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
2648
2964
|
|
|
2965
|
+
// Plugins BEFORE the gateway's first boot — the container entrypoint installs them ahead of the
|
|
2966
|
+
// gateway for the same reason: a gateway that boots with its plugins already on disk loads them
|
|
2967
|
+
// straight away, needs no follow-up restart, and prints no "plugin not found" warnings.
|
|
2968
|
+
await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin bootstrap skipped: ${e.message}`));
|
|
2969
|
+
|
|
2649
2970
|
// Managed service = auto-restart (KeepAlive/Restart=always) and start-at-login, the native
|
|
2650
2971
|
// equivalent of docker's `restart: always`. --force so re-running install updates the port.
|
|
2651
2972
|
const env = nativeEnv(projectDir);
|
|
2973
|
+
await ensureSystemdLinger();
|
|
2652
2974
|
await run('openclaw', ['daemon', 'install', '--force', '--port', String(gwPort)], { cwd: projectDir, env });
|
|
2653
2975
|
await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
|
|
2976
|
+
// Let the first boot finish its state migrations here, while nothing else is competing for the
|
|
2977
|
+
// lease. Every later action (create bot, install plugin) then restarts a settled gateway.
|
|
2978
|
+
await waitForNativeGatewayHealthy(projectDir, 180000);
|
|
2654
2979
|
sendLog(`[native] gateway service "${label}" running on 127.0.0.1:${gwPort}, 9router on 127.0.0.1:${rtPort}`);
|
|
2655
2980
|
return { gatewayPort: gwPort, routerPort: rtPort, label };
|
|
2656
2981
|
}
|
|
@@ -2799,6 +3124,14 @@ async function recreateDockerBot(projectDir) {
|
|
|
2799
3124
|
// Native: there is no image to rebuild — the gateway reads openclaw.json from disk on boot, so
|
|
2800
3125
|
// reloading config after a bot/plugin change is just a service restart. Callers stay unchanged.
|
|
2801
3126
|
if (isNativeProject(projectDir)) {
|
|
3127
|
+
// Never restart a gateway that is still on its first boot: OpenClaw runs startup migrations
|
|
3128
|
+
// under a state lease, a restart mid-migration exits 1, and systemd's start limit can then
|
|
3129
|
+
// abandon the unit. This is the same trap the docker path avoids by waiting for the container
|
|
3130
|
+
// before touching it (see startZaloConnectLogin) — wait for /health first.
|
|
3131
|
+
await waitForNativeGatewayHealthy(projectDir, 180000);
|
|
3132
|
+
// The bot that was just created/edited may have added the Zalo channel or the context engine to
|
|
3133
|
+
// openclaw.json; put those plugins on disk now so this one reload loads them too.
|
|
3134
|
+
await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin ensure skipped: ${e.message}`));
|
|
2802
3135
|
sendLog('[native] Reloading gateway to pick up openclaw.json changes...');
|
|
2803
3136
|
await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart failed: ${e.message}`));
|
|
2804
3137
|
probeCacheClear();
|
|
@@ -3160,13 +3493,58 @@ function whichSync(name) {
|
|
|
3160
3493
|
try {
|
|
3161
3494
|
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
3162
3495
|
const out = execFileSync(finder, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
3163
|
-
const
|
|
3164
|
-
return
|
|
3496
|
+
const hits = String(out).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
3497
|
+
if (process.platform !== 'win32') return hits[0] || '';
|
|
3498
|
+
// `where claude` lists the extensionless npm shim FIRST — a shell script Windows cannot spawn
|
|
3499
|
+
// ("spawn ...\\npm\\claude ENOENT"), which is how an allow-listed CLI ended up unusable for the
|
|
3500
|
+
// bot. Prefer something Windows can actually execute.
|
|
3501
|
+
const rank = (f) => {
|
|
3502
|
+
const ext = extname(f).toLowerCase();
|
|
3503
|
+
const order = ['.exe', '.cmd', '.bat', '.com', '.ps1'];
|
|
3504
|
+
const idx = order.indexOf(ext);
|
|
3505
|
+
return idx === -1 ? order.length : idx;
|
|
3506
|
+
};
|
|
3507
|
+
return [...hits].sort((a, b) => rank(a) - rank(b))[0] || '';
|
|
3165
3508
|
} catch (_) {
|
|
3166
3509
|
return '';
|
|
3167
3510
|
}
|
|
3168
3511
|
}
|
|
3169
3512
|
|
|
3513
|
+
/**
|
|
3514
|
+
* What to actually spawn for an allow-listed command. Windows needs the indirection:
|
|
3515
|
+
* - the path may be the extensionless npm shim (a shell script) — try the real siblings;
|
|
3516
|
+
* - a `.cmd`/`.bat` shim cannot be spawned without a shell on current Node, so read it and run
|
|
3517
|
+
* what it points at (`…\pkg\bin\x.exe`, or node + a cli.js) directly.
|
|
3518
|
+
* Keeping shell:false matters: the bot supplies the arguments, and a shell would let one of them
|
|
3519
|
+
* become a second command.
|
|
3520
|
+
*/
|
|
3521
|
+
function resolveHostExecutable(bin) {
|
|
3522
|
+
if (process.platform !== 'win32') return { file: bin, prefixArgs: [] };
|
|
3523
|
+
let target = bin;
|
|
3524
|
+
if (!extname(target)) {
|
|
3525
|
+
const candidate = ['.exe', '.cmd', '.bat'].map((ext) => target + ext).find((f) => existsSync(f));
|
|
3526
|
+
if (candidate) target = candidate;
|
|
3527
|
+
}
|
|
3528
|
+
const ext = extname(target).toLowerCase();
|
|
3529
|
+
if (ext !== '.cmd' && ext !== '.bat') return { file: target, prefixArgs: [] };
|
|
3530
|
+
try {
|
|
3531
|
+
const shim = readFileSync(target, 'utf8');
|
|
3532
|
+
const dir = dirname(target);
|
|
3533
|
+
const expand = (p) => resolve(dir, p.replace(/%~?dp0%\\?/gi, '').replace(/^\\+/, ''));
|
|
3534
|
+
const exeRef = shim.match(/"([^"\n]*?\.exe)"/i);
|
|
3535
|
+
if (exeRef) {
|
|
3536
|
+
const exe = expand(exeRef[1]);
|
|
3537
|
+
if (existsSync(exe)) return { file: exe, prefixArgs: [] };
|
|
3538
|
+
}
|
|
3539
|
+
const jsRef = shim.match(/"([^"\n]*?\.js)"/i);
|
|
3540
|
+
if (jsRef) {
|
|
3541
|
+
const js = expand(jsRef[1]);
|
|
3542
|
+
if (existsSync(js)) return { file: process.execPath, prefixArgs: [js] };
|
|
3543
|
+
}
|
|
3544
|
+
} catch (_) {}
|
|
3545
|
+
return { file: target, prefixArgs: [] };
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3170
3548
|
/**
|
|
3171
3549
|
* CLI tools the bot may RUN (not just open) via /api/host/exec — output is captured and
|
|
3172
3550
|
* returned. Kept as a name→path allow-list, mirroring detectHostApps: the executable is fixed,
|
|
@@ -3231,6 +3609,13 @@ function grantHostCapabilities(cfg) {
|
|
|
3231
3609
|
added.push(name);
|
|
3232
3610
|
}
|
|
3233
3611
|
}
|
|
3612
|
+
// Desktop actions (/api/host/ui) come with the same grant: screenshot, pointer, keyboard,
|
|
3613
|
+
// clipboard, windows. Built in, so they work on a machine with no Codex and no extra tools —
|
|
3614
|
+
// on Linux they lean on xdotool/scrot, which the endpoint reports if missing.
|
|
3615
|
+
if (cfg.ui !== true) {
|
|
3616
|
+
cfg.ui = true;
|
|
3617
|
+
added.push('desktop actions (screenshot/click/type)');
|
|
3618
|
+
}
|
|
3234
3619
|
return added;
|
|
3235
3620
|
}
|
|
3236
3621
|
|
|
@@ -3502,7 +3887,8 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
|
|
|
3502
3887
|
};
|
|
3503
3888
|
let child;
|
|
3504
3889
|
try {
|
|
3505
|
-
|
|
3890
|
+
const target = resolveHostExecutable(bin);
|
|
3891
|
+
child = spawn(target.file, [...target.prefixArgs, ...args], { shell: false, windowsHide: true });
|
|
3506
3892
|
} catch (e) {
|
|
3507
3893
|
return finish({ ok: false, error: e.message }, 500);
|
|
3508
3894
|
}
|
|
@@ -3522,6 +3908,262 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
|
|
|
3522
3908
|
});
|
|
3523
3909
|
}
|
|
3524
3910
|
|
|
3911
|
+
/**
|
|
3912
|
+
* Desktop actions for the bot: see the screen, move and click, type, read the clipboard, list and
|
|
3913
|
+
* focus windows. The bot runs in a container with no desktop of its own, so the installer — which
|
|
3914
|
+
* already runs on the operator's machine and already opens apps for it — performs them.
|
|
3915
|
+
*
|
|
3916
|
+
* No native modules: the approach follows the dependency-free tools (and Anthropic's own
|
|
3917
|
+
* computer-use reference, which drives xdotool + a screenshot binary):
|
|
3918
|
+
* Windows a version-stamped PowerShell helper (user32 P/Invoke, SendKeys, System.Drawing)
|
|
3919
|
+
* macOS screencapture + osascript/System Events + pbcopy/pbpaste
|
|
3920
|
+
* Linux xdotool + scrot|import|gnome-screenshot|spectacle + xclip|wl-copy
|
|
3921
|
+
* Whatever the OS, the bot sends the same JSON and gets the same shape back, so its instructions
|
|
3922
|
+
* do not fork per platform.
|
|
3923
|
+
*
|
|
3924
|
+
* Windows note: input injection and screen capture need a real desktop session. When the installer
|
|
3925
|
+
* itself was started over SSH there is none, and the capture fails — the error says so instead of
|
|
3926
|
+
* leaking a raw Win32Exception.
|
|
3927
|
+
*/
|
|
3928
|
+
const HOST_UI_ACTIONS = new Set([
|
|
3929
|
+
'screenshot', 'screen_size', 'mouse_move', 'click', 'drag', 'scroll',
|
|
3930
|
+
'type', 'key', 'clipboard_get', 'clipboard_set', 'windows', 'focus',
|
|
3931
|
+
]);
|
|
3932
|
+
|
|
3933
|
+
function hostUiScriptPath(projectDir) {
|
|
3934
|
+
return join(projectDir, '.openclaw', 'host-ui.ps1');
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
async function ensureHostUiScript(projectDir) {
|
|
3938
|
+
const path = hostUiScriptPath(projectDir);
|
|
3939
|
+
const stamp = `# OpenClaw host UI helper — version ${HOST_UI_PS1_VERSION}`;
|
|
3940
|
+
try {
|
|
3941
|
+
if (existsSync(path) && (await fsp.readFile(path, 'utf8')).startsWith(stamp)) return path;
|
|
3942
|
+
} catch (_) {}
|
|
3943
|
+
await fsp.mkdir(dirname(path), { recursive: true }).catch(() => {});
|
|
3944
|
+
await fsp.writeFile(path, HOST_UI_PS1, 'utf8');
|
|
3945
|
+
return path;
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
function firstExistingCommand(names) {
|
|
3949
|
+
for (const name of names) {
|
|
3950
|
+
const bin = whichSync(name);
|
|
3951
|
+
if (bin) return { name, bin };
|
|
3952
|
+
}
|
|
3953
|
+
return null;
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3956
|
+
async function hostUiScreenshotTarget(projectDir) {
|
|
3957
|
+
const dir = join(projectDir, '.openclaw', 'media', 'host-ui');
|
|
3958
|
+
await fsp.mkdir(dir, { recursive: true }).catch(() => {});
|
|
3959
|
+
// Keep the folder from growing forever: the bot takes a lot of these.
|
|
3960
|
+
try {
|
|
3961
|
+
const files = (await fsp.readdir(dir)).filter((f) => f.endsWith('.png')).sort();
|
|
3962
|
+
for (const stale of files.slice(0, Math.max(0, files.length - 20))) {
|
|
3963
|
+
await fsp.rm(join(dir, stale), { force: true }).catch(() => {});
|
|
3964
|
+
}
|
|
3965
|
+
} catch (_) {}
|
|
3966
|
+
const name = `shot-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
|
3967
|
+
return { hostPath: join(dir, name), containerPath: `/home/node/project/.openclaw/media/host-ui/${name}` };
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
async function runHostUiWindows(projectDir, action, body, shot) {
|
|
3971
|
+
const script = await ensureHostUiScript(projectDir);
|
|
3972
|
+
const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Action', action];
|
|
3973
|
+
const push = (flag, value) => { if (value !== undefined && value !== null && value !== '') args.push(flag, String(value)); };
|
|
3974
|
+
push('-X', body.x);
|
|
3975
|
+
push('-Y', body.y);
|
|
3976
|
+
push('-ToX', body.toX);
|
|
3977
|
+
push('-ToY', body.toY);
|
|
3978
|
+
push('-Amount', body.amount);
|
|
3979
|
+
push('-Text', body.text);
|
|
3980
|
+
push('-Button', body.button);
|
|
3981
|
+
push('-Clicks', body.clicks);
|
|
3982
|
+
push('-Title', body.title);
|
|
3983
|
+
if (shot) push('-Path', shot.hostPath);
|
|
3984
|
+
const r = await runCapture('powershell', args, { shell: false, timeout: 30000 });
|
|
3985
|
+
const parsed = parseJsonText(String(r.stdout || '').trim(), null);
|
|
3986
|
+
if (parsed) return parsed;
|
|
3987
|
+
const err = String(r.stderr || r.stdout || '').trim();
|
|
3988
|
+
if (/Win32Exception|CopyFromScreen|handle is invalid/i.test(err)) {
|
|
3989
|
+
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.' };
|
|
3990
|
+
}
|
|
3991
|
+
return { ok: false, error: err.split('\n')[0] || `powershell exited ${r.code}` };
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3994
|
+
async function runHostUiMac(action, body, shot) {
|
|
3995
|
+
const osa = (script) => runCapture('osascript', ['-e', script], { shell: false, timeout: 20000 });
|
|
3996
|
+
const point = () => `{${Number(body.x) || 0}, ${Number(body.y) || 0}}`;
|
|
3997
|
+
switch (action) {
|
|
3998
|
+
case 'screenshot': {
|
|
3999
|
+
const r = await runCapture('screencapture', ['-x', shot.hostPath], { shell: false, timeout: 20000 });
|
|
4000
|
+
return r.code === 0 ? { ok: true, path: shot.hostPath } : { ok: false, error: String(r.stderr || 'screencapture failed').trim() };
|
|
4001
|
+
}
|
|
4002
|
+
case 'screen_size': {
|
|
4003
|
+
const r = await osa('tell application "Finder" to get bounds of window of desktop');
|
|
4004
|
+
const nums = String(r.stdout || '').trim().split(/\s*,\s*/).map(Number);
|
|
4005
|
+
return nums.length === 4 ? { ok: true, width: nums[2], height: nums[3] } : { ok: false, error: 'could not read screen bounds' };
|
|
4006
|
+
}
|
|
4007
|
+
case 'mouse_move':
|
|
4008
|
+
case 'click': {
|
|
4009
|
+
// System Events can click at a point; a plain move has no equivalent, so a move is a click
|
|
4010
|
+
// target set-up only. Accessibility permission is required (System Settings → Privacy).
|
|
4011
|
+
const clicks = Math.max(1, Number(body.clicks) || 1);
|
|
4012
|
+
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 };
|
|
4013
|
+
for (let i = 0; i < clicks; i++) {
|
|
4014
|
+
const r = await osa(`tell application "System Events" to click at ${point()}`);
|
|
4015
|
+
if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() || 'click failed (grant Accessibility permission)' };
|
|
4016
|
+
}
|
|
4017
|
+
return { ok: true, button: 'left', clicks };
|
|
4018
|
+
}
|
|
4019
|
+
case 'type': {
|
|
4020
|
+
const text = String(body.text || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
4021
|
+
const r = await osa(`tell application "System Events" to keystroke "${text}"`);
|
|
4022
|
+
return r.code === 0 ? { ok: true, typed: String(body.text || '').length } : { ok: false, error: String(r.stderr || '').trim() };
|
|
4023
|
+
}
|
|
4024
|
+
case 'key': {
|
|
4025
|
+
const map = { enter: 'return', esc: 'escape', pageup: 'page up', pagedown: 'page down' };
|
|
4026
|
+
for (const combo of String(body.text || '').split(/\s+/).filter(Boolean)) {
|
|
4027
|
+
const parts = combo.toLowerCase().split('+').map((p) => p.trim()).filter(Boolean);
|
|
4028
|
+
const key = map[parts[parts.length - 1]] || parts[parts.length - 1];
|
|
4029
|
+
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);
|
|
4030
|
+
const using = mods.length ? ` using {${mods.join(', ')}}` : '';
|
|
4031
|
+
const named = ['return', 'escape', 'tab', 'space', 'delete', 'up', 'down', 'left', 'right', 'home', 'end', 'page up', 'page down'];
|
|
4032
|
+
const script = named.includes(key)
|
|
4033
|
+
? `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}`
|
|
4034
|
+
: `tell application "System Events" to keystroke "${key}"${using}`;
|
|
4035
|
+
const r = await osa(script);
|
|
4036
|
+
if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() };
|
|
4037
|
+
}
|
|
4038
|
+
return { ok: true, keys: body.text };
|
|
4039
|
+
}
|
|
4040
|
+
case 'scroll': {
|
|
4041
|
+
const amount = Number(body.amount) || 3;
|
|
4042
|
+
const dir = amount < 0 ? 121 : 116; // page down / page up
|
|
4043
|
+
for (let i = 0; i < Math.abs(amount); i++) await osa(`tell application "System Events" to key code ${dir}`);
|
|
4044
|
+
return { ok: true, amount };
|
|
4045
|
+
}
|
|
4046
|
+
case 'clipboard_get': {
|
|
4047
|
+
const r = await runCapture('pbpaste', [], { shell: false, timeout: 10000 });
|
|
4048
|
+
return { ok: true, text: String(r.stdout || '') };
|
|
4049
|
+
}
|
|
4050
|
+
case 'clipboard_set': {
|
|
4051
|
+
const r = await runCapture('sh', ['-c', 'pbcopy'], { shell: false, timeout: 10000, input: String(body.text || '') });
|
|
4052
|
+
return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: 'pbcopy failed' };
|
|
4053
|
+
}
|
|
4054
|
+
case 'windows': {
|
|
4055
|
+
const r = await osa('tell application "System Events" to get name of every process whose background only is false');
|
|
4056
|
+
const list = String(r.stdout || '').trim().split(/\s*,\s*/).filter(Boolean).map((title) => ({ title, process: title }));
|
|
4057
|
+
return { ok: true, windows: list };
|
|
4058
|
+
}
|
|
4059
|
+
case 'focus': {
|
|
4060
|
+
const title = String(body.title || '').replace(/"/g, '');
|
|
4061
|
+
if (!title) return { ok: false, error: 'focus needs a title' };
|
|
4062
|
+
const r = await osa(`tell application "${title}" to activate`);
|
|
4063
|
+
return r.code === 0 ? { ok: true, focused: title } : { ok: false, error: String(r.stderr || '').trim() || `no app named ${title}` };
|
|
4064
|
+
}
|
|
4065
|
+
default:
|
|
4066
|
+
return { ok: false, error: `unsupported on macOS: ${action}` };
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
async function runHostUiLinux(action, body, shot) {
|
|
4071
|
+
const xdo = whichSync('xdotool');
|
|
4072
|
+
const need = (bin, hint) => ({ ok: false, error: `${hint} needs ${bin}; install it (e.g. apt install ${bin})` });
|
|
4073
|
+
switch (action) {
|
|
4074
|
+
case 'screenshot': {
|
|
4075
|
+
const tool = firstExistingCommand(['gnome-screenshot', 'scrot', 'spectacle', 'import']);
|
|
4076
|
+
if (!tool) return need('scrot', 'screenshot');
|
|
4077
|
+
const argv = tool.name === 'gnome-screenshot' ? ['-f', shot.hostPath]
|
|
4078
|
+
: tool.name === 'spectacle' ? ['-b', '-n', '-o', shot.hostPath]
|
|
4079
|
+
: tool.name === 'import' ? ['-window', 'root', shot.hostPath]
|
|
4080
|
+
: [shot.hostPath];
|
|
4081
|
+
const r = await runCapture(tool.bin, argv, { shell: false, timeout: 20000 });
|
|
4082
|
+
return r.code === 0 ? { ok: true, path: shot.hostPath, tool: tool.name } : { ok: false, error: String(r.stderr || 'capture failed').trim() };
|
|
4083
|
+
}
|
|
4084
|
+
case 'screen_size': {
|
|
4085
|
+
if (!xdo) return need('xdotool', 'screen_size');
|
|
4086
|
+
const r = await runCapture(xdo, ['getdisplaygeometry'], { shell: false, timeout: 10000 });
|
|
4087
|
+
const [w, h] = String(r.stdout || '').trim().split(/\s+/).map(Number);
|
|
4088
|
+
return w && h ? { ok: true, width: w, height: h } : { ok: false, error: 'could not read display geometry' };
|
|
4089
|
+
}
|
|
4090
|
+
case 'mouse_move':
|
|
4091
|
+
case 'click':
|
|
4092
|
+
case 'drag':
|
|
4093
|
+
case 'scroll':
|
|
4094
|
+
case 'type':
|
|
4095
|
+
case 'key':
|
|
4096
|
+
case 'windows':
|
|
4097
|
+
case 'focus': {
|
|
4098
|
+
if (!xdo) return need('xdotool', action);
|
|
4099
|
+
const button = { left: 1, middle: 2, right: 3 }[String(body.button || 'left')] || 1;
|
|
4100
|
+
const argvFor = {
|
|
4101
|
+
mouse_move: ['mousemove', String(body.x ?? 0), String(body.y ?? 0)],
|
|
4102
|
+
click: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'click', '--repeat', String(Math.max(1, Number(body.clicks) || 1)), String(button)],
|
|
4103
|
+
drag: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'mousedown', '1', 'mousemove', String(body.toX ?? 0), String(body.toY ?? 0), 'mouseup', '1'],
|
|
4104
|
+
scroll: ['click', '--repeat', String(Math.max(1, Math.abs(Number(body.amount) || 3))), (Number(body.amount) || 3) < 0 ? '5' : '4'],
|
|
4105
|
+
type: ['type', '--delay', '12', '--', String(body.text || '')],
|
|
4106
|
+
key: ['key', ...String(body.text || '').split(/\s+/).filter(Boolean)],
|
|
4107
|
+
windows: ['search', '--onlyvisible', '--name', '.'],
|
|
4108
|
+
focus: ['search', '--onlyvisible', '--name', String(body.title || ''), 'windowactivate'],
|
|
4109
|
+
}[action];
|
|
4110
|
+
const r = await runCapture(xdo, argvFor, { shell: false, timeout: 20000 });
|
|
4111
|
+
if (action === 'windows') {
|
|
4112
|
+
const ids = String(r.stdout || '').trim().split(/\s+/).filter(Boolean).slice(0, 40);
|
|
4113
|
+
const titles = [];
|
|
4114
|
+
for (const id of ids) {
|
|
4115
|
+
const t = await runCapture(xdo, ['getwindowname', id], { shell: false, timeout: 5000 });
|
|
4116
|
+
const title = String(t.stdout || '').trim();
|
|
4117
|
+
if (title) titles.push({ title, id });
|
|
4118
|
+
}
|
|
4119
|
+
return { ok: true, windows: titles };
|
|
4120
|
+
}
|
|
4121
|
+
return r.code === 0 ? { ok: true, action } : { ok: false, error: String(r.stderr || '').trim() || `xdotool exited ${r.code}` };
|
|
4122
|
+
}
|
|
4123
|
+
case 'clipboard_get': {
|
|
4124
|
+
const tool = firstExistingCommand(['wl-paste', 'xclip', 'xsel']);
|
|
4125
|
+
if (!tool) return need('xclip', 'clipboard_get');
|
|
4126
|
+
const argv = tool.name === 'xclip' ? ['-o', '-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-o'] : [];
|
|
4127
|
+
const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000 });
|
|
4128
|
+
return { ok: true, text: String(r.stdout || '') };
|
|
4129
|
+
}
|
|
4130
|
+
case 'clipboard_set': {
|
|
4131
|
+
const tool = firstExistingCommand(['wl-copy', 'xclip', 'xsel']);
|
|
4132
|
+
if (!tool) return need('xclip', 'clipboard_set');
|
|
4133
|
+
const argv = tool.name === 'xclip' ? ['-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-i'] : [];
|
|
4134
|
+
const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000, input: String(body.text || '') });
|
|
4135
|
+
return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: `${tool.name} failed` };
|
|
4136
|
+
}
|
|
4137
|
+
default:
|
|
4138
|
+
return { ok: false, error: `unsupported on Linux: ${action}` };
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
|
|
4142
|
+
async function runHostUi(projectDir, body = {}) {
|
|
4143
|
+
const action = String(body.action || '').trim();
|
|
4144
|
+
if (!HOST_UI_ACTIONS.has(action)) {
|
|
4145
|
+
return { status: 400, payload: { ok: false, error: `unknown action: ${action || '(none)'}`, actions: [...HOST_UI_ACTIONS] } };
|
|
4146
|
+
}
|
|
4147
|
+
const shot = action === 'screenshot' ? await hostUiScreenshotTarget(projectDir) : null;
|
|
4148
|
+
let result;
|
|
4149
|
+
try {
|
|
4150
|
+
if (process.platform === 'win32') result = await runHostUiWindows(projectDir, action, body, shot);
|
|
4151
|
+
else if (process.platform === 'darwin') result = await runHostUiMac(action, body, shot);
|
|
4152
|
+
else result = await runHostUiLinux(action, body, shot);
|
|
4153
|
+
} catch (err) {
|
|
4154
|
+
result = { ok: false, error: err.message };
|
|
4155
|
+
}
|
|
4156
|
+
if (shot && result?.ok) {
|
|
4157
|
+
// The project folder is bind-mounted into the container, so hand back the path the bot can
|
|
4158
|
+
// actually open — otherwise it gets a Windows path it cannot read and reports failure.
|
|
4159
|
+
result.path = shot.hostPath;
|
|
4160
|
+
result.containerPath = shot.containerPath;
|
|
4161
|
+
result.bytes = existsSync(shot.hostPath) ? (await fsp.stat(shot.hostPath)).size : 0;
|
|
4162
|
+
}
|
|
4163
|
+
sendLog(`[host-control] UI "${action}" → ${result?.ok ? 'ok' : `lỗi: ${result?.error || 'unknown'}`}`);
|
|
4164
|
+
return { status: result?.ok ? 200 : 500, payload: result };
|
|
4165
|
+
}
|
|
4166
|
+
|
|
3525
4167
|
async function handleHostControl(req, res, projectDir) {
|
|
3526
4168
|
const cfg = await readHostControlConfig(projectDir);
|
|
3527
4169
|
const url = new URL(req.url, 'http://localhost');
|
|
@@ -3539,6 +4181,16 @@ async function handleHostControl(req, res, projectDir) {
|
|
|
3539
4181
|
if (url.pathname === '/api/host/apps' && req.method === 'GET') {
|
|
3540
4182
|
return json(res, { ok: true, apps: Object.keys(cfg.apps || {}), commands: Object.keys(cfg.commands || {}), platform: process.platform });
|
|
3541
4183
|
}
|
|
4184
|
+
if (url.pathname === '/api/host/ui' && req.method === 'POST') {
|
|
4185
|
+
// Part of PC control, but its own switch: seeing the screen and moving the pointer is a bigger
|
|
4186
|
+
// step than opening an app, so it only answers once the operator has granted capabilities.
|
|
4187
|
+
if (cfg.ui !== true) {
|
|
4188
|
+
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);
|
|
4189
|
+
}
|
|
4190
|
+
const body = await readJson(req).catch(() => ({}));
|
|
4191
|
+
const { status, payload } = await runHostUi(projectDir, body || {});
|
|
4192
|
+
return json(res, payload, status);
|
|
4193
|
+
}
|
|
3542
4194
|
if (url.pathname === '/api/host/exec' && req.method === 'POST') {
|
|
3543
4195
|
const body = await readJson(req).catch(() => ({}));
|
|
3544
4196
|
const name = String(body.command || '').trim().toLowerCase();
|
|
@@ -3606,6 +4258,39 @@ async function writeHostControlAccess(projectDir, cfg) {
|
|
|
3606
4258
|
'',
|
|
3607
4259
|
`Lệnh khả dụng: ${commands.map((c) => `\`${c}\``).join(', ')}. Lệnh mặc định timeout 180s, output tối đa ~200KB/luồng.`,
|
|
3608
4260
|
] : [];
|
|
4261
|
+
// Desktop actions: one endpoint, same JSON on every OS, so the bot does not need per-platform
|
|
4262
|
+
// instructions. Screenshots land in the project folder, which the container already sees.
|
|
4263
|
+
const uiBlock = cfg.ui === true ? [
|
|
4264
|
+
'',
|
|
4265
|
+
'### Thao tác trên màn hình chủ',
|
|
4266
|
+
'',
|
|
4267
|
+
'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** —',
|
|
4268
|
+
'đừng đoán vị trí. Toạ độ tính bằng pixel màn hình, gốc ở góc trên-trái.',
|
|
4269
|
+
'',
|
|
4270
|
+
'```sh',
|
|
4271
|
+
`curl -s -X POST ${base}/api/host/ui -H "x-openclaw-token: ${cfg.token}" \\`,
|
|
4272
|
+
' -H "content-type: application/json" -d \'{"action":"screenshot"}\'',
|
|
4273
|
+
'```',
|
|
4274
|
+
'',
|
|
4275
|
+
'Trả về `containerPath` — **đọc/gửi ảnh bằng đường dẫn đó** (nằm trong project nên bạn thấy được),',
|
|
4276
|
+
'kèm `width`/`height` để biết màn hình bao lớn.',
|
|
4277
|
+
'',
|
|
4278
|
+
'Các action khác (cùng dạng `{"action":...}`):',
|
|
4279
|
+
'',
|
|
4280
|
+
'- `screen_size` — kích thước màn hình',
|
|
4281
|
+
'- `mouse_move` + `x`,`y` — di chuột',
|
|
4282
|
+
'- `click` + `x`,`y`, tuỳ chọn `button` (`left`/`right`/`middle`) và `clicks` (2 = double-click)',
|
|
4283
|
+
'- `drag` + `x`,`y`,`toX`,`toY` — kéo thả',
|
|
4284
|
+
'- `scroll` + `amount` (âm = xuống), tuỳ chọn `x`,`y`',
|
|
4285
|
+
'- `type` + `text` — gõ chữ vào cửa sổ đang focus',
|
|
4286
|
+
'- `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',
|
|
4287
|
+
'- `clipboard_get` / `clipboard_set` + `text` — đọc/ghi clipboard',
|
|
4288
|
+
'- `windows` — liệt kê cửa sổ đang mở; `focus` + `title` — đưa cửa sổ lên trước',
|
|
4289
|
+
'',
|
|
4290
|
+
'Nếu trả về lỗi "no desktop session available" thì installer đang chạy ngoài phiên desktop —',
|
|
4291
|
+
'nói chủ mở lại installer trong máy, đừng thử cách khác.',
|
|
4292
|
+
'Trên Linux, thiếu `xdotool`/`scrot` thì endpoint nói rõ cần cài gì — báo lại cho chủ.',
|
|
4293
|
+
] : [];
|
|
3609
4294
|
// Screen capture / recording — only advertised when the operator granted the matching tool, so
|
|
3610
4295
|
// the bot never tries a binary that is not on this machine's allow-list.
|
|
3611
4296
|
// Windows has no capture binary to allow-list (PowerShell does it inline), so the section shows
|
|
@@ -3712,6 +4397,7 @@ async function writeHostControlAccess(projectDir, cfg) {
|
|
|
3712
4397
|
'',
|
|
3713
4398
|
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`.',
|
|
3714
4399
|
...execBlock,
|
|
4400
|
+
...uiBlock,
|
|
3715
4401
|
// Docker only: a screenshot taken on the host lands on the HOST filesystem, which this
|
|
3716
4402
|
// container cannot read — say so instead of letting the bot hunt for a missing file.
|
|
3717
4403
|
...(hasCapture ? [
|
|
@@ -3817,7 +4503,8 @@ async function ensureChromeRelay() {
|
|
|
3817
4503
|
// Launch real host Chrome in remote-debugging mode (port 9222) so the browser-automation plugin
|
|
3818
4504
|
// can drive the user's actual Chrome (logged-in profile) instead of headless Chromium. The bot
|
|
3819
4505
|
// reaches it via CDP (host.docker.internal:9222 from the container). Detached: keeps running after
|
|
3820
|
-
// this request.
|
|
4506
|
+
// this request. The debug port stays on loopback and no origin wildcard is passed — a Node CDP
|
|
4507
|
+
// client sends no Origin header, so the wildcard only widened who could drive the browser.
|
|
3821
4508
|
// On a headless VPS there is no Chrome to open here — instead we start the bridge relay and hand
|
|
3822
4509
|
// back copy-paste commands so the user runs Chrome on THEIR machine + a reverse SSH tunnel.
|
|
3823
4510
|
// Where Chrome keeps the operator's own profile, per OS. Chrome must not already be running
|
|
@@ -3902,8 +4589,8 @@ async function startChromeDebug() {
|
|
|
3902
4589
|
port: 9222,
|
|
3903
4590
|
// Same dedicated profile directories the local button and the generated scripts use, so
|
|
3904
4591
|
// an operator who has already run one of those keeps the session they signed in with.
|
|
3905
|
-
chromeCmdMac: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --user-data-dir="$HOME/${CHROME_DEBUG_PROFILE_LEAF_MAC}" --profile-directory=Default --remote-
|
|
3906
|
-
chromeCmdWin: `"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\\${CHROME_DEBUG_PROFILE_LEAF_WIN}" --profile-directory=Default --remote-
|
|
4592
|
+
chromeCmdMac: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --user-data-dir="$HOME/${CHROME_DEBUG_PROFILE_LEAF_MAC}" --profile-directory=Default --remote-debugging-address=127.0.0.1`,
|
|
4593
|
+
chromeCmdWin: `"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\\${CHROME_DEBUG_PROFILE_LEAF_WIN}" --profile-directory=Default --remote-debugging-address=127.0.0.1`,
|
|
3907
4594
|
tunnelCmd: `ssh -N -R 9222:127.0.0.1:9222 ${user}@${ip}`,
|
|
3908
4595
|
};
|
|
3909
4596
|
}
|
|
@@ -3922,12 +4609,20 @@ async function startChromeDebug() {
|
|
|
3922
4609
|
// --enable-automation, so navigator.webdriver stays false and there is no banner.
|
|
3923
4610
|
// Set OPENCLAW_CHROME_PROFILE_DIR to point somewhere else (anything but the default profile).
|
|
3924
4611
|
const userDataDir = process.env.OPENCLAW_CHROME_PROFILE_DIR || debugChromeProfileDir();
|
|
3925
|
-
|
|
4612
|
+
// Only when the operator asked for their logins: the copy duplicates cookies and sessions into
|
|
4613
|
+
// a second profile directory, which is not something to do on a button press by default.
|
|
4614
|
+
if (process.env.OPENCLAW_CHROME_SEED_PROFILE === '1') {
|
|
4615
|
+
await seedDebugChromeProfile(defaultChromeProfileDir(), userDataDir, sendLog);
|
|
4616
|
+
} else if (!existsSync(join(userDataDir, 'Default'))) {
|
|
4617
|
+
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 đó).');
|
|
4618
|
+
}
|
|
3926
4619
|
const args = [
|
|
3927
4620
|
`--remote-debugging-port=${port}`,
|
|
4621
|
+
// Loopback only, and no --remote-allow-origins=*: a Node CDP client sends no Origin header,
|
|
4622
|
+
// so the wildcard only widened who could drive this browser.
|
|
4623
|
+
'--remote-debugging-address=127.0.0.1',
|
|
3928
4624
|
`--user-data-dir=${userDataDir}`,
|
|
3929
4625
|
'--profile-directory=Default',
|
|
3930
|
-
'--remote-allow-origins=*',
|
|
3931
4626
|
'--no-first-run',
|
|
3932
4627
|
'--no-default-browser-check',
|
|
3933
4628
|
];
|
|
@@ -4030,13 +4725,18 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
|
|
|
4030
4725
|
state.os = osChoice;
|
|
4031
4726
|
state.startedAt = new Date().toISOString();
|
|
4032
4727
|
try {
|
|
4033
|
-
// Native
|
|
4034
|
-
//
|
|
4728
|
+
// Native binds the host directly, so it needs ports nothing else holds — but only when something
|
|
4729
|
+
// actually holds them. Ask the host rather than assuming: a fresh machine keeps openclaw's and
|
|
4730
|
+
// 9router's real defaults, and a machine that already runs a docker project (or an SSH tunnel to
|
|
4731
|
+
// a remote bot) steps to the next free pair instead.
|
|
4035
4732
|
if (mode === 'native') {
|
|
4036
|
-
|
|
4037
|
-
|
|
4733
|
+
gatewayPort = await findFreeHostPort(gatewayPort, { reserveNext: true });
|
|
4734
|
+
routerPort = await findFreeHostPort(routerPort);
|
|
4038
4735
|
state.gatewayPort = gatewayPort;
|
|
4039
4736
|
state.routerPort = routerPort;
|
|
4737
|
+
state.gatewayUrl = `http://127.0.0.1:${gatewayPort}`;
|
|
4738
|
+
state.routerUrl = `http://127.0.0.1:${routerPort}`;
|
|
4739
|
+
sendLog(`[native] ports: gateway ${gatewayPort}, 9router ${routerPort}`);
|
|
4040
4740
|
}
|
|
4041
4741
|
sendLog('OpenClaw local installer started');
|
|
4042
4742
|
sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
|
|
@@ -4145,6 +4845,9 @@ async function listMarkdownFiles(projectDir, agentId = '') {
|
|
|
4145
4845
|
}
|
|
4146
4846
|
|
|
4147
4847
|
async function saveState(rootProjectDir) {
|
|
4848
|
+
// Selecting, adding or removing a project all end up here, and all of them make the cached
|
|
4849
|
+
// project list wrong — drop it so the next request rebuilds instead of showing the old set.
|
|
4850
|
+
probeCacheClear('projects:');
|
|
4148
4851
|
const file = join(rootProjectDir, STATE_FILE);
|
|
4149
4852
|
await fsp.writeFile(file, JSON.stringify({
|
|
4150
4853
|
projectDir: state.projectDir,
|
|
@@ -4226,6 +4929,9 @@ function isRestrictedSystemDir(dirPath) {
|
|
|
4226
4929
|
// so a fresh `npx github:…` run (e.g. on a VPS where bots are already running) targets the
|
|
4227
4930
|
// live project instead of defaulting to an empty ~/openclaw-setup folder.
|
|
4228
4931
|
async function discoverDockerBotProjectRoots() {
|
|
4932
|
+
const ck = 'dockerroots';
|
|
4933
|
+
const cached = probeCacheGet(ck);
|
|
4934
|
+
if (cached) return cached;
|
|
4229
4935
|
const roots = [];
|
|
4230
4936
|
try {
|
|
4231
4937
|
const r = await runCapture(
|
|
@@ -4244,7 +4950,9 @@ async function discoverDockerBotProjectRoots() {
|
|
|
4244
4950
|
}
|
|
4245
4951
|
}
|
|
4246
4952
|
} catch {}
|
|
4247
|
-
|
|
4953
|
+
const unique = [...new Set(roots)];
|
|
4954
|
+
probeCacheSet(ck, unique, 15000);
|
|
4955
|
+
return unique;
|
|
4248
4956
|
}
|
|
4249
4957
|
|
|
4250
4958
|
// Native installs have no container to inspect, so we can't detect them the way Docker bots are
|
|
@@ -4343,7 +5051,15 @@ async function ensureProjectsLoaded(rootProjectDir) {
|
|
|
4343
5051
|
}
|
|
4344
5052
|
}
|
|
4345
5053
|
|
|
4346
|
-
|
|
5054
|
+
// The project list costs docker/native probes per project. It changes when someone creates or
|
|
5055
|
+
// deletes a project — not between two page loads — so serve it from a short cache and refresh in
|
|
5056
|
+
// the background: the dashboard opens instantly and is at most a few seconds stale.
|
|
5057
|
+
const PROJECTS_TTL_MS = 10000;
|
|
5058
|
+
function discoverProjects(rootProjectDir) {
|
|
5059
|
+
return sharedProbe(`projects:${rootProjectDir || ''}`, PROJECTS_TTL_MS, () => computeDiscoverProjects(rootProjectDir));
|
|
5060
|
+
}
|
|
5061
|
+
|
|
5062
|
+
async function computeDiscoverProjects(rootProjectDir) {
|
|
4347
5063
|
await ensureProjectsLoaded(rootProjectDir);
|
|
4348
5064
|
|
|
4349
5065
|
// Surface projects whose bot is running in Docker even if this install has no saved
|
|
@@ -4371,16 +5087,14 @@ async function discoverProjects(rootProjectDir) {
|
|
|
4371
5087
|
}
|
|
4372
5088
|
}
|
|
4373
5089
|
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
}
|
|
4383
|
-
state.projects = updatedProjects;
|
|
5090
|
+
// In parallel: each buildProjectMeta runs runtime detection (docker calls, port probes), so a
|
|
5091
|
+
// handful of projects turned into seconds of dashboard load when this was a sequential loop.
|
|
5092
|
+
const metas = await Promise.all(
|
|
5093
|
+
state.projects
|
|
5094
|
+
.filter((p) => existsSync(join(p.projectDir, '.openclaw', 'openclaw.json')))
|
|
5095
|
+
.map((p) => buildProjectMeta(p.projectDir).catch(() => null)),
|
|
5096
|
+
);
|
|
5097
|
+
state.projects = metas.filter(Boolean);
|
|
4384
5098
|
|
|
4385
5099
|
state.projects.sort((a, b) => {
|
|
4386
5100
|
const aActive = state.projectDir && resolve(state.projectDir) === resolve(a.projectDir);
|
|
@@ -4969,6 +5683,11 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
4969
5683
|
if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
|
|
4970
5684
|
cfg.plugins.entries[existingKey].config = Object.assign({}, cfg.plugins.entries[existingKey].config, {
|
|
4971
5685
|
hostOs: await resolveProjectHostOs(projectDir),
|
|
5686
|
+
// The plugin ships these off: editing the Docker build files, running page JavaScript
|
|
5687
|
+
// and uploading local files are things it will not do until an operator says so.
|
|
5688
|
+
// Installing it from this dashboard IS that operator saying so — otherwise browsing
|
|
5689
|
+
// would need a hand-edited config right after a one-click install.
|
|
5690
|
+
...browserAutomationOptIns(),
|
|
4972
5691
|
});
|
|
4973
5692
|
cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
|
|
4974
5693
|
cfg.tools.alsoAllow = Array.from(new Set([...(cfg.tools.alsoAllow || []), 'group:web']));
|
|
@@ -5068,6 +5787,11 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
5068
5787
|
if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
|
|
5069
5788
|
cfg.plugins.entries[existingKey].config = Object.assign({}, cfg.plugins.entries[existingKey].config, {
|
|
5070
5789
|
hostOs: await resolveProjectHostOs(projectDir),
|
|
5790
|
+
// The plugin ships these off: editing the Docker build files, running page JavaScript
|
|
5791
|
+
// and uploading local files are things it will not do until an operator says so.
|
|
5792
|
+
// Installing it from this dashboard IS that operator saying so — otherwise browsing
|
|
5793
|
+
// would need a hand-edited config right after a one-click install.
|
|
5794
|
+
...browserAutomationOptIns(),
|
|
5071
5795
|
});
|
|
5072
5796
|
cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
|
|
5073
5797
|
cfg.tools.alsoAllow = Array.from(new Set([...(cfg.tools.alsoAllow || []), 'group:web']));
|
|
@@ -5923,6 +6647,15 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
5923
6647
|
// Bring the host-control service back up when the operator left it enabled, so the bot's
|
|
5924
6648
|
// saved instructions keep working across installer restarts.
|
|
5925
6649
|
ensureHostControl(projectDir).catch(() => {});
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
|
|
6650
|
+
// Warm the probes the first page load would otherwise wait on (project list, runtime versions,
|
|
6651
|
+
// public IP, Zalo status). They run while the browser is still starting, so the dashboard opens
|
|
6652
|
+
// against a warm cache instead of paying for docker and CLI round-trips on first paint.
|
|
6653
|
+
Promise.all([
|
|
6654
|
+
discoverProjects(projectDir).catch(() => {}),
|
|
6655
|
+
getCurrentRuntimeVersions().catch(() => {}),
|
|
6656
|
+
getPublicIp().catch(() => {}),
|
|
6657
|
+
existsSync(join(projectDir, '.openclaw', 'openclaw.json')) ? getZaloHealth(projectDir).catch(() => {}) : null,
|
|
6658
|
+
]).catch(() => {});
|
|
6659
|
+
}
|
|
6660
|
+
|
|
6661
|
+
export { patchBrowserAutomationHostPreference, debugChromeProfileDir, defaultChromeProfileDir, createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations, runHostCommand, detectHostCommands, detectHostCapabilityCommands, grantHostCapabilities, detectCodexApp, detectCodexMarketplace, resolveCodexCli, openPrivacyPane, projectDeployMode, isNativeProject, nativeServiceLabel, nativeEnv, ocArgv, migrateNativePaths, discoverNativeProjectRoots, detectOs, stripCliWarnings, migrationLeaseDeadline, ensureNativePlugins, findFreeHostPort };
|