create-openclaw-bot 5.15.2 → 5.16.0
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 +728 -39
- package/dist/setup/shared/bot-config-gen.js +10 -2
- package/dist/setup/shared/common-gen.js +7 -1
- package/dist/setup/shared/docker-gen.js +16 -1
- package/dist/setup/shared/host-ui-ps1.js +179 -0
- package/dist/web/app.js +65 -7
- package/package.json +1 -1
|
@@ -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,215 @@ 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
|
-
|
|
2770
|
+
// Every restart is a chance to repair a project installed before these two fixes existed — both
|
|
2771
|
+
// calls are no-ops once the service env is complete and the stray files have been adopted.
|
|
2772
|
+
await adoptStrayNativeHome(projectDir).catch(() => {});
|
|
2773
|
+
await syncNativeServiceEnv(projectDir).catch(() => {});
|
|
2652
2774
|
const stopStart = async () => {
|
|
2653
|
-
await
|
|
2654
|
-
|
|
2775
|
+
await ocDaemon(projectDir, 'stop');
|
|
2776
|
+
return ocDaemon(projectDir, 'start');
|
|
2655
2777
|
};
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
await
|
|
2659
|
-
}
|
|
2660
|
-
|
|
2661
|
-
|
|
2778
|
+
let res;
|
|
2779
|
+
if (process.platform === 'win32') {
|
|
2780
|
+
res = await stopStart();
|
|
2781
|
+
} else {
|
|
2782
|
+
res = await ocDaemon(projectDir, 'restart');
|
|
2783
|
+
// A lease collision is a "come back in a moment", not a broken service: stop+start would only
|
|
2784
|
+
// collide again, so fall through to the wait below instead.
|
|
2785
|
+
if (res.code !== 0 && !migrationLeaseDeadline(res.text)) {
|
|
2786
|
+
sendLog(`[native] daemon restart exited ${res.code}; falling back to stop+start`);
|
|
2787
|
+
res = await stopStart();
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
const deadline = migrationLeaseDeadline(res.text);
|
|
2791
|
+
if (deadline) {
|
|
2792
|
+
const waitMs = Math.max(0, Math.min(deadline - Date.now(), 300000)) + 3000;
|
|
2793
|
+
sendLog(`[native] Startup migrations hold the state lease — waiting ${Math.ceil(waitMs / 1000)}s before retrying.`);
|
|
2794
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
2795
|
+
res = await ocDaemon(projectDir, 'restart');
|
|
2796
|
+
if (res.code !== 0) res = await stopStart();
|
|
2797
|
+
}
|
|
2798
|
+
// systemd keeps restarting a crash-looping unit every RestartSec, so a gateway blocked by a lease
|
|
2799
|
+
// we never saw still comes up on its own — give it room before calling the restart a failure.
|
|
2800
|
+
if (!(await waitForNativeGatewayHealthy(projectDir, 180000))) {
|
|
2801
|
+
throw new Error('gateway did not answer /health after restart');
|
|
2802
|
+
}
|
|
2803
|
+
return true;
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
/**
|
|
2807
|
+
* Make the generated service carry everything nativeEnv() promises.
|
|
2808
|
+
*
|
|
2809
|
+
* `openclaw daemon install` propagates only a fixed allow-list into the service it writes:
|
|
2810
|
+
* OPENCLAW_STATE_DIR survives, but OPENCLAW_HOME does NOT — verified on both a systemd user unit and
|
|
2811
|
+
* a launchd env-wrapper. Anything resolving paths from OPENCLAW_HOME then falls back to `~/.openclaw`
|
|
2812
|
+
* and writes OUTSIDE the project. zalo-connect is the visible casualty: it stages inbound files and
|
|
2813
|
+
* its Zalo session credentials under the wrong home, so a PDF sent to the bot lands somewhere the
|
|
2814
|
+
* agent's workspace cannot reach ("em chưa trích xuất được nội dung từ PDF") and the session sits in
|
|
2815
|
+
* a different home from the config that describes it.
|
|
2816
|
+
*
|
|
2817
|
+
* Idempotent: keys already present are left alone, so this is safe to run on every restart and it
|
|
2818
|
+
* self-heals projects created before the fix.
|
|
2819
|
+
*/
|
|
2820
|
+
async function syncNativeServiceEnv(projectDir) {
|
|
2821
|
+
if (!isNativeProject(projectDir)) return [];
|
|
2822
|
+
const want = nativeEnv(projectDir);
|
|
2823
|
+
const label = nativeServiceLabel(projectDir);
|
|
2824
|
+
|
|
2825
|
+
if (process.platform === 'linux') {
|
|
2826
|
+
const unit = `${label}.service`;
|
|
2827
|
+
const shown = await runCapture('systemctl', ['--user', 'show', '-p', 'FragmentPath', '--value', unit], { shell: false, timeout: 10000 });
|
|
2828
|
+
const path = String(shown.stdout || '').trim() || join(os.homedir(), '.config', 'systemd', 'user', unit);
|
|
2829
|
+
if (!existsSync(path)) return [];
|
|
2830
|
+
const lines = (await fsp.readFile(path, 'utf8')).split('\n');
|
|
2831
|
+
const have = new Set();
|
|
2832
|
+
for (const line of lines) {
|
|
2833
|
+
const m = line.trim().match(/^Environment="?([A-Z_0-9]+)=/);
|
|
2834
|
+
if (m) have.add(m[1]);
|
|
2835
|
+
}
|
|
2836
|
+
const missing = Object.entries(want).filter(([k, v]) => !have.has(k) && v !== '');
|
|
2837
|
+
if (!missing.length) return [];
|
|
2838
|
+
// Insert after the last existing Environment= line so the additions stay inside [Service].
|
|
2839
|
+
let at = -1;
|
|
2840
|
+
lines.forEach((line, i) => { if (line.startsWith('Environment=')) at = i; });
|
|
2841
|
+
if (at < 0) at = lines.findIndex((line) => line.trim() === '[Service]');
|
|
2842
|
+
if (at < 0) return [];
|
|
2843
|
+
lines.splice(at + 1, 0, ...missing.map(([k, v]) => `Environment=${k}=${v}`));
|
|
2844
|
+
await fsp.copyFile(path, `${path}.bak`).catch(() => {});
|
|
2845
|
+
await fsp.writeFile(path, lines.join('\n'), 'utf8');
|
|
2846
|
+
await run('systemctl', ['--user', 'daemon-reload'], {}).catch(() => {});
|
|
2847
|
+
sendLog(`[native] service env completed: ${missing.map(([k]) => k).join(', ')}`);
|
|
2848
|
+
return missing.map(([k]) => k);
|
|
2662
2849
|
}
|
|
2850
|
+
|
|
2851
|
+
if (process.platform === 'darwin') {
|
|
2852
|
+
// launchd runs the gateway through an env-wrapper that sources this file, so patching it is the
|
|
2853
|
+
// launchd equivalent of adding Environment= lines to a unit.
|
|
2854
|
+
const envFile = join(projectDir, '.openclaw', 'service-env', `${label}.env`);
|
|
2855
|
+
if (!existsSync(envFile)) return [];
|
|
2856
|
+
const body = await fsp.readFile(envFile, 'utf8');
|
|
2857
|
+
const have = new Set([...body.matchAll(/^\s*export\s+([A-Z_0-9]+)=/gm)].map((m) => m[1]));
|
|
2858
|
+
const missing = Object.entries(want).filter(([k, v]) => !have.has(k) && v !== '');
|
|
2859
|
+
if (!missing.length) return [];
|
|
2860
|
+
const added = missing.map(([k, v]) => `export ${k}='${String(v).replace(/'/g, "'\\''")}'`).join('\n');
|
|
2861
|
+
await fsp.copyFile(envFile, `${envFile}.bak`).catch(() => {});
|
|
2862
|
+
await fsp.writeFile(envFile, `${body.replace(/\n*$/, '')}\n${added}\n`, 'utf8');
|
|
2863
|
+
sendLog(`[native] service env completed: ${missing.map(([k]) => k).join(', ')}`);
|
|
2864
|
+
return missing.map(([k]) => k);
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
return [];
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
/**
|
|
2871
|
+
* Reunite a native project with the files an unset OPENCLAW_HOME scattered into `~/.openclaw`.
|
|
2872
|
+
*
|
|
2873
|
+
* This MUST run before syncNativeServiceEnv takes effect: once OPENCLAW_HOME is finally correct, the
|
|
2874
|
+
* plugin looks for its Zalo session inside the project — and if the credentials are still sitting in
|
|
2875
|
+
* the home directory it finds nothing and demands a fresh QR login. Copy (never move) so a failed
|
|
2876
|
+
* run leaves the working original in place; skip anything the project already has.
|
|
2877
|
+
*/
|
|
2878
|
+
async function adoptStrayNativeHome(projectDir) {
|
|
2879
|
+
if (!isNativeProject(projectDir)) return [];
|
|
2880
|
+
const projectHome = join(projectDir, '.openclaw');
|
|
2881
|
+
const strayHome = join(os.homedir(), '.openclaw');
|
|
2882
|
+
if (resolve(strayHome) === resolve(projectHome) || !existsSync(strayHome)) return [];
|
|
2883
|
+
const moved = [];
|
|
2884
|
+
const entries = await fsp.readdir(strayHome, { withFileTypes: true }).catch(() => []);
|
|
2885
|
+
for (const entry of entries) {
|
|
2886
|
+
// `state` is deliberately excluded: the project has its own live database and merging two
|
|
2887
|
+
// sqlite files is not something a copy can do correctly.
|
|
2888
|
+
const isCreds = /^zalo-connect-credentials.*\.json$/.test(entry.name);
|
|
2889
|
+
if (!isCreds && entry.name !== 'media') continue;
|
|
2890
|
+
const from = join(strayHome, entry.name);
|
|
2891
|
+
const to = join(projectHome, entry.name);
|
|
2892
|
+
if (existsSync(to)) continue;
|
|
2893
|
+
await fsp.cp(from, to, { recursive: true }).catch(() => {});
|
|
2894
|
+
if (existsSync(to)) moved.push(entry.name);
|
|
2895
|
+
}
|
|
2896
|
+
if (moved.length) {
|
|
2897
|
+
sendLog(`[migrate] Native: adopted ${moved.join(', ')} from ${strayHome} (written there while OPENCLAW_HOME was unset).`);
|
|
2898
|
+
}
|
|
2899
|
+
return moved;
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
/**
|
|
2903
|
+
* `openclaw daemon install` has no `--system` flag, so on Linux the gateway becomes a systemd USER
|
|
2904
|
+
* unit — and a user manager without linger is torn down when that user's last session exits. On a
|
|
2905
|
+
* desktop the graphical session holds it open, which is why this never showed up on macOS or a
|
|
2906
|
+
* Linux desktop; on a VPS the bot dies the moment the operator closes SSH and never comes back
|
|
2907
|
+
* after a reboot. Linger is what makes a user unit behave like the `restart: always` container it
|
|
2908
|
+
* replaces. Best-effort: a box without loginctl just keeps the old behaviour, loudly.
|
|
2909
|
+
*/
|
|
2910
|
+
async function ensureSystemdLinger() {
|
|
2911
|
+
if (process.platform !== 'linux') return false;
|
|
2912
|
+
let user = '';
|
|
2913
|
+
try { user = process.env.SUDO_USER || os.userInfo().username; } catch { return false; }
|
|
2914
|
+
if (!user) return false;
|
|
2915
|
+
const cur = await runCapture('loginctl', ['show-user', user, '-p', 'Linger'], { shell: false, timeout: 10000 });
|
|
2916
|
+
if (/Linger=yes/i.test(cur.stdout || '')) return true;
|
|
2917
|
+
const out = await runCapture('loginctl', ['enable-linger', user], { shell: false, timeout: 20000 });
|
|
2918
|
+
if (out.code === 0) {
|
|
2919
|
+
sendLog(`[native] systemd linger enabled for "${user}" — the gateway now survives logout and reboot.`);
|
|
2920
|
+
return true;
|
|
2921
|
+
}
|
|
2922
|
+
sendLog(`[native] WARNING: could not enable systemd linger for "${user}" (${(out.stderr || out.stdout || '').trim() || `exit ${out.code}`}).`);
|
|
2923
|
+
sendLog(`[native] The gateway will stop when this user's last session ends. Fix it with: sudo loginctl enable-linger ${user}`);
|
|
2924
|
+
return false;
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
/**
|
|
2928
|
+
* Native counterpart of the docker entrypoint's `ensure_plugin` (docker-gen.js).
|
|
2929
|
+
*
|
|
2930
|
+
* A container reinstalls its missing plugins on every boot; a native project has no entrypoint, so
|
|
2931
|
+
* nothing ever put zalo-connect or learning-memory on disk. The generated config declares both
|
|
2932
|
+
* anyway (bot-config-gen writes plugins.entries + allow + slots.contextEngine), so without this the
|
|
2933
|
+
* gateway boots with "plugin not found" warnings, `channels.zalo-connect` has no owner — Zalo login
|
|
2934
|
+
* fails with `Unsupported channel "zalo-connect"` — and the bot silently runs with no context
|
|
2935
|
+
* engine at all. Same set and same skip-if-present cheapness as ensure_plugin.
|
|
2936
|
+
*/
|
|
2937
|
+
async function ensureNativePlugins(projectDir, { restart = false } = {}) {
|
|
2938
|
+
if (!isNativeProject(projectDir)) return [];
|
|
2939
|
+
// Same cleanup the container entrypoint does (docker-gen.js): an interrupted `plugins install`
|
|
2940
|
+
// leaves extensions/.openclaw-install-stage-XXXXXX behind, and it still carries a plugin manifest —
|
|
2941
|
+
// so the gateway logs "duplicate plugin id detected" every boot and a stale build competes with the
|
|
2942
|
+
// real one for the same id. Native has no entrypoint, so it has to happen here.
|
|
2943
|
+
const extRoot = join(projectDir, '.openclaw', 'extensions');
|
|
2944
|
+
for (const entry of await fsp.readdir(extRoot, { withFileTypes: true }).catch(() => [])) {
|
|
2945
|
+
if (!entry.isDirectory() || !entry.name.startsWith('.openclaw-install-stage-')) continue;
|
|
2946
|
+
await fsp.rm(join(extRoot, entry.name), { recursive: true, force: true }).catch(() => {});
|
|
2947
|
+
sendLog(`[native] removed abandoned plugin staging dir ${entry.name}`);
|
|
2948
|
+
}
|
|
2949
|
+
let cfg = {};
|
|
2950
|
+
try { cfg = JSON.parse(await fsp.readFile(join(projectDir, '.openclaw', 'openclaw.json'), 'utf8')); } catch {}
|
|
2951
|
+
// learning-memory backs plugins.slots.contextEngine for every bot; zalo-connect only when a bot
|
|
2952
|
+
// actually declares the channel (mirrors docker-gen's `if (zaloBackend === 'zalo-connect')`).
|
|
2953
|
+
const wanted = new Set(['learning-memory']);
|
|
2954
|
+
if (cfg?.channels?.[ZALO_CHANNEL_ID] || cfg?.plugins?.entries?.[ZALO_PLUGIN_ID]) wanted.add(ZALO_PLUGIN_ID);
|
|
2955
|
+
const installed = [];
|
|
2956
|
+
for (const id of wanted) {
|
|
2957
|
+
const dir = join(projectDir, '.openclaw', 'extensions', id);
|
|
2958
|
+
if (existsSync(dir)) continue;
|
|
2959
|
+
const spec = id === ZALO_PLUGIN_ID ? ZALO_CONNECT_PLUGIN_SPEC : pluginInstallSpec(id);
|
|
2960
|
+
sendLog(`[native] plugin ${id} missing; installing ${spec}`);
|
|
2961
|
+
const out = await ocCapture(projectDir, ['plugins', 'install', spec, '--force', '--acknowledge-clawhub-risk'], { timeout: 300000 });
|
|
2962
|
+
const text = `${out.stdout || ''}\n${out.stderr || ''}`;
|
|
2963
|
+
for (const line of text.split(/\r?\n/).map((l) => l.trimEnd()).filter(Boolean)) sendLog(`[native] ${line}`);
|
|
2964
|
+
if (existsSync(dir) || /installed plugin/i.test(text)) installed.push(id);
|
|
2965
|
+
else sendLog(`[native] WARNING: could not install plugin ${id} — the bot will run without it.`);
|
|
2966
|
+
}
|
|
2967
|
+
if (installed.length && restart) {
|
|
2968
|
+
sendLog(`[native] Restarting gateway to load: ${installed.join(', ')}`);
|
|
2969
|
+
await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart after plugin install: ${e.message}`));
|
|
2970
|
+
}
|
|
2971
|
+
return installed;
|
|
2663
2972
|
}
|
|
2664
2973
|
|
|
2665
2974
|
/** Fire-and-forget background process (9router has no service wrapper of its own). */
|
|
@@ -2763,11 +3072,25 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
|
|
|
2763
3072
|
await new Promise((r) => setTimeout(r, 8000));
|
|
2764
3073
|
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
2765
3074
|
|
|
3075
|
+
// Plugins BEFORE the gateway's first boot — the container entrypoint installs them ahead of the
|
|
3076
|
+
// gateway for the same reason: a gateway that boots with its plugins already on disk loads them
|
|
3077
|
+
// straight away, needs no follow-up restart, and prints no "plugin not found" warnings.
|
|
3078
|
+
await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin bootstrap skipped: ${e.message}`));
|
|
3079
|
+
|
|
2766
3080
|
// Managed service = auto-restart (KeepAlive/Restart=always) and start-at-login, the native
|
|
2767
3081
|
// equivalent of docker's `restart: always`. --force so re-running install updates the port.
|
|
2768
3082
|
const env = nativeEnv(projectDir);
|
|
3083
|
+
await ensureSystemdLinger();
|
|
2769
3084
|
await run('openclaw', ['daemon', 'install', '--force', '--port', String(gwPort)], { cwd: projectDir, env });
|
|
3085
|
+
// Order is load-bearing: adopt the stray files FIRST, then complete the service env. The other way
|
|
3086
|
+
// round, the gateway boots with a corrected OPENCLAW_HOME, finds no Zalo session there, and asks
|
|
3087
|
+
// for a new QR scan even though a perfectly good session exists in the home directory.
|
|
3088
|
+
await adoptStrayNativeHome(projectDir).catch((e) => sendLog(`[migrate] stray home skipped: ${e.message}`));
|
|
3089
|
+
await syncNativeServiceEnv(projectDir).catch((e) => sendLog(`[native] service env sync skipped: ${e.message}`));
|
|
2770
3090
|
await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
|
|
3091
|
+
// Let the first boot finish its state migrations here, while nothing else is competing for the
|
|
3092
|
+
// lease. Every later action (create bot, install plugin) then restarts a settled gateway.
|
|
3093
|
+
await waitForNativeGatewayHealthy(projectDir, 180000);
|
|
2771
3094
|
sendLog(`[native] gateway service "${label}" running on 127.0.0.1:${gwPort}, 9router on 127.0.0.1:${rtPort}`);
|
|
2772
3095
|
return { gatewayPort: gwPort, routerPort: rtPort, label };
|
|
2773
3096
|
}
|
|
@@ -2916,6 +3239,14 @@ async function recreateDockerBot(projectDir) {
|
|
|
2916
3239
|
// Native: there is no image to rebuild — the gateway reads openclaw.json from disk on boot, so
|
|
2917
3240
|
// reloading config after a bot/plugin change is just a service restart. Callers stay unchanged.
|
|
2918
3241
|
if (isNativeProject(projectDir)) {
|
|
3242
|
+
// Never restart a gateway that is still on its first boot: OpenClaw runs startup migrations
|
|
3243
|
+
// under a state lease, a restart mid-migration exits 1, and systemd's start limit can then
|
|
3244
|
+
// abandon the unit. This is the same trap the docker path avoids by waiting for the container
|
|
3245
|
+
// before touching it (see startZaloConnectLogin) — wait for /health first.
|
|
3246
|
+
await waitForNativeGatewayHealthy(projectDir, 180000);
|
|
3247
|
+
// The bot that was just created/edited may have added the Zalo channel or the context engine to
|
|
3248
|
+
// openclaw.json; put those plugins on disk now so this one reload loads them too.
|
|
3249
|
+
await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin ensure skipped: ${e.message}`));
|
|
2919
3250
|
sendLog('[native] Reloading gateway to pick up openclaw.json changes...');
|
|
2920
3251
|
await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart failed: ${e.message}`));
|
|
2921
3252
|
probeCacheClear();
|
|
@@ -3277,13 +3608,58 @@ function whichSync(name) {
|
|
|
3277
3608
|
try {
|
|
3278
3609
|
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
3279
3610
|
const out = execFileSync(finder, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
3280
|
-
const
|
|
3281
|
-
return
|
|
3611
|
+
const hits = String(out).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
3612
|
+
if (process.platform !== 'win32') return hits[0] || '';
|
|
3613
|
+
// `where claude` lists the extensionless npm shim FIRST — a shell script Windows cannot spawn
|
|
3614
|
+
// ("spawn ...\\npm\\claude ENOENT"), which is how an allow-listed CLI ended up unusable for the
|
|
3615
|
+
// bot. Prefer something Windows can actually execute.
|
|
3616
|
+
const rank = (f) => {
|
|
3617
|
+
const ext = extname(f).toLowerCase();
|
|
3618
|
+
const order = ['.exe', '.cmd', '.bat', '.com', '.ps1'];
|
|
3619
|
+
const idx = order.indexOf(ext);
|
|
3620
|
+
return idx === -1 ? order.length : idx;
|
|
3621
|
+
};
|
|
3622
|
+
return [...hits].sort((a, b) => rank(a) - rank(b))[0] || '';
|
|
3282
3623
|
} catch (_) {
|
|
3283
3624
|
return '';
|
|
3284
3625
|
}
|
|
3285
3626
|
}
|
|
3286
3627
|
|
|
3628
|
+
/**
|
|
3629
|
+
* What to actually spawn for an allow-listed command. Windows needs the indirection:
|
|
3630
|
+
* - the path may be the extensionless npm shim (a shell script) — try the real siblings;
|
|
3631
|
+
* - a `.cmd`/`.bat` shim cannot be spawned without a shell on current Node, so read it and run
|
|
3632
|
+
* what it points at (`…\pkg\bin\x.exe`, or node + a cli.js) directly.
|
|
3633
|
+
* Keeping shell:false matters: the bot supplies the arguments, and a shell would let one of them
|
|
3634
|
+
* become a second command.
|
|
3635
|
+
*/
|
|
3636
|
+
function resolveHostExecutable(bin) {
|
|
3637
|
+
if (process.platform !== 'win32') return { file: bin, prefixArgs: [] };
|
|
3638
|
+
let target = bin;
|
|
3639
|
+
if (!extname(target)) {
|
|
3640
|
+
const candidate = ['.exe', '.cmd', '.bat'].map((ext) => target + ext).find((f) => existsSync(f));
|
|
3641
|
+
if (candidate) target = candidate;
|
|
3642
|
+
}
|
|
3643
|
+
const ext = extname(target).toLowerCase();
|
|
3644
|
+
if (ext !== '.cmd' && ext !== '.bat') return { file: target, prefixArgs: [] };
|
|
3645
|
+
try {
|
|
3646
|
+
const shim = readFileSync(target, 'utf8');
|
|
3647
|
+
const dir = dirname(target);
|
|
3648
|
+
const expand = (p) => resolve(dir, p.replace(/%~?dp0%\\?/gi, '').replace(/^\\+/, ''));
|
|
3649
|
+
const exeRef = shim.match(/"([^"\n]*?\.exe)"/i);
|
|
3650
|
+
if (exeRef) {
|
|
3651
|
+
const exe = expand(exeRef[1]);
|
|
3652
|
+
if (existsSync(exe)) return { file: exe, prefixArgs: [] };
|
|
3653
|
+
}
|
|
3654
|
+
const jsRef = shim.match(/"([^"\n]*?\.js)"/i);
|
|
3655
|
+
if (jsRef) {
|
|
3656
|
+
const js = expand(jsRef[1]);
|
|
3657
|
+
if (existsSync(js)) return { file: process.execPath, prefixArgs: [js] };
|
|
3658
|
+
}
|
|
3659
|
+
} catch (_) {}
|
|
3660
|
+
return { file: target, prefixArgs: [] };
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3287
3663
|
/**
|
|
3288
3664
|
* CLI tools the bot may RUN (not just open) via /api/host/exec — output is captured and
|
|
3289
3665
|
* returned. Kept as a name→path allow-list, mirroring detectHostApps: the executable is fixed,
|
|
@@ -3348,6 +3724,13 @@ function grantHostCapabilities(cfg) {
|
|
|
3348
3724
|
added.push(name);
|
|
3349
3725
|
}
|
|
3350
3726
|
}
|
|
3727
|
+
// Desktop actions (/api/host/ui) come with the same grant: screenshot, pointer, keyboard,
|
|
3728
|
+
// clipboard, windows. Built in, so they work on a machine with no Codex and no extra tools —
|
|
3729
|
+
// on Linux they lean on xdotool/scrot, which the endpoint reports if missing.
|
|
3730
|
+
if (cfg.ui !== true) {
|
|
3731
|
+
cfg.ui = true;
|
|
3732
|
+
added.push('desktop actions (screenshot/click/type)');
|
|
3733
|
+
}
|
|
3351
3734
|
return added;
|
|
3352
3735
|
}
|
|
3353
3736
|
|
|
@@ -3619,7 +4002,8 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
|
|
|
3619
4002
|
};
|
|
3620
4003
|
let child;
|
|
3621
4004
|
try {
|
|
3622
|
-
|
|
4005
|
+
const target = resolveHostExecutable(bin);
|
|
4006
|
+
child = spawn(target.file, [...target.prefixArgs, ...args], { shell: false, windowsHide: true });
|
|
3623
4007
|
} catch (e) {
|
|
3624
4008
|
return finish({ ok: false, error: e.message }, 500);
|
|
3625
4009
|
}
|
|
@@ -3639,6 +4023,262 @@ function runHostCommand(res, name, bin, args, input, timeoutMs) {
|
|
|
3639
4023
|
});
|
|
3640
4024
|
}
|
|
3641
4025
|
|
|
4026
|
+
/**
|
|
4027
|
+
* Desktop actions for the bot: see the screen, move and click, type, read the clipboard, list and
|
|
4028
|
+
* focus windows. The bot runs in a container with no desktop of its own, so the installer — which
|
|
4029
|
+
* already runs on the operator's machine and already opens apps for it — performs them.
|
|
4030
|
+
*
|
|
4031
|
+
* No native modules: the approach follows the dependency-free tools (and Anthropic's own
|
|
4032
|
+
* computer-use reference, which drives xdotool + a screenshot binary):
|
|
4033
|
+
* Windows a version-stamped PowerShell helper (user32 P/Invoke, SendKeys, System.Drawing)
|
|
4034
|
+
* macOS screencapture + osascript/System Events + pbcopy/pbpaste
|
|
4035
|
+
* Linux xdotool + scrot|import|gnome-screenshot|spectacle + xclip|wl-copy
|
|
4036
|
+
* Whatever the OS, the bot sends the same JSON and gets the same shape back, so its instructions
|
|
4037
|
+
* do not fork per platform.
|
|
4038
|
+
*
|
|
4039
|
+
* Windows note: input injection and screen capture need a real desktop session. When the installer
|
|
4040
|
+
* itself was started over SSH there is none, and the capture fails — the error says so instead of
|
|
4041
|
+
* leaking a raw Win32Exception.
|
|
4042
|
+
*/
|
|
4043
|
+
const HOST_UI_ACTIONS = new Set([
|
|
4044
|
+
'screenshot', 'screen_size', 'mouse_move', 'click', 'drag', 'scroll',
|
|
4045
|
+
'type', 'key', 'clipboard_get', 'clipboard_set', 'windows', 'focus',
|
|
4046
|
+
]);
|
|
4047
|
+
|
|
4048
|
+
function hostUiScriptPath(projectDir) {
|
|
4049
|
+
return join(projectDir, '.openclaw', 'host-ui.ps1');
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
async function ensureHostUiScript(projectDir) {
|
|
4053
|
+
const path = hostUiScriptPath(projectDir);
|
|
4054
|
+
const stamp = `# OpenClaw host UI helper — version ${HOST_UI_PS1_VERSION}`;
|
|
4055
|
+
try {
|
|
4056
|
+
if (existsSync(path) && (await fsp.readFile(path, 'utf8')).startsWith(stamp)) return path;
|
|
4057
|
+
} catch (_) {}
|
|
4058
|
+
await fsp.mkdir(dirname(path), { recursive: true }).catch(() => {});
|
|
4059
|
+
await fsp.writeFile(path, HOST_UI_PS1, 'utf8');
|
|
4060
|
+
return path;
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
function firstExistingCommand(names) {
|
|
4064
|
+
for (const name of names) {
|
|
4065
|
+
const bin = whichSync(name);
|
|
4066
|
+
if (bin) return { name, bin };
|
|
4067
|
+
}
|
|
4068
|
+
return null;
|
|
4069
|
+
}
|
|
4070
|
+
|
|
4071
|
+
async function hostUiScreenshotTarget(projectDir) {
|
|
4072
|
+
const dir = join(projectDir, '.openclaw', 'media', 'host-ui');
|
|
4073
|
+
await fsp.mkdir(dir, { recursive: true }).catch(() => {});
|
|
4074
|
+
// Keep the folder from growing forever: the bot takes a lot of these.
|
|
4075
|
+
try {
|
|
4076
|
+
const files = (await fsp.readdir(dir)).filter((f) => f.endsWith('.png')).sort();
|
|
4077
|
+
for (const stale of files.slice(0, Math.max(0, files.length - 20))) {
|
|
4078
|
+
await fsp.rm(join(dir, stale), { force: true }).catch(() => {});
|
|
4079
|
+
}
|
|
4080
|
+
} catch (_) {}
|
|
4081
|
+
const name = `shot-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
|
4082
|
+
return { hostPath: join(dir, name), containerPath: `/home/node/project/.openclaw/media/host-ui/${name}` };
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4085
|
+
async function runHostUiWindows(projectDir, action, body, shot) {
|
|
4086
|
+
const script = await ensureHostUiScript(projectDir);
|
|
4087
|
+
const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Action', action];
|
|
4088
|
+
const push = (flag, value) => { if (value !== undefined && value !== null && value !== '') args.push(flag, String(value)); };
|
|
4089
|
+
push('-X', body.x);
|
|
4090
|
+
push('-Y', body.y);
|
|
4091
|
+
push('-ToX', body.toX);
|
|
4092
|
+
push('-ToY', body.toY);
|
|
4093
|
+
push('-Amount', body.amount);
|
|
4094
|
+
push('-Text', body.text);
|
|
4095
|
+
push('-Button', body.button);
|
|
4096
|
+
push('-Clicks', body.clicks);
|
|
4097
|
+
push('-Title', body.title);
|
|
4098
|
+
if (shot) push('-Path', shot.hostPath);
|
|
4099
|
+
const r = await runCapture('powershell', args, { shell: false, timeout: 30000 });
|
|
4100
|
+
const parsed = parseJsonText(String(r.stdout || '').trim(), null);
|
|
4101
|
+
if (parsed) return parsed;
|
|
4102
|
+
const err = String(r.stderr || r.stdout || '').trim();
|
|
4103
|
+
if (/Win32Exception|CopyFromScreen|handle is invalid/i.test(err)) {
|
|
4104
|
+
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.' };
|
|
4105
|
+
}
|
|
4106
|
+
return { ok: false, error: err.split('\n')[0] || `powershell exited ${r.code}` };
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
async function runHostUiMac(action, body, shot) {
|
|
4110
|
+
const osa = (script) => runCapture('osascript', ['-e', script], { shell: false, timeout: 20000 });
|
|
4111
|
+
const point = () => `{${Number(body.x) || 0}, ${Number(body.y) || 0}}`;
|
|
4112
|
+
switch (action) {
|
|
4113
|
+
case 'screenshot': {
|
|
4114
|
+
const r = await runCapture('screencapture', ['-x', shot.hostPath], { shell: false, timeout: 20000 });
|
|
4115
|
+
return r.code === 0 ? { ok: true, path: shot.hostPath } : { ok: false, error: String(r.stderr || 'screencapture failed').trim() };
|
|
4116
|
+
}
|
|
4117
|
+
case 'screen_size': {
|
|
4118
|
+
const r = await osa('tell application "Finder" to get bounds of window of desktop');
|
|
4119
|
+
const nums = String(r.stdout || '').trim().split(/\s*,\s*/).map(Number);
|
|
4120
|
+
return nums.length === 4 ? { ok: true, width: nums[2], height: nums[3] } : { ok: false, error: 'could not read screen bounds' };
|
|
4121
|
+
}
|
|
4122
|
+
case 'mouse_move':
|
|
4123
|
+
case 'click': {
|
|
4124
|
+
// System Events can click at a point; a plain move has no equivalent, so a move is a click
|
|
4125
|
+
// target set-up only. Accessibility permission is required (System Settings → Privacy).
|
|
4126
|
+
const clicks = Math.max(1, Number(body.clicks) || 1);
|
|
4127
|
+
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 };
|
|
4128
|
+
for (let i = 0; i < clicks; i++) {
|
|
4129
|
+
const r = await osa(`tell application "System Events" to click at ${point()}`);
|
|
4130
|
+
if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() || 'click failed (grant Accessibility permission)' };
|
|
4131
|
+
}
|
|
4132
|
+
return { ok: true, button: 'left', clicks };
|
|
4133
|
+
}
|
|
4134
|
+
case 'type': {
|
|
4135
|
+
const text = String(body.text || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
4136
|
+
const r = await osa(`tell application "System Events" to keystroke "${text}"`);
|
|
4137
|
+
return r.code === 0 ? { ok: true, typed: String(body.text || '').length } : { ok: false, error: String(r.stderr || '').trim() };
|
|
4138
|
+
}
|
|
4139
|
+
case 'key': {
|
|
4140
|
+
const map = { enter: 'return', esc: 'escape', pageup: 'page up', pagedown: 'page down' };
|
|
4141
|
+
for (const combo of String(body.text || '').split(/\s+/).filter(Boolean)) {
|
|
4142
|
+
const parts = combo.toLowerCase().split('+').map((p) => p.trim()).filter(Boolean);
|
|
4143
|
+
const key = map[parts[parts.length - 1]] || parts[parts.length - 1];
|
|
4144
|
+
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);
|
|
4145
|
+
const using = mods.length ? ` using {${mods.join(', ')}}` : '';
|
|
4146
|
+
const named = ['return', 'escape', 'tab', 'space', 'delete', 'up', 'down', 'left', 'right', 'home', 'end', 'page up', 'page down'];
|
|
4147
|
+
const script = named.includes(key)
|
|
4148
|
+
? `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}`
|
|
4149
|
+
: `tell application "System Events" to keystroke "${key}"${using}`;
|
|
4150
|
+
const r = await osa(script);
|
|
4151
|
+
if (r.code !== 0) return { ok: false, error: String(r.stderr || '').trim() };
|
|
4152
|
+
}
|
|
4153
|
+
return { ok: true, keys: body.text };
|
|
4154
|
+
}
|
|
4155
|
+
case 'scroll': {
|
|
4156
|
+
const amount = Number(body.amount) || 3;
|
|
4157
|
+
const dir = amount < 0 ? 121 : 116; // page down / page up
|
|
4158
|
+
for (let i = 0; i < Math.abs(amount); i++) await osa(`tell application "System Events" to key code ${dir}`);
|
|
4159
|
+
return { ok: true, amount };
|
|
4160
|
+
}
|
|
4161
|
+
case 'clipboard_get': {
|
|
4162
|
+
const r = await runCapture('pbpaste', [], { shell: false, timeout: 10000 });
|
|
4163
|
+
return { ok: true, text: String(r.stdout || '') };
|
|
4164
|
+
}
|
|
4165
|
+
case 'clipboard_set': {
|
|
4166
|
+
const r = await runCapture('sh', ['-c', 'pbcopy'], { shell: false, timeout: 10000, input: String(body.text || '') });
|
|
4167
|
+
return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: 'pbcopy failed' };
|
|
4168
|
+
}
|
|
4169
|
+
case 'windows': {
|
|
4170
|
+
const r = await osa('tell application "System Events" to get name of every process whose background only is false');
|
|
4171
|
+
const list = String(r.stdout || '').trim().split(/\s*,\s*/).filter(Boolean).map((title) => ({ title, process: title }));
|
|
4172
|
+
return { ok: true, windows: list };
|
|
4173
|
+
}
|
|
4174
|
+
case 'focus': {
|
|
4175
|
+
const title = String(body.title || '').replace(/"/g, '');
|
|
4176
|
+
if (!title) return { ok: false, error: 'focus needs a title' };
|
|
4177
|
+
const r = await osa(`tell application "${title}" to activate`);
|
|
4178
|
+
return r.code === 0 ? { ok: true, focused: title } : { ok: false, error: String(r.stderr || '').trim() || `no app named ${title}` };
|
|
4179
|
+
}
|
|
4180
|
+
default:
|
|
4181
|
+
return { ok: false, error: `unsupported on macOS: ${action}` };
|
|
4182
|
+
}
|
|
4183
|
+
}
|
|
4184
|
+
|
|
4185
|
+
async function runHostUiLinux(action, body, shot) {
|
|
4186
|
+
const xdo = whichSync('xdotool');
|
|
4187
|
+
const need = (bin, hint) => ({ ok: false, error: `${hint} needs ${bin}; install it (e.g. apt install ${bin})` });
|
|
4188
|
+
switch (action) {
|
|
4189
|
+
case 'screenshot': {
|
|
4190
|
+
const tool = firstExistingCommand(['gnome-screenshot', 'scrot', 'spectacle', 'import']);
|
|
4191
|
+
if (!tool) return need('scrot', 'screenshot');
|
|
4192
|
+
const argv = tool.name === 'gnome-screenshot' ? ['-f', shot.hostPath]
|
|
4193
|
+
: tool.name === 'spectacle' ? ['-b', '-n', '-o', shot.hostPath]
|
|
4194
|
+
: tool.name === 'import' ? ['-window', 'root', shot.hostPath]
|
|
4195
|
+
: [shot.hostPath];
|
|
4196
|
+
const r = await runCapture(tool.bin, argv, { shell: false, timeout: 20000 });
|
|
4197
|
+
return r.code === 0 ? { ok: true, path: shot.hostPath, tool: tool.name } : { ok: false, error: String(r.stderr || 'capture failed').trim() };
|
|
4198
|
+
}
|
|
4199
|
+
case 'screen_size': {
|
|
4200
|
+
if (!xdo) return need('xdotool', 'screen_size');
|
|
4201
|
+
const r = await runCapture(xdo, ['getdisplaygeometry'], { shell: false, timeout: 10000 });
|
|
4202
|
+
const [w, h] = String(r.stdout || '').trim().split(/\s+/).map(Number);
|
|
4203
|
+
return w && h ? { ok: true, width: w, height: h } : { ok: false, error: 'could not read display geometry' };
|
|
4204
|
+
}
|
|
4205
|
+
case 'mouse_move':
|
|
4206
|
+
case 'click':
|
|
4207
|
+
case 'drag':
|
|
4208
|
+
case 'scroll':
|
|
4209
|
+
case 'type':
|
|
4210
|
+
case 'key':
|
|
4211
|
+
case 'windows':
|
|
4212
|
+
case 'focus': {
|
|
4213
|
+
if (!xdo) return need('xdotool', action);
|
|
4214
|
+
const button = { left: 1, middle: 2, right: 3 }[String(body.button || 'left')] || 1;
|
|
4215
|
+
const argvFor = {
|
|
4216
|
+
mouse_move: ['mousemove', String(body.x ?? 0), String(body.y ?? 0)],
|
|
4217
|
+
click: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'click', '--repeat', String(Math.max(1, Number(body.clicks) || 1)), String(button)],
|
|
4218
|
+
drag: ['mousemove', String(body.x ?? 0), String(body.y ?? 0), 'mousedown', '1', 'mousemove', String(body.toX ?? 0), String(body.toY ?? 0), 'mouseup', '1'],
|
|
4219
|
+
scroll: ['click', '--repeat', String(Math.max(1, Math.abs(Number(body.amount) || 3))), (Number(body.amount) || 3) < 0 ? '5' : '4'],
|
|
4220
|
+
type: ['type', '--delay', '12', '--', String(body.text || '')],
|
|
4221
|
+
key: ['key', ...String(body.text || '').split(/\s+/).filter(Boolean)],
|
|
4222
|
+
windows: ['search', '--onlyvisible', '--name', '.'],
|
|
4223
|
+
focus: ['search', '--onlyvisible', '--name', String(body.title || ''), 'windowactivate'],
|
|
4224
|
+
}[action];
|
|
4225
|
+
const r = await runCapture(xdo, argvFor, { shell: false, timeout: 20000 });
|
|
4226
|
+
if (action === 'windows') {
|
|
4227
|
+
const ids = String(r.stdout || '').trim().split(/\s+/).filter(Boolean).slice(0, 40);
|
|
4228
|
+
const titles = [];
|
|
4229
|
+
for (const id of ids) {
|
|
4230
|
+
const t = await runCapture(xdo, ['getwindowname', id], { shell: false, timeout: 5000 });
|
|
4231
|
+
const title = String(t.stdout || '').trim();
|
|
4232
|
+
if (title) titles.push({ title, id });
|
|
4233
|
+
}
|
|
4234
|
+
return { ok: true, windows: titles };
|
|
4235
|
+
}
|
|
4236
|
+
return r.code === 0 ? { ok: true, action } : { ok: false, error: String(r.stderr || '').trim() || `xdotool exited ${r.code}` };
|
|
4237
|
+
}
|
|
4238
|
+
case 'clipboard_get': {
|
|
4239
|
+
const tool = firstExistingCommand(['wl-paste', 'xclip', 'xsel']);
|
|
4240
|
+
if (!tool) return need('xclip', 'clipboard_get');
|
|
4241
|
+
const argv = tool.name === 'xclip' ? ['-o', '-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-o'] : [];
|
|
4242
|
+
const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000 });
|
|
4243
|
+
return { ok: true, text: String(r.stdout || '') };
|
|
4244
|
+
}
|
|
4245
|
+
case 'clipboard_set': {
|
|
4246
|
+
const tool = firstExistingCommand(['wl-copy', 'xclip', 'xsel']);
|
|
4247
|
+
if (!tool) return need('xclip', 'clipboard_set');
|
|
4248
|
+
const argv = tool.name === 'xclip' ? ['-selection', 'clipboard'] : tool.name === 'xsel' ? ['-b', '-i'] : [];
|
|
4249
|
+
const r = await runCapture(tool.bin, argv, { shell: false, timeout: 10000, input: String(body.text || '') });
|
|
4250
|
+
return r.code === 0 ? { ok: true, length: String(body.text || '').length } : { ok: false, error: `${tool.name} failed` };
|
|
4251
|
+
}
|
|
4252
|
+
default:
|
|
4253
|
+
return { ok: false, error: `unsupported on Linux: ${action}` };
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4257
|
+
async function runHostUi(projectDir, body = {}) {
|
|
4258
|
+
const action = String(body.action || '').trim();
|
|
4259
|
+
if (!HOST_UI_ACTIONS.has(action)) {
|
|
4260
|
+
return { status: 400, payload: { ok: false, error: `unknown action: ${action || '(none)'}`, actions: [...HOST_UI_ACTIONS] } };
|
|
4261
|
+
}
|
|
4262
|
+
const shot = action === 'screenshot' ? await hostUiScreenshotTarget(projectDir) : null;
|
|
4263
|
+
let result;
|
|
4264
|
+
try {
|
|
4265
|
+
if (process.platform === 'win32') result = await runHostUiWindows(projectDir, action, body, shot);
|
|
4266
|
+
else if (process.platform === 'darwin') result = await runHostUiMac(action, body, shot);
|
|
4267
|
+
else result = await runHostUiLinux(action, body, shot);
|
|
4268
|
+
} catch (err) {
|
|
4269
|
+
result = { ok: false, error: err.message };
|
|
4270
|
+
}
|
|
4271
|
+
if (shot && result?.ok) {
|
|
4272
|
+
// The project folder is bind-mounted into the container, so hand back the path the bot can
|
|
4273
|
+
// actually open — otherwise it gets a Windows path it cannot read and reports failure.
|
|
4274
|
+
result.path = shot.hostPath;
|
|
4275
|
+
result.containerPath = shot.containerPath;
|
|
4276
|
+
result.bytes = existsSync(shot.hostPath) ? (await fsp.stat(shot.hostPath)).size : 0;
|
|
4277
|
+
}
|
|
4278
|
+
sendLog(`[host-control] UI "${action}" → ${result?.ok ? 'ok' : `lỗi: ${result?.error || 'unknown'}`}`);
|
|
4279
|
+
return { status: result?.ok ? 200 : 500, payload: result };
|
|
4280
|
+
}
|
|
4281
|
+
|
|
3642
4282
|
async function handleHostControl(req, res, projectDir) {
|
|
3643
4283
|
const cfg = await readHostControlConfig(projectDir);
|
|
3644
4284
|
const url = new URL(req.url, 'http://localhost');
|
|
@@ -3656,6 +4296,16 @@ async function handleHostControl(req, res, projectDir) {
|
|
|
3656
4296
|
if (url.pathname === '/api/host/apps' && req.method === 'GET') {
|
|
3657
4297
|
return json(res, { ok: true, apps: Object.keys(cfg.apps || {}), commands: Object.keys(cfg.commands || {}), platform: process.platform });
|
|
3658
4298
|
}
|
|
4299
|
+
if (url.pathname === '/api/host/ui' && req.method === 'POST') {
|
|
4300
|
+
// Part of PC control, but its own switch: seeing the screen and moving the pointer is a bigger
|
|
4301
|
+
// step than opening an app, so it only answers once the operator has granted capabilities.
|
|
4302
|
+
if (cfg.ui !== true) {
|
|
4303
|
+
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);
|
|
4304
|
+
}
|
|
4305
|
+
const body = await readJson(req).catch(() => ({}));
|
|
4306
|
+
const { status, payload } = await runHostUi(projectDir, body || {});
|
|
4307
|
+
return json(res, payload, status);
|
|
4308
|
+
}
|
|
3659
4309
|
if (url.pathname === '/api/host/exec' && req.method === 'POST') {
|
|
3660
4310
|
const body = await readJson(req).catch(() => ({}));
|
|
3661
4311
|
const name = String(body.command || '').trim().toLowerCase();
|
|
@@ -3723,6 +4373,39 @@ async function writeHostControlAccess(projectDir, cfg) {
|
|
|
3723
4373
|
'',
|
|
3724
4374
|
`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
4375
|
] : [];
|
|
4376
|
+
// Desktop actions: one endpoint, same JSON on every OS, so the bot does not need per-platform
|
|
4377
|
+
// instructions. Screenshots land in the project folder, which the container already sees.
|
|
4378
|
+
const uiBlock = cfg.ui === true ? [
|
|
4379
|
+
'',
|
|
4380
|
+
'### Thao tác trên màn hình chủ',
|
|
4381
|
+
'',
|
|
4382
|
+
'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** —',
|
|
4383
|
+
'đừng đoán vị trí. Toạ độ tính bằng pixel màn hình, gốc ở góc trên-trái.',
|
|
4384
|
+
'',
|
|
4385
|
+
'```sh',
|
|
4386
|
+
`curl -s -X POST ${base}/api/host/ui -H "x-openclaw-token: ${cfg.token}" \\`,
|
|
4387
|
+
' -H "content-type: application/json" -d \'{"action":"screenshot"}\'',
|
|
4388
|
+
'```',
|
|
4389
|
+
'',
|
|
4390
|
+
'Trả về `containerPath` — **đọc/gửi ảnh bằng đường dẫn đó** (nằm trong project nên bạn thấy được),',
|
|
4391
|
+
'kèm `width`/`height` để biết màn hình bao lớn.',
|
|
4392
|
+
'',
|
|
4393
|
+
'Các action khác (cùng dạng `{"action":...}`):',
|
|
4394
|
+
'',
|
|
4395
|
+
'- `screen_size` — kích thước màn hình',
|
|
4396
|
+
'- `mouse_move` + `x`,`y` — di chuột',
|
|
4397
|
+
'- `click` + `x`,`y`, tuỳ chọn `button` (`left`/`right`/`middle`) và `clicks` (2 = double-click)',
|
|
4398
|
+
'- `drag` + `x`,`y`,`toX`,`toY` — kéo thả',
|
|
4399
|
+
'- `scroll` + `amount` (âm = xuống), tuỳ chọn `x`,`y`',
|
|
4400
|
+
'- `type` + `text` — gõ chữ vào cửa sổ đang focus',
|
|
4401
|
+
'- `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',
|
|
4402
|
+
'- `clipboard_get` / `clipboard_set` + `text` — đọc/ghi clipboard',
|
|
4403
|
+
'- `windows` — liệt kê cửa sổ đang mở; `focus` + `title` — đưa cửa sổ lên trước',
|
|
4404
|
+
'',
|
|
4405
|
+
'Nếu trả về lỗi "no desktop session available" thì installer đang chạy ngoài phiên desktop —',
|
|
4406
|
+
'nói chủ mở lại installer trong máy, đừng thử cách khác.',
|
|
4407
|
+
'Trên Linux, thiếu `xdotool`/`scrot` thì endpoint nói rõ cần cài gì — báo lại cho chủ.',
|
|
4408
|
+
] : [];
|
|
3726
4409
|
// Screen capture / recording — only advertised when the operator granted the matching tool, so
|
|
3727
4410
|
// the bot never tries a binary that is not on this machine's allow-list.
|
|
3728
4411
|
// Windows has no capture binary to allow-list (PowerShell does it inline), so the section shows
|
|
@@ -3829,6 +4512,7 @@ async function writeHostControlAccess(projectDir, cfg) {
|
|
|
3829
4512
|
'',
|
|
3830
4513
|
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
4514
|
...execBlock,
|
|
4515
|
+
...uiBlock,
|
|
3832
4516
|
// Docker only: a screenshot taken on the host lands on the HOST filesystem, which this
|
|
3833
4517
|
// container cannot read — say so instead of letting the bot hunt for a missing file.
|
|
3834
4518
|
...(hasCapture ? [
|
|
@@ -4156,13 +4840,18 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
|
|
|
4156
4840
|
state.os = osChoice;
|
|
4157
4841
|
state.startedAt = new Date().toISOString();
|
|
4158
4842
|
try {
|
|
4159
|
-
// Native
|
|
4160
|
-
//
|
|
4843
|
+
// Native binds the host directly, so it needs ports nothing else holds — but only when something
|
|
4844
|
+
// actually holds them. Ask the host rather than assuming: a fresh machine keeps openclaw's and
|
|
4845
|
+
// 9router's real defaults, and a machine that already runs a docker project (or an SSH tunnel to
|
|
4846
|
+
// a remote bot) steps to the next free pair instead.
|
|
4161
4847
|
if (mode === 'native') {
|
|
4162
|
-
|
|
4163
|
-
|
|
4848
|
+
gatewayPort = await findFreeHostPort(gatewayPort, { reserveNext: true });
|
|
4849
|
+
routerPort = await findFreeHostPort(routerPort);
|
|
4164
4850
|
state.gatewayPort = gatewayPort;
|
|
4165
4851
|
state.routerPort = routerPort;
|
|
4852
|
+
state.gatewayUrl = `http://127.0.0.1:${gatewayPort}`;
|
|
4853
|
+
state.routerUrl = `http://127.0.0.1:${routerPort}`;
|
|
4854
|
+
sendLog(`[native] ports: gateway ${gatewayPort}, 9router ${routerPort}`);
|
|
4166
4855
|
}
|
|
4167
4856
|
sendLog('OpenClaw local installer started');
|
|
4168
4857
|
sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
|
|
@@ -6084,4 +6773,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
6084
6773
|
]).catch(() => {});
|
|
6085
6774
|
}
|
|
6086
6775
|
|
|
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 };
|
|
6776
|
+
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, syncNativeServiceEnv, adoptStrayNativeHome };
|