create-openclaw-bot 5.15.2 → 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 +613 -39
- 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
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*Run one command → open the dashboard → your bot is live. Windows · macOS · Linux · VPS — Docker-powered, auto-installed for you.*
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
-
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.15.
|
|
10
|
+
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.15.4-0EA5E9?style=for-the-badge" alt="Version 5.15.4" /></a>
|
|
11
11
|
<a href="https://github.com/tuanminhhole/openclaw-setup?tab=MIT-1-ov-file"><img src="https://img.shields.io/badge/LICENSE-MIT-success?style=for-the-badge" alt="MIT License" /></a>
|
|
12
12
|
<a href="https://www.npmjs.com/package/create-openclaw-bot"><img src="https://img.shields.io/npm/v/create-openclaw-bot?style=for-the-badge&label=CLI&color=2563EB&logo=npm&logoColor=white" alt="NPM Version" /></a>
|
|
13
13
|
<a href="https://github.com/tuanminhhole/openclaw-setup/stargazers"><img src="https://img.shields.io/github/stars/tuanminhhole/openclaw-setup?style=for-the-badge&color=eab308&logo=github&logoColor=white" alt="GitHub Stars" /></a>
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
|
40
|
-
## 🆕 What's New in v5.15.
|
|
40
|
+
## 🆕 What's New in v5.15.4
|
|
41
41
|
|
|
42
42
|
- 🖥️ **"Control PC" — let the bot open Chrome & apps on your machine**: a new toggle on each bot card turns on the ability for the bot to open Chrome or an allow-listed app (TeamViewer, Zalo, …) on the computer running it. **Off by default**, token-gated, allow-list only, desktop-only.
|
|
43
43
|
- 🌐 **Browsing works out of the box on every OS**: the bot drives your real Chrome on a desktop (your logged-in profile, so sites don't flag it) and starts its own headless Chromium on a server — no more "no browser available" just because Chrome wasn't running.
|
package/README.vi.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*Chạy một lệnh → mở dashboard → bot lên sóng. Windows · macOS · Linux · VPS — chạy trên Docker, tự cài giúp bạn.*
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
-
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.15.
|
|
10
|
+
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.15.4-0EA5E9?style=for-the-badge" alt="Version 5.15.4" /></a>
|
|
11
11
|
<a href="https://github.com/tuanminhhole/openclaw-setup?tab=MIT-1-ov-file"><img src="https://img.shields.io/badge/LICENSE-MIT-success?style=for-the-badge" alt="MIT License" /></a>
|
|
12
12
|
<a href="https://www.npmjs.com/package/create-openclaw-bot"><img src="https://img.shields.io/npm/v/create-openclaw-bot?style=for-the-badge&label=CLI&color=2563EB&logo=npm&logoColor=white" alt="NPM Version" /></a>
|
|
13
13
|
<a href="https://github.com/tuanminhhole/openclaw-setup/stargazers"><img src="https://img.shields.io/github/stars/tuanminhhole/openclaw-setup?style=for-the-badge&color=eab308&logo=github&logoColor=white" alt="GitHub Stars" /></a>
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
|
40
|
-
## 🆕 Có gì mới trong v5.15.
|
|
40
|
+
## 🆕 Có gì mới trong v5.15.4
|
|
41
41
|
|
|
42
42
|
- 🖥️ **"Điều khiển máy" — cho bot mở Chrome & ứng dụng trên máy bạn**: nút mới trên thẻ mỗi bot, bật lên là bot mở được Chrome hoặc app trong danh sách cho phép (TeamViewer, Zalo, …) trên máy đang chạy bot. **Mặc định TẮT**, cần token, chỉ giới hạn danh sách bạn khai, chỉ dùng trên máy có màn hình.
|
|
43
43
|
- 🌐 **Duyệt web chạy ngay trên mọi hệ điều hành**: máy để bàn thì bot dùng **Chrome thật** (profile đã đăng nhập, web ít nghi là bot), server thì tự mở Chromium headless — hết cảnh "không có trình duyệt" chỉ vì chưa mở Chrome.
|
|
@@ -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
|
|
|
@@ -591,7 +592,18 @@ function detectOs() {
|
|
|
591
592
|
const platform = process.platform;
|
|
592
593
|
if (platform === 'win32') return 'win';
|
|
593
594
|
if (platform === 'darwin') return 'macos';
|
|
594
|
-
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
|
+
}
|
|
595
607
|
return 'linux-desktop';
|
|
596
608
|
}
|
|
597
609
|
|
|
@@ -913,6 +925,11 @@ function runCapture(cmd, args, opts = {}) {
|
|
|
913
925
|
windowsHide: opts.windowsHide ?? true,
|
|
914
926
|
env: { ...process.env, ...(opts.env || {}) },
|
|
915
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
|
+
}
|
|
916
933
|
let timedOut = false;
|
|
917
934
|
const timer = Number.isFinite(opts.timeout) && opts.timeout > 0
|
|
918
935
|
? setTimeout(() => {
|
|
@@ -1755,6 +1772,25 @@ function portStatus(port) {
|
|
|
1755
1772
|
});
|
|
1756
1773
|
}
|
|
1757
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
|
+
|
|
1758
1794
|
async function buildBotStatus() {
|
|
1759
1795
|
if (state.projectDir) await syncRuntimeState(state.projectDir).catch(() => {});
|
|
1760
1796
|
const [gatewayStatus, routerStatus, bots, runtimeVersions] = await Promise.all([
|
|
@@ -2104,6 +2140,35 @@ async function waitForDockerContainer(name, timeoutMs = 30000) {
|
|
|
2104
2140
|
return false;
|
|
2105
2141
|
}
|
|
2106
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.
|
|
2107
2172
|
async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 90000, channelKeywords = ['zalo-connect', 'openclaw zalo connect']) {
|
|
2108
2173
|
const started = Date.now();
|
|
2109
2174
|
// Use dynamic port from env: OPENCLAW_GATEWAY_PORT → OPENCLAW_PORT → fallback 18789
|
|
@@ -2118,7 +2183,7 @@ async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 900
|
|
|
2118
2183
|
const status = String(out.stdout || '').trim();
|
|
2119
2184
|
if (status === 'READY') {
|
|
2120
2185
|
const pluginCheck = await runCapture('docker', ['exec', botContainer, 'sh', '-c', 'openclaw channels status 2>&1 || true'], { cwd: projectDir, shell: false });
|
|
2121
|
-
const output = ((pluginCheck.stdout || '') + '
|
|
2186
|
+
const output = stripCliWarnings((pluginCheck.stdout || '') + '\n' + (pluginCheck.stderr || '')).toLowerCase();
|
|
2122
2187
|
if (channelKeywords.some((kw) => output.includes(kw))) {
|
|
2123
2188
|
ready = true;
|
|
2124
2189
|
break;
|
|
@@ -2142,13 +2207,21 @@ async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, chan
|
|
|
2142
2207
|
const started = Date.now();
|
|
2143
2208
|
const meta = readNativeMeta(projectDir) || {};
|
|
2144
2209
|
const port = String(meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
|
|
2210
|
+
const extDir = join(projectDir, '.openclaw', 'extensions', 'zalo-connect');
|
|
2145
2211
|
let ready = false;
|
|
2146
2212
|
let attempts = 0;
|
|
2147
2213
|
while (Date.now() - started < timeoutMs) {
|
|
2148
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
|
+
}
|
|
2149
2222
|
if (await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500)) {
|
|
2150
2223
|
const st = await ocCapture(projectDir, ['channels', 'status']).catch(() => ({ stdout: '', stderr: '' }));
|
|
2151
|
-
const output = ((st.stdout || '') + '
|
|
2224
|
+
const output = stripCliWarnings((st.stdout || '') + '\n' + (st.stderr || '')).toLowerCase();
|
|
2152
2225
|
if (channelKeywords.some((kw) => output.includes(kw))) { ready = true; break; }
|
|
2153
2226
|
if (attempts > 2) sendLog('[zalo-connect] Gateway healthy but Zalo Connect is not loaded yet (' + Math.round((Date.now() - started) / 1000) + 's)...');
|
|
2154
2227
|
} else if (attempts > 2 && attempts % 3 === 0) {
|
|
@@ -2198,20 +2271,17 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
|
|
|
2198
2271
|
// No container: the gateway runs as a managed service on the host. Wait for it to
|
|
2199
2272
|
// report the zalo-connect channel; if it never does and the plugin folder is absent,
|
|
2200
2273
|
// install it on the host (into this project's .openclaw/extensions) and reload.
|
|
2201
|
-
const gatewayReady = await waitForNativeGatewayZaloReady(projectDir, 180000
|
|
2274
|
+
const gatewayReady = await waitForNativeGatewayZaloReady(projectDir, 180000);
|
|
2202
2275
|
if (!gatewayReady) {
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
} else {
|
|
2213
|
-
sendLog('[zalo-connect] Cài plugin không thành công — thử lại bằng nút "Đăng nhập Zalo".');
|
|
2214
|
-
}
|
|
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".');
|
|
2215
2285
|
}
|
|
2216
2286
|
}
|
|
2217
2287
|
} else {
|
|
@@ -2224,7 +2294,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
|
|
|
2224
2294
|
// backend-aware entrypoint existed).
|
|
2225
2295
|
const containerUp = await waitForDockerContainer(botContainer, 90000);
|
|
2226
2296
|
if (!containerUp) sendLog(`[zalo-connect] ${botContainer} chưa chạy sau 90s — vẫn thử tiếp...`);
|
|
2227
|
-
const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000
|
|
2297
|
+
const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000);
|
|
2228
2298
|
if (!gatewayReady) {
|
|
2229
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' }));
|
|
2230
2300
|
if (String(check.stdout || '').trim() === 'MISSING') {
|
|
@@ -2237,7 +2307,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
|
|
|
2237
2307
|
// Gateway must reload to pick the plugin up — safe here: the gateway is past
|
|
2238
2308
|
// its boot (we only reach this branch when it answered the exec above).
|
|
2239
2309
|
await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
|
|
2240
|
-
await waitForGatewayZaloReady(botContainer, projectDir, 180000
|
|
2310
|
+
await waitForGatewayZaloReady(botContainer, projectDir, 180000);
|
|
2241
2311
|
} else {
|
|
2242
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.');
|
|
2243
2313
|
}
|
|
@@ -2563,10 +2633,13 @@ function getBotServiceName(projectDir) {
|
|
|
2563
2633
|
// single fixed one, so without this a second native project would take over the first's service.
|
|
2564
2634
|
|
|
2565
2635
|
const NATIVE_MARKER = 'native.json';
|
|
2566
|
-
// Native
|
|
2567
|
-
//
|
|
2568
|
-
|
|
2569
|
-
|
|
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;
|
|
2570
2643
|
|
|
2571
2644
|
function nativeMarkerPath(projectDir) {
|
|
2572
2645
|
return join(projectDir || state.projectDir || '', '.openclaw', NATIVE_MARKER);
|
|
@@ -2638,6 +2711,47 @@ function ocCapture(projectDir, args, opts = {}) {
|
|
|
2638
2711
|
return runCapture(a.cmd, a.args, { shell: false, ...a.opts, ...opts, env: { ...(a.opts.env || {}), ...(opts.env || {}) } });
|
|
2639
2712
|
}
|
|
2640
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
|
+
|
|
2641
2755
|
/**
|
|
2642
2756
|
* Restart the native gateway service.
|
|
2643
2757
|
*
|
|
@@ -2646,20 +2760,105 @@ function ocCapture(projectDir, args, opts = {}) {
|
|
|
2646
2760
|
* newly installed plugins never load, silently). stop+start is what actually works there, and it
|
|
2647
2761
|
* works everywhere else too, so Windows takes that path and other systems keep `restart` with
|
|
2648
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.
|
|
2649
2768
|
*/
|
|
2650
2769
|
async function restartNativeRuntime(projectDir) {
|
|
2651
|
-
const env = nativeEnv(projectDir);
|
|
2652
2770
|
const stopStart = async () => {
|
|
2653
|
-
await
|
|
2654
|
-
|
|
2771
|
+
await ocDaemon(projectDir, 'stop');
|
|
2772
|
+
return ocDaemon(projectDir, 'start');
|
|
2655
2773
|
};
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
await
|
|
2659
|
-
}
|
|
2660
|
-
|
|
2661
|
-
|
|
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();
|
|
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;
|
|
2662
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;
|
|
2663
2862
|
}
|
|
2664
2863
|
|
|
2665
2864
|
/** Fire-and-forget background process (9router has no service wrapper of its own). */
|
|
@@ -2763,11 +2962,20 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
|
|
|
2763
2962
|
await new Promise((r) => setTimeout(r, 8000));
|
|
2764
2963
|
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
2765
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
|
+
|
|
2766
2970
|
// Managed service = auto-restart (KeepAlive/Restart=always) and start-at-login, the native
|
|
2767
2971
|
// equivalent of docker's `restart: always`. --force so re-running install updates the port.
|
|
2768
2972
|
const env = nativeEnv(projectDir);
|
|
2973
|
+
await ensureSystemdLinger();
|
|
2769
2974
|
await run('openclaw', ['daemon', 'install', '--force', '--port', String(gwPort)], { cwd: projectDir, env });
|
|
2770
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);
|
|
2771
2979
|
sendLog(`[native] gateway service "${label}" running on 127.0.0.1:${gwPort}, 9router on 127.0.0.1:${rtPort}`);
|
|
2772
2980
|
return { gatewayPort: gwPort, routerPort: rtPort, label };
|
|
2773
2981
|
}
|
|
@@ -2916,6 +3124,14 @@ async function recreateDockerBot(projectDir) {
|
|
|
2916
3124
|
// Native: there is no image to rebuild — the gateway reads openclaw.json from disk on boot, so
|
|
2917
3125
|
// reloading config after a bot/plugin change is just a service restart. Callers stay unchanged.
|
|
2918
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}`));
|
|
2919
3135
|
sendLog('[native] Reloading gateway to pick up openclaw.json changes...');
|
|
2920
3136
|
await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart failed: ${e.message}`));
|
|
2921
3137
|
probeCacheClear();
|
|
@@ -3277,13 +3493,58 @@ function whichSync(name) {
|
|
|
3277
3493
|
try {
|
|
3278
3494
|
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
3279
3495
|
const out = execFileSync(finder, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
3280
|
-
const
|
|
3281
|
-
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] || '';
|
|
3282
3508
|
} catch (_) {
|
|
3283
3509
|
return '';
|
|
3284
3510
|
}
|
|
3285
3511
|
}
|
|
3286
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
|
+
|
|
3287
3548
|
/**
|
|
3288
3549
|
* CLI tools the bot may RUN (not just open) via /api/host/exec — output is captured and
|
|
3289
3550
|
* returned. Kept as a name→path allow-list, mirroring detectHostApps: the executable is fixed,
|
|
@@ -3348,6 +3609,13 @@ function grantHostCapabilities(cfg) {
|
|
|
3348
3609
|
added.push(name);
|
|
3349
3610
|
}
|
|
3350
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
|
+
}
|
|
3351
3619
|
return added;
|
|
3352
3620
|
}
|
|
3353
3621
|
|
|
@@ -3619,7 +3887,8 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
|
|
|
3619
3887
|
};
|
|
3620
3888
|
let child;
|
|
3621
3889
|
try {
|
|
3622
|
-
|
|
3890
|
+
const target = resolveHostExecutable(bin);
|
|
3891
|
+
child = spawn(target.file, [...target.prefixArgs, ...args], { shell: false, windowsHide: true });
|
|
3623
3892
|
} catch (e) {
|
|
3624
3893
|
return finish({ ok: false, error: e.message }, 500);
|
|
3625
3894
|
}
|
|
@@ -3639,6 +3908,262 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
|
|
|
3639
3908
|
});
|
|
3640
3909
|
}
|
|
3641
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
|
+
|
|
3642
4167
|
async function handleHostControl(req, res, projectDir) {
|
|
3643
4168
|
const cfg = await readHostControlConfig(projectDir);
|
|
3644
4169
|
const url = new URL(req.url, 'http://localhost');
|
|
@@ -3656,6 +4181,16 @@ async function handleHostControl(req, res, projectDir) {
|
|
|
3656
4181
|
if (url.pathname === '/api/host/apps' && req.method === 'GET') {
|
|
3657
4182
|
return json(res, { ok: true, apps: Object.keys(cfg.apps || {}), commands: Object.keys(cfg.commands || {}), platform: process.platform });
|
|
3658
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
|
+
}
|
|
3659
4194
|
if (url.pathname === '/api/host/exec' && req.method === 'POST') {
|
|
3660
4195
|
const body = await readJson(req).catch(() => ({}));
|
|
3661
4196
|
const name = String(body.command || '').trim().toLowerCase();
|
|
@@ -3723,6 +4258,39 @@ async function writeHostControlAccess(projectDir, cfg) {
|
|
|
3723
4258
|
'',
|
|
3724
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.`,
|
|
3725
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
|
+
] : [];
|
|
3726
4294
|
// Screen capture / recording — only advertised when the operator granted the matching tool, so
|
|
3727
4295
|
// the bot never tries a binary that is not on this machine's allow-list.
|
|
3728
4296
|
// Windows has no capture binary to allow-list (PowerShell does it inline), so the section shows
|
|
@@ -3829,6 +4397,7 @@ async function writeHostControlAccess(projectDir, cfg) {
|
|
|
3829
4397
|
'',
|
|
3830
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`.',
|
|
3831
4399
|
...execBlock,
|
|
4400
|
+
...uiBlock,
|
|
3832
4401
|
// Docker only: a screenshot taken on the host lands on the HOST filesystem, which this
|
|
3833
4402
|
// container cannot read — say so instead of letting the bot hunt for a missing file.
|
|
3834
4403
|
...(hasCapture ? [
|
|
@@ -4156,13 +4725,18 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
|
|
|
4156
4725
|
state.os = osChoice;
|
|
4157
4726
|
state.startedAt = new Date().toISOString();
|
|
4158
4727
|
try {
|
|
4159
|
-
// Native
|
|
4160
|
-
//
|
|
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.
|
|
4161
4732
|
if (mode === 'native') {
|
|
4162
|
-
|
|
4163
|
-
|
|
4733
|
+
gatewayPort = await findFreeHostPort(gatewayPort, { reserveNext: true });
|
|
4734
|
+
routerPort = await findFreeHostPort(routerPort);
|
|
4164
4735
|
state.gatewayPort = gatewayPort;
|
|
4165
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}`);
|
|
4166
4740
|
}
|
|
4167
4741
|
sendLog('OpenClaw local installer started');
|
|
4168
4742
|
sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
|
|
@@ -6084,4 +6658,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
6084
6658
|
]).catch(() => {});
|
|
6085
6659
|
}
|
|
6086
6660
|
|
|
6087
|
-
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 };
|
|
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 };
|
|
@@ -277,11 +277,19 @@
|
|
|
277
277
|
}
|
|
278
278
|
|
|
279
279
|
// ── gateway ──────────────────────────────────────────────────────────────
|
|
280
|
+
// Docker MUST bind 0.0.0.0 inside the container to be reachable at all, but that is contained:
|
|
281
|
+
// compose publishes the port as `127.0.0.1:<port>:<port>` (docker-gen), so the host still only
|
|
282
|
+
// answers on loopback. A NATIVE gateway has no port mapping to hide behind — 0.0.0.0 there puts
|
|
283
|
+
// it straight on the VPS's public interface, with the auth token crossing the wire in plaintext
|
|
284
|
+
// (the gateway speaks plain HTTP/WS) and, on a fresh VPS, no firewall in front of it. The
|
|
285
|
+
// `osChoice === 'vps'` clause predates native mode; keep native on loopback and reach it through
|
|
286
|
+
// an SSH tunnel, which is what docker-on-a-VPS effectively does too.
|
|
287
|
+
const openGatewayBind = deployMode === 'docker' || (osChoice === 'vps' && deployMode !== 'native');
|
|
280
288
|
cfg.gateway = {
|
|
281
289
|
port: gatewayPort,
|
|
282
290
|
mode: 'local',
|
|
283
|
-
bind:
|
|
284
|
-
...(
|
|
291
|
+
bind: openGatewayBind ? 'custom' : 'loopback',
|
|
292
|
+
...(openGatewayBind ? { customBindHost: '0.0.0.0' } : {}),
|
|
285
293
|
controlUi: {
|
|
286
294
|
allowedOrigins: gatewayAllowedOrigins.length > 0
|
|
287
295
|
? gatewayAllowedOrigins
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// PowerShell backend for host UI automation on Windows.
|
|
3
|
+
//
|
|
4
|
+
// Windows has no xdotool and no bundled CLI for input, so the actions are done the way the
|
|
5
|
+
// no-dependency tools do it: P/Invoke into user32.dll for the pointer and keys (SetCursorPos /
|
|
6
|
+
// mouse_event / keybd_event), System.Windows.Forms.SendKeys for text, System.Drawing's
|
|
7
|
+
// CopyFromScreen for captures, and Get/Set-Clipboard for the clipboard. Shipping this as one
|
|
8
|
+
// version-stamped .ps1 keeps the quoting sane: the server passes typed parameters, never a
|
|
9
|
+
// composed command line.
|
|
10
|
+
(function (root) {
|
|
11
|
+
const HOST_UI_PS1_VERSION = '1';
|
|
12
|
+
|
|
13
|
+
const HOST_UI_PS1 = String.raw`# OpenClaw host UI helper — version __VERSION__
|
|
14
|
+
# Generated by openclaw-setup. Edits are overwritten when the version changes.
|
|
15
|
+
param(
|
|
16
|
+
[Parameter(Mandatory = $true)][string]$Action,
|
|
17
|
+
[int]$X = -1,
|
|
18
|
+
[int]$Y = -1,
|
|
19
|
+
[int]$ToX = -1,
|
|
20
|
+
[int]$ToY = -1,
|
|
21
|
+
[int]$Amount = 3,
|
|
22
|
+
[string]$Text = '',
|
|
23
|
+
[string]$Button = 'left',
|
|
24
|
+
[int]$Clicks = 1,
|
|
25
|
+
[string]$Path = '',
|
|
26
|
+
[string]$Title = ''
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
$ErrorActionPreference = 'Stop'
|
|
30
|
+
Add-Type -AssemblyName System.Windows.Forms, System.Drawing
|
|
31
|
+
|
|
32
|
+
if (-not ('OpenClawUi' -as [type])) {
|
|
33
|
+
Add-Type -Namespace '' -Name OpenClawUi -MemberDefinition @'
|
|
34
|
+
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
|
35
|
+
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, int extra);
|
|
36
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr hWnd);
|
|
37
|
+
[DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int cmd);
|
|
38
|
+
'@
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
$MOUSEEVENTF = @{
|
|
42
|
+
leftdown = 0x0002; leftup = 0x0004;
|
|
43
|
+
rightdown = 0x0008; rightup = 0x0010;
|
|
44
|
+
middledown = 0x0020; middleup = 0x0040;
|
|
45
|
+
wheel = 0x0800; hwheel = 0x01000;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Out-Json($obj) { $obj | ConvertTo-Json -Compress -Depth 6 }
|
|
49
|
+
|
|
50
|
+
function Move-Pointer([int]$px, [int]$py) {
|
|
51
|
+
if ($px -ge 0 -and $py -ge 0) { [OpenClawUi]::SetCursorPos($px, $py) | Out-Null; Start-Sleep -Milliseconds 40 }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Invoke-Click([string]$btn, [int]$times) {
|
|
55
|
+
$down = $MOUSEEVENTF[($btn + 'down')]
|
|
56
|
+
$up = $MOUSEEVENTF[($btn + 'up')]
|
|
57
|
+
if (-not $down) { throw "unknown button: $btn" }
|
|
58
|
+
for ($i = 0; $i -lt [Math]::Max(1, $times); $i++) {
|
|
59
|
+
[OpenClawUi]::mouse_event($down, 0, 0, 0, 0)
|
|
60
|
+
[OpenClawUi]::mouse_event($up, 0, 0, 0, 0)
|
|
61
|
+
Start-Sleep -Milliseconds 60
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# SendKeys needs its own escaping: + ^ % ~ ( ) { } [ ] are modifiers there, so literal text has
|
|
66
|
+
# to be wrapped in braces or the bot's message silently turns into shortcuts.
|
|
67
|
+
function ConvertTo-SendKeysLiteral([string]$s) {
|
|
68
|
+
$sb = New-Object System.Text.StringBuilder
|
|
69
|
+
foreach ($ch in $s.ToCharArray()) {
|
|
70
|
+
if ('+^%~(){}[]'.Contains($ch)) { [void]$sb.Append('{' + $ch + '}') }
|
|
71
|
+
elseif ($ch -eq [char]10) { [void]$sb.Append('{ENTER}') }
|
|
72
|
+
elseif ($ch -eq [char]9) { [void]$sb.Append('{TAB}') }
|
|
73
|
+
elseif ($ch -eq [char]13) { }
|
|
74
|
+
else { [void]$sb.Append($ch) }
|
|
75
|
+
}
|
|
76
|
+
$sb.ToString()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# "ctrl+shift+t" / "enter" / "cmd+a" (cmd is mapped to ctrl on Windows) → SendKeys syntax.
|
|
80
|
+
function ConvertTo-SendKeysCombo([string]$combo) {
|
|
81
|
+
$named = @{
|
|
82
|
+
enter = '{ENTER}'; return = '{ENTER}'; tab = '{TAB}'; esc = '{ESC}'; escape = '{ESC}';
|
|
83
|
+
backspace = '{BACKSPACE}'; delete = '{DELETE}'; del = '{DELETE}'; home = '{HOME}'; end = '{END}';
|
|
84
|
+
pageup = '{PGUP}'; pagedown = '{PGDN}'; up = '{UP}'; down = '{DOWN}'; left = '{LEFT}'; right = '{RIGHT}';
|
|
85
|
+
space = ' '; f1 = '{F1}'; f2 = '{F2}'; f3 = '{F3}'; f4 = '{F4}'; f5 = '{F5}'; f6 = '{F6}';
|
|
86
|
+
f7 = '{F7}'; f8 = '{F8}'; f9 = '{F9}'; f10 = '{F10}'; f11 = '{F11}'; f12 = '{F12}';
|
|
87
|
+
printscreen = '{PRTSC}'; insert = '{INSERT}';
|
|
88
|
+
}
|
|
89
|
+
$prefix = ''
|
|
90
|
+
$parts = $combo.Split('+') | ForEach-Object { $_.Trim().ToLower() } | Where-Object { $_ }
|
|
91
|
+
$keyPart = ''
|
|
92
|
+
foreach ($p in $parts) {
|
|
93
|
+
switch ($p) {
|
|
94
|
+
'ctrl' { $prefix += '^' }
|
|
95
|
+
'control' { $prefix += '^' }
|
|
96
|
+
'cmd' { $prefix += '^' } # macOS habit; Windows equivalent is Ctrl
|
|
97
|
+
'meta' { $prefix += '^' }
|
|
98
|
+
'alt' { $prefix += '%' }
|
|
99
|
+
'shift' { $prefix += '+' }
|
|
100
|
+
'win' { $prefix += '' } # SendKeys cannot press Win; use the Windows key combos below
|
|
101
|
+
default { $keyPart = $p }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (-not $keyPart) { throw "no key in combo: $combo" }
|
|
105
|
+
$mapped = if ($named.ContainsKey($keyPart)) { $named[$keyPart] } else { $keyPart }
|
|
106
|
+
$prefix + $mapped
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
switch ($Action) {
|
|
110
|
+
'screen_size' {
|
|
111
|
+
$b = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
|
112
|
+
Out-Json @{ ok = $true; width = $b.Width; height = $b.Height; left = $b.Left; top = $b.Top }
|
|
113
|
+
}
|
|
114
|
+
'screenshot' {
|
|
115
|
+
if (-not $Path) { throw 'screenshot needs -Path' }
|
|
116
|
+
$b = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
|
117
|
+
$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height
|
|
118
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
119
|
+
$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)
|
|
120
|
+
$dir = Split-Path -Parent $Path
|
|
121
|
+
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
|
|
122
|
+
$bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
123
|
+
$g.Dispose(); $bmp.Dispose()
|
|
124
|
+
Out-Json @{ ok = $true; path = $Path; width = $b.Width; height = $b.Height }
|
|
125
|
+
}
|
|
126
|
+
'mouse_move' { Move-Pointer $X $Y; Out-Json @{ ok = $true; x = $X; y = $Y } }
|
|
127
|
+
'click' { Move-Pointer $X $Y; Invoke-Click $Button $Clicks; Out-Json @{ ok = $true; button = $Button; clicks = $Clicks } }
|
|
128
|
+
'drag' {
|
|
129
|
+
Move-Pointer $X $Y
|
|
130
|
+
[OpenClawUi]::mouse_event($MOUSEEVENTF['leftdown'], 0, 0, 0, 0)
|
|
131
|
+
Start-Sleep -Milliseconds 80
|
|
132
|
+
Move-Pointer $ToX $ToY
|
|
133
|
+
[OpenClawUi]::mouse_event($MOUSEEVENTF['leftup'], 0, 0, 0, 0)
|
|
134
|
+
Out-Json @{ ok = $true; from = @($X, $Y); to = @($ToX, $ToY) }
|
|
135
|
+
}
|
|
136
|
+
'scroll' {
|
|
137
|
+
Move-Pointer $X $Y
|
|
138
|
+
$ticks = [Math]::Max(1, [Math]::Abs($Amount))
|
|
139
|
+
$delta = if ($Amount -lt 0) { -120 } else { 120 }
|
|
140
|
+
for ($i = 0; $i -lt $ticks; $i++) {
|
|
141
|
+
[OpenClawUi]::mouse_event($MOUSEEVENTF['wheel'], 0, 0, [uint32]([int]$delta -band 0xFFFFFFFF), 0)
|
|
142
|
+
Start-Sleep -Milliseconds 40
|
|
143
|
+
}
|
|
144
|
+
Out-Json @{ ok = $true; amount = $Amount }
|
|
145
|
+
}
|
|
146
|
+
'type' {
|
|
147
|
+
[System.Windows.Forms.SendKeys]::SendWait((ConvertTo-SendKeysLiteral $Text))
|
|
148
|
+
Out-Json @{ ok = $true; typed = $Text.Length }
|
|
149
|
+
}
|
|
150
|
+
'key' {
|
|
151
|
+
foreach ($combo in ($Text -split '\s+' | Where-Object { $_ })) {
|
|
152
|
+
[System.Windows.Forms.SendKeys]::SendWait((ConvertTo-SendKeysCombo $combo))
|
|
153
|
+
Start-Sleep -Milliseconds 60
|
|
154
|
+
}
|
|
155
|
+
Out-Json @{ ok = $true; keys = $Text }
|
|
156
|
+
}
|
|
157
|
+
'clipboard_get' { Out-Json @{ ok = $true; text = (Get-Clipboard -Raw) } }
|
|
158
|
+
'clipboard_set' { Set-Clipboard -Value $Text; Out-Json @{ ok = $true; length = $Text.Length } }
|
|
159
|
+
'windows' {
|
|
160
|
+
$list = Get-Process | Where-Object { $_.MainWindowTitle } |
|
|
161
|
+
Select-Object -First 40 @{n = 'title'; e = { $_.MainWindowTitle } }, @{n = 'process'; e = { $_.ProcessName } }, Id
|
|
162
|
+
Out-Json @{ ok = $true; windows = @($list) }
|
|
163
|
+
}
|
|
164
|
+
'focus' {
|
|
165
|
+
if (-not $Title) { throw 'focus needs -Title' }
|
|
166
|
+
$proc = Get-Process | Where-Object { $_.MainWindowTitle -like "*$Title*" } | Select-Object -First 1
|
|
167
|
+
if (-not $proc) { Out-Json @{ ok = $false; error = "no window matching: $Title" }; break }
|
|
168
|
+
[OpenClawUi]::ShowWindow($proc.MainWindowHandle, 9) | Out-Null # SW_RESTORE
|
|
169
|
+
[OpenClawUi]::SetForegroundWindow($proc.MainWindowHandle) | Out-Null
|
|
170
|
+
Out-Json @{ ok = $true; focused = $proc.MainWindowTitle }
|
|
171
|
+
}
|
|
172
|
+
default { Out-Json @{ ok = $false; error = "unknown action: $Action" } }
|
|
173
|
+
}
|
|
174
|
+
`.replace('__VERSION__', HOST_UI_PS1_VERSION);
|
|
175
|
+
|
|
176
|
+
const api = { HOST_UI_PS1, HOST_UI_PS1_VERSION };
|
|
177
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
178
|
+
root.__openclawHostUiPs1 = api;
|
|
179
|
+
})(typeof globalThis !== 'undefined' ? globalThis : this);
|