create-openclaw-bot 5.14.1 → 5.15.1
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 +11 -3
- package/README.vi.md +11 -3
- package/dist/server/local-server.js +1712 -100
- package/dist/setup/shared/bot-config-gen.js +12 -0
- package/dist/setup/shared/docker-gen.js +74 -1
- package/dist/web/app.js +139 -10
- package/dist/web/styles.css +20 -1
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ import fs, { createReadStream, existsSync, promises as fsp } from 'fs';
|
|
|
3
3
|
import { createRequire } from 'module';
|
|
4
4
|
import { basename, dirname, extname, join, normalize, resolve } from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
-
import { spawn, execFile } from 'child_process';
|
|
6
|
+
import { spawn, execFile, execFileSync } from 'child_process';
|
|
7
7
|
import os from 'os';
|
|
8
8
|
import net from 'net';
|
|
9
9
|
import { DatabaseSync } from 'node:sqlite';
|
|
@@ -19,6 +19,23 @@ const { buildDockerArtifacts } = loadSharedModule('../setup/shared/docker-gen.js
|
|
|
19
19
|
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
20
|
const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
|
|
21
21
|
|
|
22
|
+
// Chrome 136+ ignores --remote-debugging-port when --user-data-dir is the default profile
|
|
23
|
+
// directory, so every launch path here (the dashboard button and the generated start-chrome
|
|
24
|
+
// scripts) runs a dedicated profile seeded from the operator's real one. Kept outside Chrome's
|
|
25
|
+
// own folders: the block is an exact match on the default directory, and a sibling of it is a
|
|
26
|
+
// needless bet on that staying true.
|
|
27
|
+
const CHROME_SCRIPT_MARKER = 'OPENCLAW_CHROME_PROFILE_V2';
|
|
28
|
+
const CHROME_DEBUG_PROFILE_LEAF_WIN = 'OpenClaw\\chrome-profile';
|
|
29
|
+
const CHROME_DEBUG_PROFILE_LEAF_MAC = 'Library/Application Support/OpenClaw/chrome-profile';
|
|
30
|
+
const CHROME_DEBUG_PROFILE_LEAF_LINUX = '.config/openclaw/chrome-profile';
|
|
31
|
+
// Bulk that a fresh profile rebuilds on its own; skipping it turns a multi-GB copy into a
|
|
32
|
+
// few hundred MB.
|
|
33
|
+
const CHROME_PROFILE_CACHE_DIRS = [
|
|
34
|
+
'Cache', 'Code Cache', 'GPUCache', 'GrShaderCache', 'ShaderCache', 'DawnCache',
|
|
35
|
+
'DawnGraphiteCache', 'DawnWebGPUCache', 'Service Worker', 'component_crx_cache',
|
|
36
|
+
'extensions_crx_cache', 'optimization_guide_model_store', 'blob_storage',
|
|
37
|
+
];
|
|
38
|
+
|
|
22
39
|
async function syncExecApprovals(projectDir, cfg) {
|
|
23
40
|
const openclawHome = join(projectDir, '.openclaw');
|
|
24
41
|
const agentMetas = (cfg.agents?.list || []).map((a) => ({ agentId: a.id }));
|
|
@@ -44,6 +61,49 @@ async function syncExecApprovals(projectDir, cfg) {
|
|
|
44
61
|
await fsp.writeFile(path2, JSON.stringify(approvals, null, 2), 'utf8');
|
|
45
62
|
}
|
|
46
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Write files into the bot container's plugin folder. Needed when `.openclaw/extensions` is a
|
|
66
|
+
* Docker named volume: the host sees an empty directory, so there is nothing on disk to patch.
|
|
67
|
+
* Best-effort — a stopped container or a project without Docker just yields 0.
|
|
68
|
+
*/
|
|
69
|
+
async function pushBrowserScriptsIntoContainer(projectDir, aliases, files, sendLog = () => {}) {
|
|
70
|
+
if (isNativeProject(projectDir)) return 0;
|
|
71
|
+
const container = getBotContainerName(projectDir);
|
|
72
|
+
if (!container) return 0;
|
|
73
|
+
const running = await runCapture('docker', ['inspect', '-f', '{{.State.Running}}', container], { shell: false, timeout: 8000 }).catch(() => null);
|
|
74
|
+
if (String(running?.stdout || '').trim() !== 'true') return 0;
|
|
75
|
+
|
|
76
|
+
const homeOut = await runCapture('docker', ['exec', container, 'sh', '-c', 'echo "${OPENCLAW_HOME:-/home/node/project/.openclaw}"'], { shell: false, timeout: 8000 }).catch(() => null);
|
|
77
|
+
const openclawHome = String(homeOut?.stdout || '').trim() || '/home/node/project/.openclaw';
|
|
78
|
+
|
|
79
|
+
let pushed = 0;
|
|
80
|
+
for (const alias of aliases) {
|
|
81
|
+
const dir = `${openclawHome}/extensions/${alias}`;
|
|
82
|
+
const check = await runCapture('docker', ['exec', container, 'sh', '-c', `[ -d "${dir}" ] && echo yes || echo no`], { shell: false, timeout: 8000 }).catch(() => null);
|
|
83
|
+
if (String(check?.stdout || '').trim() !== 'yes') continue;
|
|
84
|
+
for (const [name, content, mode] of files) {
|
|
85
|
+
const tmp = join(os.tmpdir(), `openclaw-${Date.now()}-${name}`);
|
|
86
|
+
try {
|
|
87
|
+
await fsp.writeFile(tmp, content, 'utf8');
|
|
88
|
+
// runCapture, not run: run() forces a shell on Windows, and the temp path goes through
|
|
89
|
+
// a home directory that usually has a space in it ("VT 2025") — cmd then splits it and
|
|
90
|
+
// docker cp fails with a usage error.
|
|
91
|
+
const cp = await runCapture('docker', ['cp', tmp, `${container}:${dir}/${name}`], { shell: false, timeout: 20000 });
|
|
92
|
+
if (cp.code !== 0) throw new Error(String(cp.stderr || cp.stdout || 'docker cp failed').trim());
|
|
93
|
+
// A world-writable file makes OpenClaw refuse to load the plugin, and a copy landing
|
|
94
|
+
// from a Windows host is exactly that.
|
|
95
|
+
await runCapture('docker', ['exec', container, 'sh', '-c', `chmod ${mode} "${dir}/${name}"`], { shell: false, timeout: 20000 });
|
|
96
|
+
pushed += 1;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
sendLog(`[browser] Could not push ${name} into ${container}: ${err.message}`);
|
|
99
|
+
} finally {
|
|
100
|
+
await fsp.rm(tmp, { force: true }).catch(() => {});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return pushed;
|
|
105
|
+
}
|
|
106
|
+
|
|
47
107
|
async function patchBrowserAutomationHostPreference(projectDir, aliases = [], sendLog = () => {}) {
|
|
48
108
|
const preferredCdpBlock = `const dns = require('dns').promises;
|
|
49
109
|
const DEFAULT_CDP_URLS = [
|
|
@@ -95,6 +155,150 @@ async function connectPreferredChrome() {
|
|
|
95
155
|
return next;
|
|
96
156
|
};
|
|
97
157
|
|
|
158
|
+
// The shipped script is replaced outright rather than tweaked: it launches Chrome against a
|
|
159
|
+
// throwaway profile under %TEMP%, which is the single clearest bot signal a site can read —
|
|
160
|
+
// no cookies, no logins, no history, no extensions, brand new on every run.
|
|
161
|
+
//
|
|
162
|
+
// The obvious fix, pointing --user-data-dir at the operator's real profile, is what earlier
|
|
163
|
+
// versions did and it stopped working: since Chrome 136 the browser silently refuses
|
|
164
|
+
// --remote-debugging-port when --user-data-dir IS the default profile directory. Chrome
|
|
165
|
+
// still opens, port 9222 never comes up, and the bot reports "Chrome debug not connected"
|
|
166
|
+
// no matter how many times the operator restarts it.
|
|
167
|
+
//
|
|
168
|
+
// So: a dedicated profile directory, seeded once from the real one. Cookies, logins,
|
|
169
|
+
// history and extensions come along (that was the point of using the real profile), the
|
|
170
|
+
// debug port is allowed because the directory is not the default one, and the operator's
|
|
171
|
+
// own Chrome can keep running next to it. Set OPENCLAW_CHROME_PROFILE_DIR to override —
|
|
172
|
+
// anything except the default profile directory works.
|
|
173
|
+
const chromeProfileCacheJunk = CHROME_PROFILE_CACHE_DIRS;
|
|
174
|
+
|
|
175
|
+
const startChromeBat = [
|
|
176
|
+
'@echo off',
|
|
177
|
+
`REM ${CHROME_SCRIPT_MARKER}`,
|
|
178
|
+
'echo ====== OpenClaw - Chrome ======',
|
|
179
|
+
'echo.',
|
|
180
|
+
'',
|
|
181
|
+
'set "REAL_PROFILE=%LOCALAPPDATA%\\Google\\Chrome\\User Data"',
|
|
182
|
+
'REM Chrome 136+ tu choi --remote-debugging-port khi user-data-dir la profile mac dinh,',
|
|
183
|
+
'REM nen dung mot thu muc rieng (chep tu profile that o lan chay dau).',
|
|
184
|
+
`if "%OPENCLAW_CHROME_PROFILE_DIR%"=="" set "OPENCLAW_CHROME_PROFILE_DIR=%LOCALAPPDATA%\\${CHROME_DEBUG_PROFILE_LEAF_WIN}"`,
|
|
185
|
+
'',
|
|
186
|
+
'set "CHROME_BIN=C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"',
|
|
187
|
+
'if not exist "%CHROME_BIN%" set "CHROME_BIN=C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"',
|
|
188
|
+
'if not exist "%CHROME_BIN%" set "CHROME_BIN=%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe"',
|
|
189
|
+
'if not exist "%CHROME_BIN%" (',
|
|
190
|
+
' echo LOI: Khong tim thay Google Chrome. Hay cai Chrome roi chay lai.',
|
|
191
|
+
' pause',
|
|
192
|
+
' exit /b 1',
|
|
193
|
+
')',
|
|
194
|
+
'',
|
|
195
|
+
'REM Chi dong ban Chrome dieu khien cu neu co - Chrome thuong cua ban van chay binh thuong,',
|
|
196
|
+
'REM vi ban dieu khien dung profile rieng.',
|
|
197
|
+
'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq \'chrome.exe\' -and $_.CommandLine -like \'*--remote-debugging-port=9222*\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }" >nul 2>&1',
|
|
198
|
+
// `timeout` dies with "Input redirection is not supported" whenever stdin is not a
|
|
199
|
+
// console — which is every run from the dashboard, a scheduled task or SSH. ping waits
|
|
200
|
+
// the same way and does not care.
|
|
201
|
+
'ping -n 3 127.0.0.1 >nul',
|
|
202
|
+
'',
|
|
203
|
+
'if not exist "%OPENCLAW_CHROME_PROFILE_DIR%\\Default" (',
|
|
204
|
+
// No parentheses in text inside an if-block: cmd closes the block on the first ")".
|
|
205
|
+
' echo Lan dau: dang dong Chrome de chep profile - cookie, dang nhap...',
|
|
206
|
+
' taskkill /F /IM chrome.exe >nul 2>&1',
|
|
207
|
+
' ping -n 4 127.0.0.1 >nul',
|
|
208
|
+
' echo Dang chep profile Chrome that sang "%OPENCLAW_CHROME_PROFILE_DIR%" ...',
|
|
209
|
+
' robocopy "%REAL_PROFILE%\\Default" "%OPENCLAW_CHROME_PROFILE_DIR%\\Default" /E /R:0 /W:0 /NFL /NDL /NJH /NJS /NP ^',
|
|
210
|
+
` /XD ${chromeProfileCacheJunk.map((n) => (n.includes(' ') ? `"${n}"` : n)).join(' ')} >nul`,
|
|
211
|
+
' copy /Y "%REAL_PROFILE%\\Local State" "%OPENCLAW_CHROME_PROFILE_DIR%\\Local State" >nul',
|
|
212
|
+
')',
|
|
213
|
+
'',
|
|
214
|
+
'echo Dang mo Chrome - profile: %OPENCLAW_CHROME_PROFILE_DIR%',
|
|
215
|
+
'start "" "%CHROME_BIN%" ^',
|
|
216
|
+
' --remote-debugging-port=9222 ^',
|
|
217
|
+
' --remote-allow-origins=* ^',
|
|
218
|
+
' --user-data-dir="%OPENCLAW_CHROME_PROFILE_DIR%" ^',
|
|
219
|
+
' --profile-directory=Default ^',
|
|
220
|
+
' --no-first-run ^',
|
|
221
|
+
' --no-default-browser-check',
|
|
222
|
+
'ping -n 6 127.0.0.1 >nul',
|
|
223
|
+
'powershell -NoProfile -Command "try { Invoke-WebRequest -Uri \'http://localhost:9222/json/version\' -UseBasicParsing -TimeoutSec 5 | Out-Null; Write-Host \'OK! Chrome dang mo cong dieu khien 9222 - bot dung duoc.\' -ForegroundColor Green } catch { Write-Host \'LOI: Cong 9222 chua mo. Dong het cua so Chrome roi chay lai file nay.\' -ForegroundColor Red }"',
|
|
224
|
+
'echo.',
|
|
225
|
+
'pause',
|
|
226
|
+
'',
|
|
227
|
+
].join('\r\n');
|
|
228
|
+
|
|
229
|
+
const startChromeSh = [
|
|
230
|
+
'#!/usr/bin/env bash',
|
|
231
|
+
`# ${CHROME_SCRIPT_MARKER}`,
|
|
232
|
+
'# ====== OpenClaw - Chrome (Mac/Linux) ======',
|
|
233
|
+
'set -e',
|
|
234
|
+
'echo "====== OpenClaw - Chrome ======"',
|
|
235
|
+
'echo ""',
|
|
236
|
+
'',
|
|
237
|
+
'if [[ "$OSTYPE" == "darwin"* ]]; then',
|
|
238
|
+
' CHROME_BIN="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"',
|
|
239
|
+
' [ ! -f "$CHROME_BIN" ] && CHROME_BIN="/Applications/Chromium.app/Contents/MacOS/Chromium"',
|
|
240
|
+
' [ ! -f "$CHROME_BIN" ] && CHROME_BIN="/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"',
|
|
241
|
+
' REAL_PROFILE="$HOME/Library/Application Support/Google/Chrome"',
|
|
242
|
+
` DEFAULT_DEBUG_PROFILE="$HOME/${CHROME_DEBUG_PROFILE_LEAF_MAC}"`,
|
|
243
|
+
'else',
|
|
244
|
+
" CHROME_BIN=\"$(command -v google-chrome || command -v google-chrome-stable || command -v chromium-browser || command -v chromium || echo '')\"",
|
|
245
|
+
' REAL_PROFILE="$HOME/.config/google-chrome"',
|
|
246
|
+
` DEFAULT_DEBUG_PROFILE="$HOME/${CHROME_DEBUG_PROFILE_LEAF_LINUX}"`,
|
|
247
|
+
'fi',
|
|
248
|
+
'[ -n "$CHROME_DEBUG_BIN" ] && CHROME_BIN="$CHROME_DEBUG_BIN"',
|
|
249
|
+
'',
|
|
250
|
+
'if [ -z "$CHROME_BIN" ] || { [ ! -f "$CHROME_BIN" ] && [ ! -x "$CHROME_BIN" ]; }; then',
|
|
251
|
+
' echo -e "\\033[31mERROR: Chrome/Chromium not found.\\033[0m"',
|
|
252
|
+
' echo "Install Chrome or: export CHROME_DEBUG_BIN=/path/to/chrome"',
|
|
253
|
+
' exit 1',
|
|
254
|
+
'fi',
|
|
255
|
+
'',
|
|
256
|
+
'# Chrome 136+ refuses --remote-debugging-port on the default profile directory, so run a',
|
|
257
|
+
'# dedicated one seeded from the real profile (keeps cookies, logins, history, extensions).',
|
|
258
|
+
': "${OPENCLAW_CHROME_PROFILE_DIR:=$DEFAULT_DEBUG_PROFILE}"',
|
|
259
|
+
'',
|
|
260
|
+
'echo "Using: $CHROME_BIN"',
|
|
261
|
+
'echo "Killing existing Chrome debug instances..."',
|
|
262
|
+
'pkill -f -- "--remote-debugging-port=9222" 2>/dev/null || true',
|
|
263
|
+
'sleep 2',
|
|
264
|
+
'',
|
|
265
|
+
'if [ ! -d "$OPENCLAW_CHROME_PROFILE_DIR/Default" ] && [ -d "$REAL_PROFILE/Default" ]; then',
|
|
266
|
+
' echo "First run: copying the real Chrome profile into $OPENCLAW_CHROME_PROFILE_DIR ..."',
|
|
267
|
+
' mkdir -p "$OPENCLAW_CHROME_PROFILE_DIR/Default"',
|
|
268
|
+
' cp -R "$REAL_PROFILE/Default/." "$OPENCLAW_CHROME_PROFILE_DIR/Default/" 2>/dev/null || true',
|
|
269
|
+
' cp -f "$REAL_PROFILE/Local State" "$OPENCLAW_CHROME_PROFILE_DIR/Local State" 2>/dev/null || true',
|
|
270
|
+
` for junk in ${chromeProfileCacheJunk.map((n) => `"${n}"`).join(' ')}; do`,
|
|
271
|
+
' rm -rf "$OPENCLAW_CHROME_PROFILE_DIR/Default/$junk"',
|
|
272
|
+
' done',
|
|
273
|
+
'fi',
|
|
274
|
+
'mkdir -p "$OPENCLAW_CHROME_PROFILE_DIR"',
|
|
275
|
+
'',
|
|
276
|
+
'echo "Starting Chrome (profile: $OPENCLAW_CHROME_PROFILE_DIR)..."',
|
|
277
|
+
'"$CHROME_BIN" \\',
|
|
278
|
+
' --remote-debugging-port=9222 \\',
|
|
279
|
+
' --remote-allow-origins=* \\',
|
|
280
|
+
' --user-data-dir="$OPENCLAW_CHROME_PROFILE_DIR" \\',
|
|
281
|
+
' --profile-directory=Default \\',
|
|
282
|
+
' --no-first-run \\',
|
|
283
|
+
' --no-default-browser-check &',
|
|
284
|
+
'',
|
|
285
|
+
'sleep 4',
|
|
286
|
+
'if curl -s http://localhost:9222/json/version > /dev/null 2>&1; then',
|
|
287
|
+
' echo -e "\\033[32mOK! Chrome is listening on port 9222.\\033[0m"',
|
|
288
|
+
'else',
|
|
289
|
+
' echo -e "\\033[31mERROR: Port 9222 not responding. Quit every Chrome window and run this again.\\033[0m"',
|
|
290
|
+
' exit 1',
|
|
291
|
+
'fi',
|
|
292
|
+
'',
|
|
293
|
+
].join('\n');
|
|
294
|
+
|
|
295
|
+
// Scripts from before the dedicated-profile fix carry OPENCLAW_CHROME_PROFILE_DIR but point
|
|
296
|
+
// it at the default profile, so they are dead on Chrome 136+. The marker — not the variable
|
|
297
|
+
// name — decides whether a script is current; anything older is replaced.
|
|
298
|
+
const patchChromeDebugScript = (content, isBat) => (
|
|
299
|
+
content.includes(CHROME_SCRIPT_MARKER) ? content : (isBat ? startChromeBat : startChromeSh)
|
|
300
|
+
);
|
|
301
|
+
|
|
98
302
|
const browserToolCandidates = new Set();
|
|
99
303
|
const extensionDirs = [];
|
|
100
304
|
for (const alias of aliases) {
|
|
@@ -111,7 +315,9 @@ async function connectPreferredChrome() {
|
|
|
111
315
|
for (const a of cfg.agents?.list || []) {
|
|
112
316
|
const workspaceRel = a.workspace || cfg.agents?.defaults?.workspace;
|
|
113
317
|
if (!workspaceRel) continue;
|
|
114
|
-
const workspacePath = workspaceRel.startsWith('/')
|
|
318
|
+
const workspacePath = workspaceRel.startsWith('/')
|
|
319
|
+
? (resolve(workspaceRel).startsWith(resolve(projectDir)) ? workspaceRel : join(projectDir, workspaceRel.replace(/^\/home\/node\/project\/?/, '').replace(/^\/root\/project\/?/, '')))
|
|
320
|
+
: join(projectDir, workspaceRel);
|
|
115
321
|
workspaceDirs.add(workspacePath);
|
|
116
322
|
browserToolCandidates.add(join(workspacePath, 'plugin-skills', 'browser-automation', 'browser-tool.js'));
|
|
117
323
|
}
|
|
@@ -135,6 +341,41 @@ async function connectPreferredChrome() {
|
|
|
135
341
|
sendLog(`[browser] Patched ${patched} browser-tool.js file(s) to prefer host Chrome debug before headless Chromium.`);
|
|
136
342
|
}
|
|
137
343
|
|
|
344
|
+
// Patch the plugin's own copies too: the plugin re-syncs its skill folder into every
|
|
345
|
+
// workspace on startup, so an unpatched source would undo the profile change below.
|
|
346
|
+
const sourceScriptNames = ['start-chrome-debug.bat', 'start-chrome-debug.sh'];
|
|
347
|
+
let scriptsPatched = 0;
|
|
348
|
+
for (const dir of extensionDirs) {
|
|
349
|
+
for (const name of sourceScriptNames) {
|
|
350
|
+
const file = join(dir, name);
|
|
351
|
+
if (!existsSync(file)) continue;
|
|
352
|
+
const content = await fsp.readFile(file, 'utf8');
|
|
353
|
+
const next = patchChromeDebugScript(content, name.endsWith('.bat'));
|
|
354
|
+
if (next !== content) {
|
|
355
|
+
await fsp.writeFile(file, next, 'utf8');
|
|
356
|
+
scriptsPatched += 1;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (scriptsPatched > 0) {
|
|
361
|
+
sendLog(`[browser] Patched ${scriptsPatched} start-chrome script(s) to use a dedicated Chrome profile seeded from yours (set OPENCLAW_CHROME_PROFILE_DIR to override).`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Some Docker projects mount `.openclaw/extensions` as a named volume, so the plugin's own
|
|
365
|
+
// files exist only inside the container. Every host path above then quietly finds nothing,
|
|
366
|
+
// the plugin re-syncs its stale script into the workspaces on each boot, and the operator
|
|
367
|
+
// keeps running the version that cannot open the debug port. Push the script in through the
|
|
368
|
+
// container instead.
|
|
369
|
+
if (!extensionDirs.some((dir) => existsSync(join(dir, sourceScriptNames[0])) || existsSync(join(dir, sourceScriptNames[1])))) {
|
|
370
|
+
const pushed = await pushBrowserScriptsIntoContainer(projectDir, aliases, [
|
|
371
|
+
['start-chrome-debug.bat', startChromeBat, '644'],
|
|
372
|
+
['start-chrome-debug.sh', startChromeSh, '755'],
|
|
373
|
+
], sendLog);
|
|
374
|
+
if (pushed > 0) {
|
|
375
|
+
sendLog(`[browser] Extensions live in a Docker volume — pushed ${pushed} start-chrome script(s) into the container so the plugin delivers the current one.`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
138
379
|
const browserMd = `# Browser Automation
|
|
139
380
|
|
|
140
381
|
This plugin skill owns browser automation only. For normal web search, use OpenClaw's built-in \`web_search\` capability.
|
|
@@ -148,10 +389,14 @@ Run commands from this folder or pass the full path from the workspace root:
|
|
|
148
389
|
|
|
149
390
|
On a desktop machine, start real Chrome in debug mode before asking the bot to browse:
|
|
150
391
|
|
|
151
|
-
- Windows: run \`start-chrome
|
|
152
|
-
- macOS/Linux: run \`./start-chrome
|
|
392
|
+
- Windows: run \`start-chrome.bat\`
|
|
393
|
+
- macOS/Linux: run \`./start-chrome.sh\`
|
|
394
|
+
|
|
395
|
+
Chrome launches with a profile copied from the operator's own on first run, so pages see a normal browser with its usual cookies, logins and history. (Chrome 136+ refuses the debug port on the default profile directory itself, hence the copy.) Set \`OPENCLAW_CHROME_PROFILE_DIR\` before running the script to use a different profile.
|
|
153
396
|
|
|
154
|
-
The tool
|
|
397
|
+
The tool connects to whichever Chrome answers first: the operator's Chrome on the host, then a local one on \`127.0.0.1:9222\`. On a server with no desktop Chrome, the container starts its own headless Chromium there at boot, so the same commands work everywhere.
|
|
398
|
+
|
|
399
|
+
**Use these commands, not OpenClaw's built-in \`browser\` tool** — that tool is switched off here because it cannot read page text or links, which is the whole reason this skill exists. If a command reports it cannot connect, the operator's Chrome is not running: ask them to run the debug script above. Do not conclude that the environment has no browser.
|
|
155
400
|
|
|
156
401
|
## Browser Commands
|
|
157
402
|
|
|
@@ -179,9 +424,11 @@ Do not call \`search-tool.js\`; browser-automation does not own search. Use \`we
|
|
|
179
424
|
|
|
180
425
|
const hostOs = normalizeHostOs(await resolveProjectHostOs(projectDir));
|
|
181
426
|
const shouldKeepBat = hostOs === 'win';
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
427
|
+
// Shipped as start-chrome-debug.* by the plugin; delivered to the workspace as
|
|
428
|
+
// start-chrome.* — it launches Chrome with the debug port open, so "debug" in the name only
|
|
429
|
+
// ever made people think it was a developer-only thing.
|
|
430
|
+
const scriptToKeep = shouldKeepBat ? 'start-chrome.bat' : 'start-chrome.sh';
|
|
431
|
+
const legacyScripts = ['start-chrome-debug.bat', 'start-chrome-debug.sh', shouldKeepBat ? 'start-chrome.sh' : 'start-chrome.bat'];
|
|
185
432
|
const sourceBrowserTool = extensionDirs.map((dir) => join(dir, 'browser-tool.js')).find((file) => existsSync(file));
|
|
186
433
|
|
|
187
434
|
let sanitized = 0;
|
|
@@ -192,9 +439,11 @@ Do not call \`search-tool.js\`; browser-automation does not own search. Use \`we
|
|
|
192
439
|
await fsp.rm(join(workspacePath, 'search-tool.js'), { force: true }).catch(() => {});
|
|
193
440
|
await fsp.rm(join(workspacePath, 'browser-tool.js'), { force: true }).catch(() => {});
|
|
194
441
|
await fsp.rm(join(workspacePath, 'BROWSER.md'), { force: true }).catch(() => {});
|
|
195
|
-
|
|
442
|
+
for (const legacy of legacyScripts) {
|
|
443
|
+
await fsp.rm(join(workspacePath, legacy), { force: true }).catch(() => {});
|
|
444
|
+
await fsp.rm(join(pluginSkillPath, legacy), { force: true }).catch(() => {});
|
|
445
|
+
}
|
|
196
446
|
await fsp.rm(join(workspacePath, scriptToKeep), { force: true }).catch(() => {});
|
|
197
|
-
await fsp.rm(join(pluginSkillPath, scriptToRemove), { force: true }).catch(() => {});
|
|
198
447
|
if (sourceBrowserTool) {
|
|
199
448
|
const targetBrowserTool = join(pluginSkillPath, 'browser-tool.js');
|
|
200
449
|
await fsp.copyFile(sourceBrowserTool, targetBrowserTool).catch(() => {});
|
|
@@ -204,10 +453,12 @@ Do not call \`search-tool.js\`; browser-automation does not own search. Use \`we
|
|
|
204
453
|
if (next !== content) await fsp.writeFile(targetBrowserTool, next, 'utf8');
|
|
205
454
|
}
|
|
206
455
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
456
|
+
// Written from the generator, not copied from the plugin: on a project whose extensions
|
|
457
|
+
// folder is a Docker volume there is no host copy to read, and the workspace would be left
|
|
458
|
+
// without a starter at all.
|
|
459
|
+
const targetScript = join(pluginSkillPath, scriptToKeep);
|
|
460
|
+
await fsp.writeFile(targetScript, scriptToKeep.endsWith('.bat') ? startChromeBat : startChromeSh, 'utf8');
|
|
461
|
+
if (scriptToKeep.endsWith('.sh')) await fsp.chmod(targetScript, 0o755).catch(() => {});
|
|
211
462
|
await fsp.writeFile(join(pluginSkillPath, 'BROWSER.md'), browserMd, 'utf8');
|
|
212
463
|
for (const dirName of ['cl-stealth-search', 'openclaw-smart-search']) {
|
|
213
464
|
await fsp.rm(join(workspacePath, 'plugin-skills', dirName), { recursive: true, force: true }).catch(() => {});
|
|
@@ -765,8 +1016,15 @@ async function syncRuntimeState(projectDir, { full = false } = {}) {
|
|
|
765
1016
|
await removeEmptyWorkspaceAttestations(projectDir).catch(() => {});
|
|
766
1017
|
const firstSync = full || !_runtimeSynced.has(projectDir);
|
|
767
1018
|
if (firstSync) {
|
|
768
|
-
|
|
769
|
-
|
|
1019
|
+
if (isNativeProject(projectDir)) {
|
|
1020
|
+
// Native has no container: rewrite any Docker/legacy container path
|
|
1021
|
+
// (/home/node/project, /root/project) to project-relative, or the gateway tries to
|
|
1022
|
+
// mkdir '/home/node' on the host and fails every turn (bot never replies).
|
|
1023
|
+
await migrateNativePaths(projectDir).catch(() => {});
|
|
1024
|
+
} else {
|
|
1025
|
+
// Auto-migrate legacy /root/project paths → /home/node/project in openclaw.json
|
|
1026
|
+
await migrateContainerPaths(projectDir).catch(() => {});
|
|
1027
|
+
}
|
|
770
1028
|
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
771
1029
|
}
|
|
772
1030
|
const rt = await detectRuntime(projectDir).catch(() => null);
|
|
@@ -799,6 +1057,43 @@ async function removeEmptyWorkspaceAttestations(projectDir) {
|
|
|
799
1057
|
return true;
|
|
800
1058
|
}
|
|
801
1059
|
|
|
1060
|
+
/**
|
|
1061
|
+
* Native counterpart of migrateContainerPaths. A native bot runs on the host with cwd = the
|
|
1062
|
+
* project dir, so any Docker/legacy container path baked into openclaw.json (e.g. an agent
|
|
1063
|
+
* `workspace` of "/home/node/project/.openclaw/workspace-x", left over from a bot created by an
|
|
1064
|
+
* older build or carried over from Docker) points at a directory that does not exist on the host —
|
|
1065
|
+
* the gateway then fails every turn with `ENOENT: mkdir '/home/node'` and the bot never replies.
|
|
1066
|
+
* Strip the container prefix so the path becomes project-relative (what bot-config-gen now emits).
|
|
1067
|
+
*/
|
|
1068
|
+
async function migrateNativePaths(projectDir) {
|
|
1069
|
+
const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
|
|
1070
|
+
if (!existsSync(cfgPath)) return;
|
|
1071
|
+
let cfg;
|
|
1072
|
+
try { cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8')); } catch { return; }
|
|
1073
|
+
// The native gateway runs with cwd = OPENCLAW_HOME (projectDir/.openclaw), while the setup
|
|
1074
|
+
// writes the workspace under projectDir/.openclaw/<name>. A relative value can't satisfy both:
|
|
1075
|
+
// ".openclaw/workspace-x" → runtime doubles it to .openclaw/.openclaw/workspace-x (blank persona)
|
|
1076
|
+
// "workspace-x" → setup's own resolver looks in projectDir/workspace-x
|
|
1077
|
+
// Only an ABSOLUTE host path is correct for both — the direct parallel of Docker's absolute
|
|
1078
|
+
// "/home/node/project/.openclaw/workspace-x". Normalise every agent's workspace to it.
|
|
1079
|
+
const wsRoot = join(projectDir, '.openclaw');
|
|
1080
|
+
let changed = false;
|
|
1081
|
+
const fix = (obj) => {
|
|
1082
|
+
if (!obj || typeof obj.workspace !== 'string' || !obj.workspace) return;
|
|
1083
|
+
const base = basename(obj.workspace.replace(/[\\/]+$/, ''));
|
|
1084
|
+
if (!base || base === '.' || base === '.openclaw') return;
|
|
1085
|
+
const abs = join(wsRoot, base);
|
|
1086
|
+
if (obj.workspace !== abs) { obj.workspace = abs; changed = true; }
|
|
1087
|
+
};
|
|
1088
|
+
for (const a of (cfg.agents?.list || [])) fix(a);
|
|
1089
|
+
fix(cfg.agents?.defaults);
|
|
1090
|
+
if (changed) {
|
|
1091
|
+
await fsp.copyFile(cfgPath, `${cfgPath}.bak`).catch(() => {});
|
|
1092
|
+
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
1093
|
+
sendLog('[migrate] Native: normalized agent workspace paths → absolute project paths (fixes doubled/container paths).');
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
802
1097
|
/**
|
|
803
1098
|
* Migrate legacy /root/project/ paths to /home/node/project/ in openclaw.json.
|
|
804
1099
|
* Old projects may have been created with /root/project/ which doesn't match the
|
|
@@ -1314,6 +1609,17 @@ async function deleteBotInProject(projectDir, agentId) {
|
|
|
1314
1609
|
}
|
|
1315
1610
|
if (cfg.channels?.telegram?.accounts?.[agentId]) delete cfg.channels.telegram.accounts[agentId];
|
|
1316
1611
|
|
|
1612
|
+
// Drop any channel orphaned by this deletion — no binding references it and it has no accounts
|
|
1613
|
+
// (e.g. a Telegram channel whose only bot was just removed). An enabled channel with no account
|
|
1614
|
+
// keeps erroring in `channels status` ("not configured") and shows a broken card.
|
|
1615
|
+
const stillReferenced = new Set((cfg.bindings || []).map((b) => b.match?.channel).filter(Boolean));
|
|
1616
|
+
for (const ch of new Set(removedBindings.map((b) => b.match?.channel).filter(Boolean))) {
|
|
1617
|
+
const chCfg = cfg.channels?.[ch];
|
|
1618
|
+
if (chCfg && !stillReferenced.has(ch) && Object.keys(chCfg.accounts || {}).length === 0) {
|
|
1619
|
+
delete cfg.channels[ch];
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1317
1623
|
if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
|
|
1318
1624
|
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
1319
1625
|
await syncExecApprovals(projectDir, cfg);
|
|
@@ -1385,7 +1691,10 @@ async function buildBotStatus() {
|
|
|
1385
1691
|
const cap = (s) => String(s).toLowerCase() === 'openai' ? 'OpenAI' : String(s).toLowerCase() === '9router' ? '9Router' : s;
|
|
1386
1692
|
activeProvider = cap(activeProvider);
|
|
1387
1693
|
|
|
1388
|
-
|
|
1694
|
+
// Resolved per project, not from the installer-wide state.mode: the operator can switch between
|
|
1695
|
+
// a docker project and a native one, and the UI hides/shows container-only actions on this.
|
|
1696
|
+
const deployMode = projectDeployMode(state.projectDir);
|
|
1697
|
+
return { ...state, deployMode, gatewayStatus, routerStatus, bots, credentials, runtimeVersions, activeModel, activeProvider };
|
|
1389
1698
|
}
|
|
1390
1699
|
|
|
1391
1700
|
async function createBotInProject(projectDir, body = {}, runtime = {}) {
|
|
@@ -1506,6 +1815,10 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
|
|
|
1506
1815
|
validateOpenclawConfig(cfg);
|
|
1507
1816
|
if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
|
|
1508
1817
|
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
1818
|
+
// Native gateway resolves a relative workspace against OPENCLAW_HOME (=projectDir/.openclaw),
|
|
1819
|
+
// so the generator's ".openclaw/workspace-x" would double. Rewrite to an absolute path now so
|
|
1820
|
+
// the bot reads its persona on the very first turn (not only after the next runtime sync).
|
|
1821
|
+
if (isNativeProject(projectDir)) await migrateNativePaths(projectDir).catch(() => {});
|
|
1509
1822
|
await syncExecApprovals(projectDir, cfg);
|
|
1510
1823
|
|
|
1511
1824
|
const hasScheduler = !!(cfg.tools?.alsoAllow || []).includes('group:automation');
|
|
@@ -1536,6 +1849,10 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
|
|
|
1536
1849
|
// the token); don't pollute other channels' bot-meta.json with an empty appId.
|
|
1537
1850
|
if (channel === 'fb-messenger') botMeta.appId = fbAppId;
|
|
1538
1851
|
await writeBotMeta(projectDir, workspaceDir, botMeta);
|
|
1852
|
+
// PC control is granted per PROJECT, so a bot added afterwards must get the same instructions —
|
|
1853
|
+
// its TOOLS.md was just written fresh and would otherwise have no host-control block at all.
|
|
1854
|
+
const hostCfg = await readHostControlConfig(projectDir).catch(() => null);
|
|
1855
|
+
if (hostCfg?.enabled) await writeHostControlAccess(projectDir, hostCfg).catch(() => {});
|
|
1539
1856
|
|
|
1540
1857
|
return { ok: true, agentId, accountId, channel, workspace: `.openclaw/${workspaceDir}`, warning };
|
|
1541
1858
|
}
|
|
@@ -1721,6 +2038,30 @@ async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 900
|
|
|
1721
2038
|
return ready;
|
|
1722
2039
|
}
|
|
1723
2040
|
|
|
2041
|
+
// Native equivalent of waitForGatewayZaloReady: no container to exec into, so probe the
|
|
2042
|
+
// gateway's /health over loopback and read `channels status` through the host CLI (ocCapture).
|
|
2043
|
+
async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, channelKeywords = ['zalo-connect', 'openclaw zalo connect']) {
|
|
2044
|
+
const started = Date.now();
|
|
2045
|
+
const meta = readNativeMeta(projectDir) || {};
|
|
2046
|
+
const port = String(meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
|
|
2047
|
+
let ready = false;
|
|
2048
|
+
let attempts = 0;
|
|
2049
|
+
while (Date.now() - started < timeoutMs) {
|
|
2050
|
+
attempts++;
|
|
2051
|
+
if (await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500)) {
|
|
2052
|
+
const st = await ocCapture(projectDir, ['channels', 'status']).catch(() => ({ stdout: '', stderr: '' }));
|
|
2053
|
+
const output = ((st.stdout || '') + ' ' + (st.stderr || '')).toLowerCase();
|
|
2054
|
+
if (channelKeywords.some((kw) => output.includes(kw))) { ready = true; break; }
|
|
2055
|
+
if (attempts > 2) sendLog('[zalo-connect] Gateway healthy but Zalo Connect is not loaded yet (' + Math.round((Date.now() - started) / 1000) + 's)...');
|
|
2056
|
+
} else if (attempts > 2 && attempts % 3 === 0) {
|
|
2057
|
+
sendLog('[zalo-connect] Waiting for native gateway... (' + Math.round((Date.now() - started) / 1000) + 's)');
|
|
2058
|
+
}
|
|
2059
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
2060
|
+
}
|
|
2061
|
+
if (!ready) sendLog('[zalo-connect] Native gateway readiness timeout after ' + Math.round(timeoutMs / 1000) + 's — proceeding anyway.');
|
|
2062
|
+
return ready;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
1724
2065
|
async function startZaloLogin(projectDir, agentId = "") {
|
|
1725
2066
|
const cfgPath = join(projectDir, ".openclaw", "openclaw.json");
|
|
1726
2067
|
if (!existsSync(cfgPath)) throw httpError(404, "openclaw.json not found");
|
|
@@ -1742,39 +2083,64 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
|
|
|
1742
2083
|
if (zaloLoginInFlight) {
|
|
1743
2084
|
return { message: 'Zalo login is already running. Keep this modal open...' };
|
|
1744
2085
|
}
|
|
1745
|
-
const
|
|
1746
|
-
if (!
|
|
1747
|
-
|
|
2086
|
+
const native = isNativeProject(projectDir);
|
|
2087
|
+
if (!native) {
|
|
2088
|
+
const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
|
|
2089
|
+
if (!existsSync(composeFile)) {
|
|
2090
|
+
throw httpError(400, 'Zalo login cần project Docker đang chạy (không tìm thấy docker-compose.yml).');
|
|
2091
|
+
}
|
|
1748
2092
|
}
|
|
1749
2093
|
zaloLoginInFlight = true;
|
|
1750
|
-
const botContainer = getBotContainerName(projectDir);
|
|
2094
|
+
const botContainer = native ? '' : getBotContainerName(projectDir);
|
|
1751
2095
|
sendLog(`[zalo-connect] Preparing QR login for account [${accountId}]...`);
|
|
1752
2096
|
try {
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
2097
|
+
if (native) {
|
|
2098
|
+
// No container: the gateway runs as a managed service on the host. Wait for it to
|
|
2099
|
+
// report the zalo-connect channel; if it never does and the plugin folder is absent,
|
|
2100
|
+
// install it on the host (into this project's .openclaw/extensions) and reload.
|
|
2101
|
+
const gatewayReady = await waitForNativeGatewayZaloReady(projectDir, 180000, ['zalo-connect']);
|
|
2102
|
+
if (!gatewayReady) {
|
|
2103
|
+
const extDir = join(projectDir, '.openclaw', 'extensions', 'zalo-connect');
|
|
2104
|
+
if (!existsSync(extDir)) {
|
|
2105
|
+
sendLog(`[zalo-connect] Plugin missing — installing ${ZALO_CONNECT_PLUGIN_SPEC} natively...`);
|
|
2106
|
+
const inst = await ocCapture(projectDir, ['plugins', 'install', ZALO_CONNECT_PLUGIN_SPEC, '--force', '--acknowledge-clawhub-risk']);
|
|
2107
|
+
const instOut = `${inst.stdout}\n${inst.stderr}`;
|
|
2108
|
+
for (const line of instOut.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
|
|
2109
|
+
if (/installed plugin/i.test(instOut) || existsSync(extDir)) {
|
|
2110
|
+
await restartNativeRuntime(projectDir).catch((err) => sendLog(`[native] restart skipped/failed: ${err.message}`));
|
|
2111
|
+
await waitForNativeGatewayZaloReady(projectDir, 180000, ['zalo-connect']);
|
|
2112
|
+
} else {
|
|
2113
|
+
sendLog('[zalo-connect] Cài plugin không thành công — thử lại bằng nút "Đăng nhập Zalo".');
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
} else {
|
|
2118
|
+
// NEVER poke the container while it is still booting: OpenClaw runs first-boot
|
|
2119
|
+
// migrations under a state lease, and a docker exec/restart mid-migration wedges
|
|
2120
|
+
// the lease and crash-loops the gateway. Wait for the container, then for the
|
|
2121
|
+
// gateway to report the zalo-connect channel (the entrypoint installs the pinned
|
|
2122
|
+
// plugin itself on first boot), and only fall back to an exec-install when the
|
|
2123
|
+
// gateway is up but the plugin is genuinely absent (projects created before the
|
|
2124
|
+
// backend-aware entrypoint existed).
|
|
2125
|
+
const containerUp = await waitForDockerContainer(botContainer, 90000);
|
|
2126
|
+
if (!containerUp) sendLog(`[zalo-connect] ${botContainer} chưa chạy sau 90s — vẫn thử tiếp...`);
|
|
2127
|
+
const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
|
|
2128
|
+
if (!gatewayReady) {
|
|
2129
|
+
const check = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', '[ -d "${OPENCLAW_HOME:-/home/node/project/.openclaw}/extensions/zalo-connect" ] && echo OK || echo MISSING'], { cwd: projectDir, shell: false }).catch(() => ({ stdout: 'ERR' }));
|
|
2130
|
+
if (String(check.stdout || '').trim() === 'MISSING') {
|
|
2131
|
+
sendLog(`[zalo-connect] Plugin missing — installing ${ZALO_CONNECT_PLUGIN_SPEC}...`);
|
|
2132
|
+
const installCmd = `cd /home/node/project && openclaw plugins install ${ZALO_CONNECT_PLUGIN_SPEC} --force --acknowledge-clawhub-risk 2>&1`;
|
|
2133
|
+
const inst = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', installCmd], { cwd: projectDir, shell: false });
|
|
2134
|
+
const instOut = `${inst.stdout}\n${inst.stderr}`;
|
|
2135
|
+
for (const line of instOut.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
|
|
2136
|
+
if (/installed plugin/i.test(instOut)) {
|
|
2137
|
+
// Gateway must reload to pick the plugin up — safe here: the gateway is past
|
|
2138
|
+
// its boot (we only reach this branch when it answered the exec above).
|
|
2139
|
+
await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
|
|
2140
|
+
await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
|
|
2141
|
+
} else {
|
|
2142
|
+
sendLog('[zalo-connect] Cài plugin không thành công — thử lại bằng nút "Đăng nhập Zalo" sau khi container ổn định.');
|
|
2143
|
+
}
|
|
1778
2144
|
}
|
|
1779
2145
|
}
|
|
1780
2146
|
}
|
|
@@ -1788,10 +2154,19 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
|
|
|
1788
2154
|
let qrSent = false;
|
|
1789
2155
|
let loginDone = false;
|
|
1790
2156
|
|
|
1791
|
-
const
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
2157
|
+
const pushQr = async (pngPath) => {
|
|
2158
|
+
let b64 = '';
|
|
2159
|
+
if (native) {
|
|
2160
|
+
// The CLI ran on the host, so the QR PNG is a real host path — read it directly.
|
|
2161
|
+
try {
|
|
2162
|
+
const st = await fsp.stat(pngPath);
|
|
2163
|
+
if (st.size > 100) b64 = (await fsp.readFile(pngPath)).toString('base64');
|
|
2164
|
+
} catch {}
|
|
2165
|
+
} else {
|
|
2166
|
+
const js = `const fs=require('fs');const p=${JSON.stringify(pngPath)};try{if(fs.existsSync(p)&&fs.statSync(p).size>100){process.stdout.write(fs.readFileSync(p).toString('base64'));}}catch{}`;
|
|
2167
|
+
const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', js], { cwd: projectDir, shell: false }).catch(() => ({ stdout: '' }));
|
|
2168
|
+
b64 = extractCompletePngBase64(out.stdout);
|
|
2169
|
+
}
|
|
1795
2170
|
if (b64.length > 100) {
|
|
1796
2171
|
qrSent = true;
|
|
1797
2172
|
sendLog(`[zalo-connect:qr] data:image/png;base64,${b64}`);
|
|
@@ -1803,7 +2178,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
|
|
|
1803
2178
|
const handleLine = (line) => {
|
|
1804
2179
|
const qrFile = line.match(/QR image saved at:\s*(\S+\.png)/i);
|
|
1805
2180
|
if (qrFile) {
|
|
1806
|
-
|
|
2181
|
+
pushQr(qrFile[1]).catch(() => {});
|
|
1807
2182
|
return;
|
|
1808
2183
|
}
|
|
1809
2184
|
if (isQrAsciiArt(line)) return; // don't flood the UI modal with terminal QR art
|
|
@@ -1818,7 +2193,9 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
|
|
|
1818
2193
|
const runAttempt = () => {
|
|
1819
2194
|
attempt++;
|
|
1820
2195
|
if (attempt > 1) sendLog(`[zalo-connect] Retry ${attempt}/${MAX_ATTEMPTS}...`);
|
|
1821
|
-
const child =
|
|
2196
|
+
const child = native
|
|
2197
|
+
? spawn(resolveBinPath('openclaw'), ['channels', 'login', '--channel', 'zalo-connect', '--account', accountId, '--verbose'], { cwd: projectDir, shell: false, windowsHide: true, env: { ...process.env, ...nativeEnv(projectDir) } })
|
|
2198
|
+
: spawn('docker', ['exec', botContainer, 'sh', '-lc', loginCmd], { cwd: projectDir, shell: false, windowsHide: true });
|
|
1822
2199
|
zaloLoginChild = child;
|
|
1823
2200
|
child.stdout.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLine));
|
|
1824
2201
|
child.stderr.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLine));
|
|
@@ -1828,9 +2205,15 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
|
|
|
1828
2205
|
if (zaloLoginChild === child) zaloLoginChild = null;
|
|
1829
2206
|
sendLog(`[zalo-connect] Login process exited ${code}`);
|
|
1830
2207
|
if (loginDone) {
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
2208
|
+
if (native) {
|
|
2209
|
+
sendLog('[zalo-connect] Login saved. Restarting native gateway so the Zalo channel connects...');
|
|
2210
|
+
await restartNativeRuntime(projectDir).catch((err) => sendLog(`[zalo-connect] Gateway restart failed: ${err.message}`));
|
|
2211
|
+
sendLog('[zalo-connect] Gateway restarted. Try sending a Zalo message now.');
|
|
2212
|
+
} else {
|
|
2213
|
+
sendLog(`[zalo-connect] Login saved. Restarting ${botContainer} so the Zalo channel connects...`);
|
|
2214
|
+
await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[zalo-connect] Container restart failed: ${err.message}`));
|
|
2215
|
+
sendLog(`[zalo-connect] ${botContainer} restarted. Try sending a Zalo message now.`);
|
|
2216
|
+
}
|
|
1834
2217
|
zaloLoginInFlight = false;
|
|
1835
2218
|
} else if (code !== 0 && !qrSent && !wasCancelled && attempt < MAX_ATTEMPTS) {
|
|
1836
2219
|
const delay = RETRY_DELAYS[attempt] || 15000;
|
|
@@ -1967,38 +2350,68 @@ async function getZaloHealth(projectDir) {
|
|
|
1967
2350
|
}
|
|
1968
2351
|
}
|
|
1969
2352
|
|
|
2353
|
+
const native = isNativeProject(projectDir);
|
|
1970
2354
|
let containerRunning = false;
|
|
1971
|
-
try {
|
|
1972
|
-
const r = await runCapture('docker', ['inspect', '-f', '{{.State.Running}}', botContainer], { shell: false, timeout: 8000 });
|
|
1973
|
-
containerRunning = String(r.stdout || '').trim() === 'true';
|
|
1974
|
-
} catch {}
|
|
1975
2355
|
let statusJson = null;
|
|
1976
2356
|
let textStatus = '';
|
|
1977
2357
|
let credentialNames = null;
|
|
1978
|
-
if (
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
2358
|
+
if (native) {
|
|
2359
|
+
// "containerRunning" here means "runtime up": for native, probe the managed gateway's
|
|
2360
|
+
// /health over loopback, then read channel status + credentials directly on the host.
|
|
2361
|
+
const nmeta = readNativeMeta(projectDir) || {};
|
|
2362
|
+
const port = String(nmeta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
|
|
2363
|
+
containerRunning = await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500);
|
|
2364
|
+
if (containerRunning) {
|
|
1984
2365
|
try {
|
|
1985
|
-
const r = await
|
|
1986
|
-
|
|
2366
|
+
const r = await ocCapture(projectDir, ['channels', 'status', '--json'], { timeout: 20000 });
|
|
2367
|
+
statusJson = parseJsonText(String(r.stdout || '').trim(), null);
|
|
1987
2368
|
} catch {}
|
|
2369
|
+
if (!statusJson) {
|
|
2370
|
+
try {
|
|
2371
|
+
const r = await ocCapture(projectDir, ['channels', 'status'], { timeout: 20000 });
|
|
2372
|
+
textStatus = `${r.stdout || ''}\n${r.stderr || ''}`;
|
|
2373
|
+
} catch {}
|
|
2374
|
+
}
|
|
2375
|
+
// zalo-connect writes credentials under the project's .openclaw (OPENCLAW_HOME); fall
|
|
2376
|
+
// back to the real home dir in case the plugin used os.homedir() instead.
|
|
2377
|
+
const credRe = /^zalo-connect-credentials(?:-[^.]+)?\.json$/i;
|
|
2378
|
+
for (const dir of [join(projectDir, '.openclaw'), join(os.homedir(), '.openclaw')]) {
|
|
2379
|
+
try {
|
|
2380
|
+
const names = (await fsp.readdir(dir)).filter((n) => credRe.test(n));
|
|
2381
|
+
if (names.length) { credentialNames = names; break; }
|
|
2382
|
+
} catch {}
|
|
2383
|
+
}
|
|
1988
2384
|
}
|
|
2385
|
+
} else {
|
|
1989
2386
|
try {
|
|
1990
|
-
const
|
|
1991
|
-
|
|
1992
|
-
credentialNames = parseJsonText(String(r.stdout || '[]').trim(), []);
|
|
2387
|
+
const r = await runCapture('docker', ['inspect', '-f', '{{.State.Running}}', botContainer], { shell: false, timeout: 8000 });
|
|
2388
|
+
containerRunning = String(r.stdout || '').trim() === 'true';
|
|
1993
2389
|
} catch {}
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2390
|
+
if (containerRunning) {
|
|
2391
|
+
try {
|
|
2392
|
+
const r = await runCapture('docker', ['exec', botContainer, 'openclaw', 'channels', 'status', '--json'], { cwd: projectDir, shell: false, timeout: 20000 });
|
|
2393
|
+
statusJson = parseJsonText(String(r.stdout || '').trim(), null);
|
|
2394
|
+
} catch {}
|
|
2395
|
+
if (!statusJson) {
|
|
2396
|
+
try {
|
|
2397
|
+
const r = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', 'openclaw channels status 2>&1 || true'], { cwd: projectDir, shell: false, timeout: 20000 });
|
|
2398
|
+
textStatus = String(r.stdout || '');
|
|
2399
|
+
} catch {}
|
|
2000
2400
|
}
|
|
2001
|
-
|
|
2401
|
+
try {
|
|
2402
|
+
const script = "const fs=require('fs'),path=require('path'),os=require('os');const d=path.join(os.homedir(),'.openclaw');let a=[];try{a=fs.readdirSync(d).filter(n=>/^zalo-connect-credentials(?:-[^.]+)?\\.json$/i.test(n))}catch{}process.stdout.write(JSON.stringify(a))";
|
|
2403
|
+
const r = await runCapture('docker', ['exec', botContainer, 'node', '-e', script], { cwd: projectDir, shell: false, timeout: 8000 });
|
|
2404
|
+
credentialNames = parseJsonText(String(r.stdout || '[]').trim(), []);
|
|
2405
|
+
} catch {}
|
|
2406
|
+
try {
|
|
2407
|
+
const versions = await getContainerExtensionVersions(projectDir);
|
|
2408
|
+
const zaloModVersion = versions['zalo-mod'] || versions['openclaw-zalo-mod'] || '';
|
|
2409
|
+
if (zaloModVersion) {
|
|
2410
|
+
meta.zaloModInstalled = true;
|
|
2411
|
+
meta.zaloModVersion = zaloModVersion;
|
|
2412
|
+
}
|
|
2413
|
+
} catch {}
|
|
2414
|
+
}
|
|
2002
2415
|
}
|
|
2003
2416
|
return { ...buildZaloHealthSnapshot(cfg, statusJson, credentialNames, { containerRunning, textStatus }), ...meta };
|
|
2004
2417
|
}
|
|
@@ -2019,6 +2432,239 @@ function getBotServiceName(projectDir) {
|
|
|
2019
2432
|
return 'ai-bot';
|
|
2020
2433
|
}
|
|
2021
2434
|
|
|
2435
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
2436
|
+
// Native runtime — openclaw + 9router straight on the host, no Docker
|
|
2437
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
2438
|
+
// Two things replace the container:
|
|
2439
|
+
// 1. `docker exec <container> openclaw …` → `openclaw …` carrying the project env.
|
|
2440
|
+
// Without that env the CLI silently reads ~/.openclaw instead of the project, so every
|
|
2441
|
+
// native invocation MUST go through ocRun/ocCapture rather than calling openclaw directly.
|
|
2442
|
+
// 2. container lifecycle → `openclaw daemon …` (launchd on macOS, systemd on Linux,
|
|
2443
|
+
// schtasks on Windows). The generated service keeps the project env via its own
|
|
2444
|
+
// env-wrapper and sets KeepAlive, which is the native equivalent of `restart: always`.
|
|
2445
|
+
// Service identity is per project (OPENCLAW_LAUNCHD_LABEL/…): the CLI's default label is a
|
|
2446
|
+
// single fixed one, so without this a second native project would take over the first's service.
|
|
2447
|
+
|
|
2448
|
+
const NATIVE_MARKER = 'native.json';
|
|
2449
|
+
// Native ports sit one hundred above the docker ones (18789/20128) so a native project can run
|
|
2450
|
+
// next to a docker project — or next to an SSH tunnel forwarding a remote bot's ports — untouched.
|
|
2451
|
+
const NATIVE_DEFAULT_GATEWAY_PORT = 18889;
|
|
2452
|
+
const NATIVE_DEFAULT_ROUTER_PORT = 20228;
|
|
2453
|
+
|
|
2454
|
+
function nativeMarkerPath(projectDir) {
|
|
2455
|
+
return join(projectDir || state.projectDir || '', '.openclaw', NATIVE_MARKER);
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
function readNativeMeta(projectDir) {
|
|
2459
|
+
try { return JSON.parse(fs.readFileSync(nativeMarkerPath(projectDir), 'utf8')); } catch (e) { return null; }
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
/** Per-project deploy mode. The marker file wins; a compose file means docker; else fall back. */
|
|
2463
|
+
function projectDeployMode(projectDir) {
|
|
2464
|
+
const dir = projectDir || state.projectDir || '';
|
|
2465
|
+
if (!dir) return state.mode || 'docker';
|
|
2466
|
+
if (existsSync(nativeMarkerPath(dir))) return 'native';
|
|
2467
|
+
if (existsSync(join(dir, 'docker', 'openclaw', 'docker-compose.yml'))) return 'docker';
|
|
2468
|
+
return state.mode || 'docker';
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
function isNativeProject(projectDir) {
|
|
2472
|
+
return projectDeployMode(projectDir) === 'native';
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
/** launchd label / systemd unit / scheduled-task name — unique per project so installs coexist. */
|
|
2476
|
+
function nativeServiceLabel(projectDir) {
|
|
2477
|
+
const meta = readNativeMeta(projectDir);
|
|
2478
|
+
if (meta && meta.label) return meta.label;
|
|
2479
|
+
const id = slugify(basename(projectDir || 'openclaw'), 'bot');
|
|
2480
|
+
return `ai.openclaw.gateway.${id}`;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
/** The env every native CLI call needs (mirrors the docker runtime env in docker-gen.js). */
|
|
2484
|
+
function nativeEnv(projectDir, extra = {}) {
|
|
2485
|
+
const dir = projectDir || state.projectDir || '';
|
|
2486
|
+
const home = join(dir, '.openclaw');
|
|
2487
|
+
const meta = readNativeMeta(dir) || {};
|
|
2488
|
+
const gatewayPort = String(meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
|
|
2489
|
+
const label = nativeServiceLabel(dir);
|
|
2490
|
+
return {
|
|
2491
|
+
OPENCLAW_HOME: home,
|
|
2492
|
+
OPENCLAW_STATE_DIR: home,
|
|
2493
|
+
DATA_DIR: join(dir, '.9router'),
|
|
2494
|
+
OPENCLAW_GATEWAY_PORT: gatewayPort,
|
|
2495
|
+
OPENCLAW_PORT: gatewayPort,
|
|
2496
|
+
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: '1',
|
|
2497
|
+
OPENCLAW_SETUP_OS: meta.osChoice || state.os || '',
|
|
2498
|
+
OPENCLAW_BROWSER_HOST_OS: meta.osChoice || state.os || '',
|
|
2499
|
+
OPENCLAW_LAUNCHD_LABEL: label,
|
|
2500
|
+
OPENCLAW_SYSTEMD_UNIT: `${label}.service`,
|
|
2501
|
+
OPENCLAW_WINDOWS_TASK_NAME: label,
|
|
2502
|
+
...extra,
|
|
2503
|
+
};
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
/** Resolve `openclaw <args>` for whichever runtime this project uses. */
|
|
2507
|
+
function ocArgv(projectDir, args) {
|
|
2508
|
+
if (isNativeProject(projectDir)) {
|
|
2509
|
+
return { cmd: 'openclaw', args, opts: { cwd: projectDir, env: nativeEnv(projectDir) } };
|
|
2510
|
+
}
|
|
2511
|
+
return { cmd: 'docker', args: ['exec', getBotContainerName(projectDir), 'openclaw', ...args], opts: { cwd: projectDir } };
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
function ocRun(projectDir, args, opts = {}) {
|
|
2515
|
+
const a = ocArgv(projectDir, args);
|
|
2516
|
+
return run(a.cmd, a.args, { ...a.opts, ...opts });
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
function ocCapture(projectDir, args, opts = {}) {
|
|
2520
|
+
const a = ocArgv(projectDir, args);
|
|
2521
|
+
return runCapture(a.cmd, a.args, { shell: false, ...a.opts, ...opts, env: { ...(a.opts.env || {}), ...(opts.env || {}) } });
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
/**
|
|
2525
|
+
* Restart the native gateway service.
|
|
2526
|
+
*
|
|
2527
|
+
* `daemon restart` is the obvious call, but on Windows it dies with
|
|
2528
|
+
* `ERR_UNKNOWN_SIGNAL: Unknown signal: SIGUSR1` (verified on a real box: the old pid survives and
|
|
2529
|
+
* newly installed plugins never load, silently). stop+start is what actually works there, and it
|
|
2530
|
+
* works everywhere else too, so Windows takes that path and other systems keep `restart` with
|
|
2531
|
+
* stop+start as a fallback.
|
|
2532
|
+
*/
|
|
2533
|
+
async function restartNativeRuntime(projectDir) {
|
|
2534
|
+
const env = nativeEnv(projectDir);
|
|
2535
|
+
const stopStart = async () => {
|
|
2536
|
+
await run('openclaw', ['daemon', 'stop'], { cwd: projectDir, env }).catch(() => {});
|
|
2537
|
+
await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
|
|
2538
|
+
};
|
|
2539
|
+
if (process.platform === 'win32') return stopStart();
|
|
2540
|
+
try {
|
|
2541
|
+
await run('openclaw', ['daemon', 'restart'], { cwd: projectDir, env });
|
|
2542
|
+
} catch (e) {
|
|
2543
|
+
sendLog(`[native] daemon restart failed (${e.message}); falling back to stop+start`);
|
|
2544
|
+
await stopStart();
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
/** Fire-and-forget background process (9router has no service wrapper of its own). */
|
|
2549
|
+
function startDetached(cmd, args, opts = {}) {
|
|
2550
|
+
sendLog(`$ ${cmd} ${args.join(' ')} &`);
|
|
2551
|
+
const shell = process.platform === 'win32';
|
|
2552
|
+
const rawBin = resolveBinPath(cmd);
|
|
2553
|
+
const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
|
|
2554
|
+
const child = spawn(bin, args, {
|
|
2555
|
+
cwd: opts.cwd,
|
|
2556
|
+
shell,
|
|
2557
|
+
detached: true,
|
|
2558
|
+
stdio: 'ignore',
|
|
2559
|
+
windowsHide: opts.windowsHide ?? true,
|
|
2560
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
2561
|
+
});
|
|
2562
|
+
child.on('error', (err) => sendLog(`[native] Failed to start "${cmd}": ${err.message}`));
|
|
2563
|
+
child.unref();
|
|
2564
|
+
return child.pid;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
/** Kill whatever is listening on a port. `pkill` does not exist on Windows, so resolve pid → kill. */
|
|
2568
|
+
async function killListenerOnPort(port) {
|
|
2569
|
+
if (process.platform === 'win32') {
|
|
2570
|
+
const out = await runCapture('powershell', ['-NoProfile', '-Command', `(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1).OwningProcess`], { shell: false, timeout: 15000 });
|
|
2571
|
+
const pid = String(out.stdout || '').trim();
|
|
2572
|
+
if (/^\d+$/.test(pid)) await run('taskkill', ['/F', '/PID', pid], { shell: false }).catch(() => {});
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
const out = await runCapture('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { shell: false, timeout: 10000 });
|
|
2576
|
+
const pid = String(out.stdout || '').trim().split(/\s+/)[0];
|
|
2577
|
+
if (/^\d+$/.test(pid)) await run('kill', [pid], { shell: false }).catch(() => {});
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
async function probeHttpOk(url, timeoutMs = 2000) {
|
|
2581
|
+
const r = await runCapture('curl', ['-s', '-m', String(Math.ceil(timeoutMs / 1000)), '-o', '/dev/null', '-w', '%{http_code}', url], { shell: false, timeout: timeoutMs + 2000 });
|
|
2582
|
+
return /^[23]/.test((r.stdout || '').trim());
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
/**
|
|
2586
|
+
* Start 9router for a native project. Bound to loopback on purpose: openclaw talks to it over
|
|
2587
|
+
* localhost (see get9RouterBaseUrl), so exposing the LLM proxy on 0.0.0.0 would only create an
|
|
2588
|
+
* open relay — on a VPS that is a real risk. Data lives in the project so projects stay separate.
|
|
2589
|
+
*/
|
|
2590
|
+
async function startNative9Router(projectDir, { restart = false } = {}) {
|
|
2591
|
+
const meta = readNativeMeta(projectDir) || {};
|
|
2592
|
+
const routerPort = meta.routerPort || state.routerPort || NATIVE_DEFAULT_ROUTER_PORT;
|
|
2593
|
+
const dataDir = join(projectDir, '.9router');
|
|
2594
|
+
await fsp.mkdir(dataDir, { recursive: true }).catch(() => {});
|
|
2595
|
+
if (restart) {
|
|
2596
|
+
// No service wrapper for 9router: stop the old listener before rebinding the same port.
|
|
2597
|
+
await killListenerOnPort(routerPort);
|
|
2598
|
+
} else if (await probeHttpOk(`http://127.0.0.1:${routerPort}/`)) {
|
|
2599
|
+
sendLog(`[native] 9router already listening on ${routerPort}`);
|
|
2600
|
+
return routerPort;
|
|
2601
|
+
}
|
|
2602
|
+
startDetached('9router', ['-n', '-l', '-H', '127.0.0.1', '-p', String(routerPort), '--skip-update'], {
|
|
2603
|
+
cwd: projectDir,
|
|
2604
|
+
env: nativeEnv(projectDir, { DATA_DIR: dataDir }),
|
|
2605
|
+
});
|
|
2606
|
+
return routerPort;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
/**
|
|
2610
|
+
* Bring a native project up end to end: 9router → smart-route sync → resolve its API key into
|
|
2611
|
+
* openclaw.json → install the gateway as a managed service. Order matters: the gateway must boot
|
|
2612
|
+
* after 9router is reachable and after the key is on disk, or its first turn has no model.
|
|
2613
|
+
*/
|
|
2614
|
+
async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, routerPort }) {
|
|
2615
|
+
const gwPort = gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT;
|
|
2616
|
+
const rtPort = routerPort || NATIVE_DEFAULT_ROUTER_PORT;
|
|
2617
|
+
const label = nativeServiceLabel(projectDir);
|
|
2618
|
+
// Marker first: nativeEnv()/isNativeProject() read it, and everything below depends on them.
|
|
2619
|
+
await fsp.mkdir(join(projectDir, '.openclaw'), { recursive: true });
|
|
2620
|
+
await fsp.writeFile(
|
|
2621
|
+
nativeMarkerPath(projectDir),
|
|
2622
|
+
JSON.stringify({ mode: 'native', gatewayPort: gwPort, routerPort: rtPort, osChoice, label }, null, 2),
|
|
2623
|
+
'utf8',
|
|
2624
|
+
);
|
|
2625
|
+
|
|
2626
|
+
await startNative9Router(projectDir);
|
|
2627
|
+
|
|
2628
|
+
// Same smart-route sync the docker sidecar runs, pointed at the native DB path. Without it
|
|
2629
|
+
// 9router keeps its login gate and the default `smart-route` combo has no backing models.
|
|
2630
|
+
try {
|
|
2631
|
+
const artifacts = buildDockerArtifacts({ is9Router: true, osChoice, openClawNpmSpec: OPENCLAW_NPM_SPEC, gatewayPort: gwPort, routerPort: rtPort });
|
|
2632
|
+
if (artifacts && artifacts.syncScript) {
|
|
2633
|
+
const dataDir = join(projectDir, '.9router');
|
|
2634
|
+
const syncPath = join(dataDir, 'sync.js');
|
|
2635
|
+
await fsp.writeFile(syncPath, artifacts.syncScript, 'utf8');
|
|
2636
|
+
startDetached(process.execPath, [syncPath], {
|
|
2637
|
+
cwd: dataDir,
|
|
2638
|
+
env: nativeEnv(projectDir, { NINEROUTER_DB_PATH: join(dataDir, 'db', 'data.sqlite'), PORT: String(rtPort) }),
|
|
2639
|
+
});
|
|
2640
|
+
sendLog('[native] 9router smart-route sync started');
|
|
2641
|
+
}
|
|
2642
|
+
} catch (e) {
|
|
2643
|
+
sendLog(`[native] smart-route sync skipped: ${e.message}`);
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
await new Promise((r) => setTimeout(r, 8000));
|
|
2647
|
+
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
2648
|
+
|
|
2649
|
+
// Managed service = auto-restart (KeepAlive/Restart=always) and start-at-login, the native
|
|
2650
|
+
// equivalent of docker's `restart: always`. --force so re-running install updates the port.
|
|
2651
|
+
const env = nativeEnv(projectDir);
|
|
2652
|
+
await run('openclaw', ['daemon', 'install', '--force', '--port', String(gwPort)], { cwd: projectDir, env });
|
|
2653
|
+
await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
|
|
2654
|
+
sendLog(`[native] gateway service "${label}" running on 127.0.0.1:${gwPort}, 9router on 127.0.0.1:${rtPort}`);
|
|
2655
|
+
return { gatewayPort: gwPort, routerPort: rtPort, label };
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
/** Tear down a native project's service (used when deleting the project). */
|
|
2659
|
+
async function removeNativeRuntime(projectDir) {
|
|
2660
|
+
if (!isNativeProject(projectDir)) return false;
|
|
2661
|
+
const env = nativeEnv(projectDir);
|
|
2662
|
+
await run('openclaw', ['daemon', 'uninstall'], { cwd: projectDir, env }).catch((e) => sendLog(`[native] daemon uninstall: ${e.message}`));
|
|
2663
|
+
const routerPort = (readNativeMeta(projectDir) || {}).routerPort || NATIVE_DEFAULT_ROUTER_PORT;
|
|
2664
|
+
await killListenerOnPort(routerPort);
|
|
2665
|
+
return true;
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2022
2668
|
function getBotContainerName(projectDir) {
|
|
2023
2669
|
const composeFile = join(projectDir || state.projectDir || '', 'docker', 'openclaw', 'docker-compose.yml');
|
|
2024
2670
|
if (!existsSync(composeFile)) return 'openclaw-bot';
|
|
@@ -2150,6 +2796,14 @@ async function syncDockerInfra(projectDir, force = false) {
|
|
|
2150
2796
|
}
|
|
2151
2797
|
|
|
2152
2798
|
async function recreateDockerBot(projectDir) {
|
|
2799
|
+
// Native: there is no image to rebuild — the gateway reads openclaw.json from disk on boot, so
|
|
2800
|
+
// reloading config after a bot/plugin change is just a service restart. Callers stay unchanged.
|
|
2801
|
+
if (isNativeProject(projectDir)) {
|
|
2802
|
+
sendLog('[native] Reloading gateway to pick up openclaw.json changes...');
|
|
2803
|
+
await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart failed: ${e.message}`));
|
|
2804
|
+
probeCacheClear();
|
|
2805
|
+
return true;
|
|
2806
|
+
}
|
|
2153
2807
|
const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
|
|
2154
2808
|
if (!existsSync(composeFile)) return false;
|
|
2155
2809
|
const depDir = join(projectDir, '.openclaw', 'plugin-runtime-deps');
|
|
@@ -2168,6 +2822,17 @@ async function recreateDockerBot(projectDir) {
|
|
|
2168
2822
|
async function updateRuntime(target, projectDir) {
|
|
2169
2823
|
const isRouter = target === '9router';
|
|
2170
2824
|
const spec = isRouter ? NINE_ROUTER_NPM_SPEC : OPENCLAW_NPM_SPEC;
|
|
2825
|
+
// Native: the runtime is a global npm package, not an image. Reinstall it, then restart the
|
|
2826
|
+
// service so the new binary is the one actually serving. This is what replaces "Rebuild".
|
|
2827
|
+
if (isNativeProject(projectDir)) {
|
|
2828
|
+
sendLog(`[native] Updating ${target} → ${spec}`);
|
|
2829
|
+
await run('npm', ['install', '-g', spec]);
|
|
2830
|
+
if (isRouter) await startNative9Router(projectDir, { restart: true }).catch((e) => sendLog(`[native] 9router restart: ${e.message}`));
|
|
2831
|
+
else await restartNativeRuntime(projectDir);
|
|
2832
|
+
await syncRuntimeState(projectDir, { full: true }).catch(() => {});
|
|
2833
|
+
probeCacheClear();
|
|
2834
|
+
return { ok: true, target, spec, mode: 'native' };
|
|
2835
|
+
}
|
|
2171
2836
|
if (state.mode === 'docker' && projectDir) {
|
|
2172
2837
|
const dockerDir = join(projectDir, 'docker', 'openclaw');
|
|
2173
2838
|
if (isRouter) {
|
|
@@ -2189,6 +2854,14 @@ async function updateRuntime(target, projectDir) {
|
|
|
2189
2854
|
}
|
|
2190
2855
|
|
|
2191
2856
|
async function restartDockerBotContainer(projectDir = state.projectDir) {
|
|
2857
|
+
// Native projects have no container: the gateway runs as a managed service, so restarting it
|
|
2858
|
+
// is `openclaw daemon restart` (which also clears any stale gateway process holding the port).
|
|
2859
|
+
if (isNativeProject(projectDir)) {
|
|
2860
|
+
sendLog('[native] Restarting gateway service...');
|
|
2861
|
+
await restartNativeRuntime(projectDir);
|
|
2862
|
+
probeCacheClear(`runtime:${projectDir}`);
|
|
2863
|
+
return true;
|
|
2864
|
+
}
|
|
2192
2865
|
const containerName = getBotContainerName(projectDir);
|
|
2193
2866
|
sendLog(`[docker] Restarting ${containerName} container...`);
|
|
2194
2867
|
await run('docker', ['restart', containerName], { shell: false });
|
|
@@ -2428,6 +3101,695 @@ async function getDockerBridgeIp() {
|
|
|
2428
3101
|
} catch {}
|
|
2429
3102
|
return '172.17.0.1';
|
|
2430
3103
|
}
|
|
3104
|
+
// ── Host control ────────────────────────────────────────────────────────────────
|
|
3105
|
+
// The bot runs inside a container: it has no view of the host desktop and cannot start a
|
|
3106
|
+
// program there, which is why asking it to open TeamViewer gets a refusal. The installer,
|
|
3107
|
+
// though, already runs ON the host and already spawns processes (it launches Chrome). This
|
|
3108
|
+
// exposes that ability to the bot over a small HTTP service.
|
|
3109
|
+
//
|
|
3110
|
+
// Reachability: the dashboard itself binds to 127.0.0.1, which a container cannot reach, so
|
|
3111
|
+
// this listens on the Docker bridge address as well — the same approach the Chrome relay
|
|
3112
|
+
// uses, private to this machine and not routable from outside.
|
|
3113
|
+
//
|
|
3114
|
+
// Everything is gated: the service only starts when hostControl.enabled is true, every
|
|
3115
|
+
// request needs the per-project token, and `open` accepts a key from the operator's own app
|
|
3116
|
+
// list rather than an arbitrary command line. Opening apps on the host is a real capability,
|
|
3117
|
+
// so it stays opt-in and enumerable instead of a general shell.
|
|
3118
|
+
const HOST_CONTROL_PORT = 18795;
|
|
3119
|
+
let _hostControlServer = null;
|
|
3120
|
+
// The project the running host-control service serves. Tracked separately from the server
|
|
3121
|
+
// singleton so enabling from a different (connected) project re-points the service without a
|
|
3122
|
+
// restart — the request handler reads config from THIS dir, not a value captured at first-start.
|
|
3123
|
+
let _hostControlProjectDir = null;
|
|
3124
|
+
|
|
3125
|
+
function hostControlConfigPath(projectDir) {
|
|
3126
|
+
return join(projectDir, '.openclaw', 'host-control.json');
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
/** Common install locations, so the app list is useful before anyone edits it. */
|
|
3130
|
+
function detectHostApps() {
|
|
3131
|
+
const apps = {};
|
|
3132
|
+
const add = (key, candidates) => {
|
|
3133
|
+
for (const candidate of candidates) {
|
|
3134
|
+
if (candidate && existsSync(candidate)) {
|
|
3135
|
+
apps[key] = candidate;
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
};
|
|
3140
|
+
if (process.platform === 'win32') {
|
|
3141
|
+
const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
|
|
3142
|
+
const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
|
|
3143
|
+
const local = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
|
|
3144
|
+
add('teamviewer', [join(pf, 'TeamViewer', 'TeamViewer.exe'), join(pf86, 'TeamViewer', 'TeamViewer.exe')]);
|
|
3145
|
+
add('chrome', [join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'), join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe')]);
|
|
3146
|
+
add('zalo', [join(local, 'Programs', 'Zalo', 'Zalo.exe'), join(local, 'Zalo', 'Zalo.exe')]);
|
|
3147
|
+
add('explorer', ['C:\\Windows\\explorer.exe']);
|
|
3148
|
+
add('notepad', ['C:\\Windows\\System32\\notepad.exe']);
|
|
3149
|
+
} else if (process.platform === 'darwin') {
|
|
3150
|
+
add('teamviewer', ['/Applications/TeamViewer.app']);
|
|
3151
|
+
add('chrome', ['/Applications/Google Chrome.app']);
|
|
3152
|
+
add('zalo', ['/Applications/Zalo.app']);
|
|
3153
|
+
add('finder', ['/System/Library/CoreServices/Finder.app']);
|
|
3154
|
+
}
|
|
3155
|
+
return apps;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
/** Resolve an executable on PATH synchronously (returns absolute path or ''). */
|
|
3159
|
+
function whichSync(name) {
|
|
3160
|
+
try {
|
|
3161
|
+
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
3162
|
+
const out = execFileSync(finder, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
3163
|
+
const first = String(out).split(/\r?\n/).map((s) => s.trim()).find(Boolean);
|
|
3164
|
+
return first || '';
|
|
3165
|
+
} catch (_) {
|
|
3166
|
+
return '';
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
/**
|
|
3171
|
+
* CLI tools the bot may RUN (not just open) via /api/host/exec — output is captured and
|
|
3172
|
+
* returned. Kept as a name→path allow-list, mirroring detectHostApps: the executable is fixed,
|
|
3173
|
+
* only allow-listed names run. Auto-detects Claude Code CLI; add more by editing
|
|
3174
|
+
* `.openclaw/host-control.json` → `commands`.
|
|
3175
|
+
*/
|
|
3176
|
+
function detectHostCommands() {
|
|
3177
|
+
const commands = {};
|
|
3178
|
+
const claude = whichSync('claude');
|
|
3179
|
+
if (claude) commands.claude = claude;
|
|
3180
|
+
return commands;
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
/**
|
|
3184
|
+
* Extra capabilities the operator grants together with PC control: seeing the screen
|
|
3185
|
+
* (screenshot / screen recording) and running scripts through node or the Codex CLI.
|
|
3186
|
+
*
|
|
3187
|
+
* Kept out of detectHostCommands() on purpose. That one is the default list every project gets
|
|
3188
|
+
* as soon as the dashboard reads host-control state; these are only merged in when the operator
|
|
3189
|
+
* actually flips PC control on, so nothing is granted before they ask for it. `node` in
|
|
3190
|
+
* particular runs arbitrary code, which is why it takes an explicit act.
|
|
3191
|
+
*/
|
|
3192
|
+
function detectHostCapabilityCommands() {
|
|
3193
|
+
const commands = {};
|
|
3194
|
+
// The installer is itself node, so this path is guaranteed to exist and to be the same
|
|
3195
|
+
// interpreter the native bot runs under (the one macOS will attach the screen permission to).
|
|
3196
|
+
commands.node = process.execPath;
|
|
3197
|
+
for (const name of ['npx', 'codex', 'claude', 'ffmpeg']) {
|
|
3198
|
+
const bin = whichSync(name);
|
|
3199
|
+
if (bin) commands[name] = bin; // ffmpeg = screen recording on Linux/macOS
|
|
3200
|
+
}
|
|
3201
|
+
// The Codex CLI usually is not on PATH — it ships inside the desktop app. With it allow-listed
|
|
3202
|
+
// the bot can hand a job to Codex headlessly (`codex exec "…"`) and read the answer back.
|
|
3203
|
+
if (!commands.codex) {
|
|
3204
|
+
const bundledCodex = resolveCodexCli(detectCodexApp());
|
|
3205
|
+
if (bundledCodex) commands.codex = bundledCodex;
|
|
3206
|
+
}
|
|
3207
|
+
if (process.platform === 'darwin') {
|
|
3208
|
+
// Both a screenshot (`-x`) and a screen recording (`-v -V <secs>`) tool.
|
|
3209
|
+
if (existsSync('/usr/sbin/screencapture')) commands.screencapture = '/usr/sbin/screencapture';
|
|
3210
|
+
} else if (process.platform === 'linux') {
|
|
3211
|
+
for (const name of ['gnome-screenshot', 'spectacle', 'scrot', 'import']) {
|
|
3212
|
+
const bin = whichSync(name);
|
|
3213
|
+
if (bin) { commands.screenshot = bin; break; }
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
return commands;
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
/**
|
|
3220
|
+
* Merge the capability commands into the project's allow-list, and report what was added so the
|
|
3221
|
+
* dashboard can name it. Existing entries are left alone: an operator who pointed `node` at a
|
|
3222
|
+
* specific interpreter keeps that path.
|
|
3223
|
+
*/
|
|
3224
|
+
function grantHostCapabilities(cfg) {
|
|
3225
|
+
const detected = detectHostCapabilityCommands();
|
|
3226
|
+
const added = [];
|
|
3227
|
+
cfg.commands = cfg.commands || {};
|
|
3228
|
+
for (const [name, bin] of Object.entries(detected)) {
|
|
3229
|
+
if (!cfg.commands[name]) {
|
|
3230
|
+
cfg.commands[name] = bin;
|
|
3231
|
+
added.push(name);
|
|
3232
|
+
}
|
|
3233
|
+
}
|
|
3234
|
+
return added;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
// Mouse/keyboard/screen control comes from the Codex desktop app's own `computer-use` plugin.
|
|
3238
|
+
// The bot reaches it by running `codex exec "<task>"`, which is a normal allow-listed command —
|
|
3239
|
+
// no OpenClaw-side harness, no second agent, no gateway restart. All this code has to do is make
|
|
3240
|
+
// sure the desktop app itself has computer-use installed and wired.
|
|
3241
|
+
//
|
|
3242
|
+
/** Where the desktop app that ships the Codex CLI + computer-use bundle lives. */
|
|
3243
|
+
function detectCodexApp() {
|
|
3244
|
+
const candidates = process.platform === 'darwin'
|
|
3245
|
+
? [
|
|
3246
|
+
{ app: '/Applications/Codex.app', bundle: '/Applications/Codex.app/Contents/Resources/plugins/openai-bundled' },
|
|
3247
|
+
{ app: '/Applications/ChatGPT.app', bundle: '/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled' },
|
|
3248
|
+
]
|
|
3249
|
+
: process.platform === 'win32'
|
|
3250
|
+
? [
|
|
3251
|
+
{ app: join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'Programs', 'Codex'), bundle: '' },
|
|
3252
|
+
{ app: join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'Programs', 'ChatGPT'), bundle: '' },
|
|
3253
|
+
]
|
|
3254
|
+
: [];
|
|
3255
|
+
for (const candidate of candidates) {
|
|
3256
|
+
if (existsSync(candidate.app)) {
|
|
3257
|
+
return { present: true, app: candidate.app, bundle: candidate.bundle && existsSync(candidate.bundle) ? candidate.bundle : '' };
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
return { present: false, app: '', bundle: '' };
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
/**
|
|
3264
|
+
* Find a marketplace the Codex app-server has ALREADY registered that carries the computer-use
|
|
3265
|
+
* plugin, by reading its own `~/.codex/config.toml`.
|
|
3266
|
+
*
|
|
3267
|
+
* This matters because auto-install refuses to add new sources: pointing the plugin at a
|
|
3268
|
+
* marketplace directory it has not discovered fails with "auto-install only uses marketplaces
|
|
3269
|
+
* Codex app-server has already discovered … run /codex computer-use install". Naming a discovered
|
|
3270
|
+
* marketplace instead keeps provisioning fully automatic.
|
|
3271
|
+
*/
|
|
3272
|
+
function detectCodexMarketplace() {
|
|
3273
|
+
const codexHome = process.env.CODEX_HOME || join(getRealHomedir(), '.codex');
|
|
3274
|
+
const configPath = join(codexHome, 'config.toml');
|
|
3275
|
+
if (!existsSync(configPath)) return null;
|
|
3276
|
+
let toml = '';
|
|
3277
|
+
try {
|
|
3278
|
+
toml = fs.readFileSync(configPath, 'utf8');
|
|
3279
|
+
} catch (_) {
|
|
3280
|
+
return null;
|
|
3281
|
+
}
|
|
3282
|
+
// Minimal line-based TOML read: [marketplaces.<name>] headers and their `source = "..."`. A full
|
|
3283
|
+
// TOML parser is not worth pulling in for two fields of someone else's config.
|
|
3284
|
+
let name = '';
|
|
3285
|
+
for (const rawLine of toml.split(/\r?\n/)) {
|
|
3286
|
+
const line = rawLine.trim();
|
|
3287
|
+
const header = line.match(/^\[([^\]]+)\]$/);
|
|
3288
|
+
if (header) {
|
|
3289
|
+
const section = header[1];
|
|
3290
|
+
name = section.startsWith('marketplaces.') ? section.slice('marketplaces.'.length).replace(/^["']|["']$/g, '') : '';
|
|
3291
|
+
continue;
|
|
3292
|
+
}
|
|
3293
|
+
if (!name) continue;
|
|
3294
|
+
const source = (line.match(/^source\s*=\s*"([^"]+)"$/) || [])[1];
|
|
3295
|
+
if (source && existsSync(join(source, 'plugins', 'computer-use'))) return { name, source };
|
|
3296
|
+
}
|
|
3297
|
+
return null;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
/** The Codex CLI that ships inside the desktop app (or one on PATH). */
|
|
3301
|
+
function resolveCodexCli(app) {
|
|
3302
|
+
const bundled = app && app.app ? join(app.app, 'Contents', 'Resources', 'codex') : '';
|
|
3303
|
+
if (bundled && existsSync(bundled)) return bundled;
|
|
3304
|
+
return whichSync('codex');
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
/**
|
|
3308
|
+
* Last mile on the Codex side: the OpenClaw plugin can only USE computer-use, it cannot install it
|
|
3309
|
+
* into the desktop app. Two things have to be true there, and both are fixable with the app's own
|
|
3310
|
+
* CLI (verified on a real machine):
|
|
3311
|
+
* - the `computer-use` plugin is installed from a discovered marketplace, and
|
|
3312
|
+
* - the `computer-use` MCP server points at that installed plugin. A stale global entry (left by
|
|
3313
|
+
* an earlier manual attempt) shadows the plugin's own and exposes zero tools, which surfaces as
|
|
3314
|
+
* the confusing "Computer Use is ready" with nothing behind it.
|
|
3315
|
+
*/
|
|
3316
|
+
async function ensureCodexComputerUsePlugin(app, marketplace) {
|
|
3317
|
+
const result = { cli: resolveCodexCli(app), pluginInstalled: false, installedNow: false, mcpRepaired: false };
|
|
3318
|
+
if (!result.cli || !marketplace) return result;
|
|
3319
|
+
const list = await runCapture(result.cli, ['plugin', 'list'], { shell: false }).catch(() => null);
|
|
3320
|
+
if (!list) return result;
|
|
3321
|
+
const ref = `computer-use@${marketplace.name}`;
|
|
3322
|
+
const row = `${list.stdout || ''}\n${list.stderr || ''}`.split(/\r?\n/).find((line) => line.trim().startsWith(ref));
|
|
3323
|
+
if (!row) return result;
|
|
3324
|
+
result.pluginInstalled = /\binstalled\b/.test(row) && !/not installed/.test(row);
|
|
3325
|
+
if (!result.pluginInstalled) {
|
|
3326
|
+
sendLog(`[computer-use] Cài plugin ${ref} vào app Codex…`);
|
|
3327
|
+
const add = await runCapture(result.cli, ['plugin', 'add', ref], { shell: false }).catch((err) => ({ code: 1, stderr: err.message }));
|
|
3328
|
+
result.installedNow = add.code === 0;
|
|
3329
|
+
if (!result.installedNow) result.error = (add.stderr || add.stdout || '').trim().split(/\r?\n/).slice(-2).join(' ');
|
|
3330
|
+
else result.pluginInstalled = true;
|
|
3331
|
+
}
|
|
3332
|
+
// Repair the MCP registration only when it clearly is NOT the plugin's own (its cwd lives under
|
|
3333
|
+
// the plugin cache). Removing the global entry lets the plugin-provided server take over.
|
|
3334
|
+
const mcp = await runCapture(result.cli, ['mcp', 'get', 'computer-use'], { shell: false }).catch(() => null);
|
|
3335
|
+
const mcpText = mcp ? `${mcp.stdout || ''}${mcp.stderr || ''}` : '';
|
|
3336
|
+
if (mcpText && !/plugins\/cache\//.test(mcpText)) {
|
|
3337
|
+
sendLog('[computer-use] Gỡ khai báo MCP computer-use cũ (trỏ sai chỗ) để dùng bản của plugin…');
|
|
3338
|
+
const removed = await runCapture(result.cli, ['mcp', 'remove', 'computer-use'], { shell: false }).catch(() => ({ code: 1 }));
|
|
3339
|
+
result.mcpRepaired = removed.code === 0;
|
|
3340
|
+
}
|
|
3341
|
+
return result;
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
/**
|
|
3345
|
+
* Drop a tiny wrapper next to each workspace so GUI hand-off is one fixed command.
|
|
3346
|
+
*
|
|
3347
|
+
* Relying on the model to remember `--sandbox danger-full-access` does not work: a running session
|
|
3348
|
+
* still holds the TOOLS.md it loaded at session start, so a bot mid-conversation keeps calling
|
|
3349
|
+
* plain `codex exec`, gets "Computer Use was not approved to use <app>", and then invents a reason
|
|
3350
|
+
* (observed twice: it told the operator to grant Screen Recording, which was already granted).
|
|
3351
|
+
* With the wrapper the flags live on disk instead of in the prompt.
|
|
3352
|
+
*/
|
|
3353
|
+
async function writeCodexTaskScript(projectDir, cliPath) {
|
|
3354
|
+
const openclawDir = join(projectDir, '.openclaw');
|
|
3355
|
+
if (!existsSync(openclawDir) || !cliPath) return '';
|
|
3356
|
+
const body = [
|
|
3357
|
+
'#!/bin/sh',
|
|
3358
|
+
'# Managed by create-openclaw-bot — hand a desktop/GUI job to Codex and print its answer.',
|
|
3359
|
+
'# Usage: pc-task.sh "mở TeamViewer và đọc ID trên màn hình"',
|
|
3360
|
+
'# The sandbox flag is REQUIRED: the default read-only sandbox makes Codex refuse computer-use',
|
|
3361
|
+
'# with "Computer Use was not approved to use <app>".',
|
|
3362
|
+
'if [ $# -eq 0 ]; then echo "usage: pc-task.sh \\"việc cần làm\\"" >&2; exit 2; fi',
|
|
3363
|
+
`exec ${JSON.stringify(cliPath)} exec --skip-git-repo-check --sandbox danger-full-access "$@"`,
|
|
3364
|
+
'',
|
|
3365
|
+
].join('\n');
|
|
3366
|
+
let written = '';
|
|
3367
|
+
for (const entry of await fsp.readdir(openclawDir).catch(() => [])) {
|
|
3368
|
+
if (!entry.startsWith('workspace')) continue;
|
|
3369
|
+
const binDir = join(openclawDir, entry, 'bin');
|
|
3370
|
+
await fsp.mkdir(binDir, { recursive: true }).catch(() => {});
|
|
3371
|
+
const path = join(binDir, 'pc-task.sh');
|
|
3372
|
+
await fsp.writeFile(path, body, 'utf8').catch(() => {});
|
|
3373
|
+
await fsp.chmod(path, 0o755).catch(() => {});
|
|
3374
|
+
written = path;
|
|
3375
|
+
}
|
|
3376
|
+
return written;
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
/**
|
|
3380
|
+
* macOS/Windows privacy panes for the permissions PC control needs. The OS never lets an app
|
|
3381
|
+
* grant these for you (that is the point of TCC), so the best we can do is take the operator
|
|
3382
|
+
* straight to the right pane and — for screen capture — poke the API so the system prompt appears.
|
|
3383
|
+
*/
|
|
3384
|
+
function openPrivacyPane(kind) {
|
|
3385
|
+
const macPanes = {
|
|
3386
|
+
screen: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
|
|
3387
|
+
accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility',
|
|
3388
|
+
automation: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Automation',
|
|
3389
|
+
files: 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
|
|
3390
|
+
};
|
|
3391
|
+
const winPanes = {
|
|
3392
|
+
screen: 'ms-settings:privacy-general',
|
|
3393
|
+
accessibility: 'ms-settings:easeofaccess',
|
|
3394
|
+
automation: 'ms-settings:privacy-general',
|
|
3395
|
+
files: 'ms-settings:privacy-broadfilesystemaccess',
|
|
3396
|
+
};
|
|
3397
|
+
if (process.platform === 'darwin') {
|
|
3398
|
+
const url = macPanes[kind] || macPanes.screen;
|
|
3399
|
+
spawnDetached('open', [url]);
|
|
3400
|
+
return { opened: true, pane: url };
|
|
3401
|
+
}
|
|
3402
|
+
if (process.platform === 'win32') {
|
|
3403
|
+
const url = winPanes[kind] || winPanes.screen;
|
|
3404
|
+
spawnDetached('cmd', ['/c', 'start', '', url]);
|
|
3405
|
+
return { opened: true, pane: url };
|
|
3406
|
+
}
|
|
3407
|
+
return { opened: false, pane: '', reason: 'unsupported-platform' };
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3410
|
+
/**
|
|
3411
|
+
* Ask macOS for a screenshot. First call raises the Screen Recording prompt for THIS node binary
|
|
3412
|
+
* (the same one the native bot runs under); afterwards a non-empty file means the permission is
|
|
3413
|
+
* granted, and a failure/empty file means it is not.
|
|
3414
|
+
*/
|
|
3415
|
+
async function probeScreenPermission() {
|
|
3416
|
+
if (process.platform !== 'darwin') return { supported: false, granted: null };
|
|
3417
|
+
const shot = join(os.tmpdir(), `openclaw-screen-probe-${Date.now()}.png`);
|
|
3418
|
+
try {
|
|
3419
|
+
await run('/usr/sbin/screencapture', ['-x', '-t', 'png', shot], { shell: false });
|
|
3420
|
+
} catch (_) {
|
|
3421
|
+
// non-zero exit → denied (screencapture exits with an error when TCC blocks it)
|
|
3422
|
+
}
|
|
3423
|
+
let granted = false;
|
|
3424
|
+
try {
|
|
3425
|
+
granted = existsSync(shot) && (await fsp.stat(shot)).size > 1024;
|
|
3426
|
+
} catch (_) {
|
|
3427
|
+
granted = false;
|
|
3428
|
+
}
|
|
3429
|
+
await fsp.unlink(shot).catch(() => {});
|
|
3430
|
+
return { supported: true, granted };
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
async function readHostControlConfig(projectDir) {
|
|
3434
|
+
const path = hostControlConfigPath(projectDir);
|
|
3435
|
+
let cfg = {};
|
|
3436
|
+
try {
|
|
3437
|
+
if (existsSync(path)) cfg = JSON.parse(await fsp.readFile(path, 'utf8'));
|
|
3438
|
+
} catch (_) {
|
|
3439
|
+
cfg = {};
|
|
3440
|
+
}
|
|
3441
|
+
let changed = false;
|
|
3442
|
+
if (typeof cfg.enabled !== 'boolean') {
|
|
3443
|
+
cfg.enabled = false;
|
|
3444
|
+
changed = true;
|
|
3445
|
+
}
|
|
3446
|
+
if (!cfg.token) {
|
|
3447
|
+
cfg.token = _require('crypto').randomBytes(24).toString('hex');
|
|
3448
|
+
changed = true;
|
|
3449
|
+
}
|
|
3450
|
+
if (!cfg.apps || typeof cfg.apps !== 'object') {
|
|
3451
|
+
cfg.apps = detectHostApps();
|
|
3452
|
+
changed = true;
|
|
3453
|
+
}
|
|
3454
|
+
if (!cfg.commands || typeof cfg.commands !== 'object') {
|
|
3455
|
+
cfg.commands = detectHostCommands();
|
|
3456
|
+
changed = true;
|
|
3457
|
+
}
|
|
3458
|
+
if (changed) {
|
|
3459
|
+
await fsp.mkdir(dirname(path), { recursive: true }).catch(() => {});
|
|
3460
|
+
await fsp.writeFile(path, JSON.stringify(cfg, null, 2), 'utf8').catch(() => {});
|
|
3461
|
+
}
|
|
3462
|
+
return cfg;
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
/** Launch a host program detached, so it outlives this request. */
|
|
3466
|
+
function spawnDetached(command, args) {
|
|
3467
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: false });
|
|
3468
|
+
child.on('error', (err) => sendLog(`[host-control] Không chạy được "${command}": ${err.message}`));
|
|
3469
|
+
child.unref();
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
function openHostApp(target) {
|
|
3473
|
+
if (process.platform === 'win32') {
|
|
3474
|
+
// `start` needs a shell; the empty "" is the window title cmd expects before the path.
|
|
3475
|
+
spawnDetached('cmd', ['/c', 'start', '', target]);
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
if (process.platform === 'darwin') {
|
|
3479
|
+
spawnDetached('open', [target]);
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
spawnDetached('xdg-open', [target]);
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
/**
|
|
3486
|
+
* Run an allow-listed CLI (e.g. Claude Code) and return its output. Unlike openHostApp this is
|
|
3487
|
+
* NOT detached: we wait for it, capture stdout/stderr (capped), and enforce a timeout. No shell
|
|
3488
|
+
* (shell:false) so args are literal — no injection; the executable is fixed by the allow-list.
|
|
3489
|
+
*/
|
|
3490
|
+
function runHostCommand(res, name, bin, args, input, timeoutMs) {
|
|
3491
|
+
const MAX_OUT = 200_000; // ~200 KB cap per stream, so a runaway process can't flood the reply
|
|
3492
|
+
return new Promise((resolveP) => {
|
|
3493
|
+
let out = '';
|
|
3494
|
+
let err = '';
|
|
3495
|
+
let settled = false;
|
|
3496
|
+
const finish = (payload, status) => {
|
|
3497
|
+
if (settled) return;
|
|
3498
|
+
settled = true;
|
|
3499
|
+
clearTimeout(timer);
|
|
3500
|
+
json(res, payload, status);
|
|
3501
|
+
resolveP();
|
|
3502
|
+
};
|
|
3503
|
+
let child;
|
|
3504
|
+
try {
|
|
3505
|
+
child = spawn(bin, args, { shell: false, windowsHide: true });
|
|
3506
|
+
} catch (e) {
|
|
3507
|
+
return finish({ ok: false, error: e.message }, 500);
|
|
3508
|
+
}
|
|
3509
|
+
const timer = setTimeout(() => {
|
|
3510
|
+
try { child.kill('SIGKILL'); } catch (_) {}
|
|
3511
|
+
finish({ ok: false, error: `timeout after ${timeoutMs}ms`, timedOut: true, stdout: out.slice(0, MAX_OUT), stderr: err.slice(0, MAX_OUT) }, 504);
|
|
3512
|
+
}, timeoutMs);
|
|
3513
|
+
child.stdout?.on('data', (d) => { if (out.length < MAX_OUT) out += d.toString(); });
|
|
3514
|
+
child.stderr?.on('data', (d) => { if (err.length < MAX_OUT) err += d.toString(); });
|
|
3515
|
+
child.on('error', (e) => finish({ ok: false, error: e.message }, 500));
|
|
3516
|
+
child.on('close', (code) => {
|
|
3517
|
+
sendLog(`[host-control] Đã chạy "${name}" (exit ${code}).`);
|
|
3518
|
+
finish({ ok: code === 0, command: name, code, stdout: out.slice(0, MAX_OUT), stderr: err.slice(0, MAX_OUT) }, 200);
|
|
3519
|
+
});
|
|
3520
|
+
if (input != null) { try { child.stdin.write(input); } catch (_) {} }
|
|
3521
|
+
try { child.stdin.end(); } catch (_) {}
|
|
3522
|
+
});
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
async function handleHostControl(req, res, projectDir) {
|
|
3526
|
+
const cfg = await readHostControlConfig(projectDir);
|
|
3527
|
+
const url = new URL(req.url, 'http://localhost');
|
|
3528
|
+
const presented = req.headers['x-openclaw-token'] || url.searchParams.get('token') || '';
|
|
3529
|
+
if (!cfg.enabled) return json(res, { ok: false, error: 'host control is disabled' }, 403);
|
|
3530
|
+
if (presented !== cfg.token) return json(res, { ok: false, error: 'invalid token' }, 401);
|
|
3531
|
+
|
|
3532
|
+
if (url.pathname === '/api/browser/start-chrome' && req.method === 'POST') {
|
|
3533
|
+
try {
|
|
3534
|
+
return json(res, await startChromeDebug());
|
|
3535
|
+
} catch (err) {
|
|
3536
|
+
return json(res, { ok: false, error: err.message }, err.status || 500);
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
if (url.pathname === '/api/host/apps' && req.method === 'GET') {
|
|
3540
|
+
return json(res, { ok: true, apps: Object.keys(cfg.apps || {}), commands: Object.keys(cfg.commands || {}), platform: process.platform });
|
|
3541
|
+
}
|
|
3542
|
+
if (url.pathname === '/api/host/exec' && req.method === 'POST') {
|
|
3543
|
+
const body = await readJson(req).catch(() => ({}));
|
|
3544
|
+
const name = String(body.command || '').trim().toLowerCase();
|
|
3545
|
+
if (!name) return json(res, { ok: false, error: 'missing "command"' }, 400);
|
|
3546
|
+
const bin = (cfg.commands || {})[name];
|
|
3547
|
+
if (!bin) {
|
|
3548
|
+
return json(res, {
|
|
3549
|
+
ok: false,
|
|
3550
|
+
error: `"${name}" is not in this machine's command list`,
|
|
3551
|
+
commands: Object.keys(cfg.commands || {}),
|
|
3552
|
+
}, 404);
|
|
3553
|
+
}
|
|
3554
|
+
// Args are passed literally (spawn with shell:false) so nothing in them is re-interpreted
|
|
3555
|
+
// by a shell — the executable is fixed to the allow-listed path, callers cannot pick a
|
|
3556
|
+
// different binary or inject a second command.
|
|
3557
|
+
const args = Array.isArray(body.args) ? body.args.map((a) => String(a)) : [];
|
|
3558
|
+
const input = body.input != null ? String(body.input) : null;
|
|
3559
|
+
const timeoutMs = Math.min(Math.max(Number(body.timeoutMs) || 180000, 1000), 600000);
|
|
3560
|
+
return runHostCommand(res, name, bin, args, input, timeoutMs);
|
|
3561
|
+
}
|
|
3562
|
+
if (url.pathname === '/api/host/open' && req.method === 'POST') {
|
|
3563
|
+
const body = await readJson(req).catch(() => ({}));
|
|
3564
|
+
const key = String(body.app || body.target || '').trim();
|
|
3565
|
+
if (!key) return json(res, { ok: false, error: 'missing "app"' }, 400);
|
|
3566
|
+
const path = (cfg.apps || {})[key.toLowerCase()];
|
|
3567
|
+
if (!path) {
|
|
3568
|
+
return json(res, {
|
|
3569
|
+
ok: false,
|
|
3570
|
+
error: `"${key}" is not in this machine's app list`,
|
|
3571
|
+
apps: Object.keys(cfg.apps || {}),
|
|
3572
|
+
}, 404);
|
|
3573
|
+
}
|
|
3574
|
+
openHostApp(path);
|
|
3575
|
+
sendLog(`[host-control] Đã mở "${key}" trên máy (${path}).`);
|
|
3576
|
+
return json(res, { ok: true, app: key, path });
|
|
3577
|
+
}
|
|
3578
|
+
return json(res, { ok: false, error: 'unknown endpoint' }, 404);
|
|
3579
|
+
}
|
|
3580
|
+
|
|
3581
|
+
/**
|
|
3582
|
+
* Teach every bot in the project how to reach the host-control service, and hand it the
|
|
3583
|
+
* token. Written into TOOLS.md as a managed block so flipping the switch off removes it
|
|
3584
|
+
* again — a bot that still had the instructions would keep trying an endpoint that now
|
|
3585
|
+
* refuses. `host.docker.internal` resolves in the container on every OS because the
|
|
3586
|
+
* generated compose maps it to host-gateway.
|
|
3587
|
+
*/
|
|
3588
|
+
async function writeHostControlAccess(projectDir, cfg) {
|
|
3589
|
+
const openclawDir = join(projectDir, '.openclaw');
|
|
3590
|
+
if (!existsSync(openclawDir)) return;
|
|
3591
|
+
const native = isNativeProject(projectDir);
|
|
3592
|
+
// Native bots run on the host itself; host.docker.internal only resolves from inside a container,
|
|
3593
|
+
// so a native bot curling it fails ("could not connect"). Use loopback there instead.
|
|
3594
|
+
const base = native ? `http://127.0.0.1:${HOST_CONTROL_PORT}` : `http://host.docker.internal:${HOST_CONTROL_PORT}`;
|
|
3595
|
+
const apps = Object.keys(cfg.apps || {});
|
|
3596
|
+
const commands = Object.keys(cfg.commands || {});
|
|
3597
|
+
const execBlock = commands.length ? [
|
|
3598
|
+
'',
|
|
3599
|
+
'Chạy một CLI trên máy chủ và LẤY KẾT QUẢ về (chỉ lệnh trong danh sách; trả `{ok,code,stdout,stderr}`).',
|
|
3600
|
+
'Dùng để giao việc cho công cụ dòng lệnh, ví dụ Claude Code:',
|
|
3601
|
+
'',
|
|
3602
|
+
'```sh',
|
|
3603
|
+
`curl -s -X POST ${base}/api/host/exec -H "x-openclaw-token: ${cfg.token}" \\`,
|
|
3604
|
+
' -H "content-type: application/json" -d \'{"command":"claude","args":["-p","tóm tắt repo hiện tại"]}\'',
|
|
3605
|
+
'```',
|
|
3606
|
+
'',
|
|
3607
|
+
`Lệnh khả dụng: ${commands.map((c) => `\`${c}\``).join(', ')}. Lệnh mặc định timeout 180s, output tối đa ~200KB/luồng.`,
|
|
3608
|
+
] : [];
|
|
3609
|
+
// Screen capture / recording — only advertised when the operator granted the matching tool, so
|
|
3610
|
+
// the bot never tries a binary that is not on this machine's allow-list.
|
|
3611
|
+
// Windows has no capture binary to allow-list (PowerShell does it inline), so the section shows
|
|
3612
|
+
// up there too — a native bot runs the command itself, the allow-list only gates the bridge.
|
|
3613
|
+
const hasCapture = commands.includes('screencapture') || commands.includes('screenshot') || commands.includes('ffmpeg') || (native && process.platform === 'win32');
|
|
3614
|
+
const captureBlock = hasCapture ? [
|
|
3615
|
+
'',
|
|
3616
|
+
'### Chụp / quay màn hình',
|
|
3617
|
+
'',
|
|
3618
|
+
...(commands.includes('screencapture') ? [
|
|
3619
|
+
'- Chụp: `screencapture -x /tmp/shot.png` (thêm `-R x,y,w,h` để chụp một vùng, `-l <windowid>` chụp 1 cửa sổ).',
|
|
3620
|
+
'- Quay: `screencapture -v -V 10 /tmp/rec.mov` (quay 10 giây rồi tự dừng).',
|
|
3621
|
+
] : []),
|
|
3622
|
+
...(commands.includes('screenshot') ? ['- Chụp: dùng lệnh `screenshot` (công cụ chụp của desktop này) với đường dẫn file đầu ra.'] : []),
|
|
3623
|
+
...(native && process.platform === 'win32' ? [
|
|
3624
|
+
'- Chụp (Windows): `powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bm=New-Object Drawing.Bitmap $b.Width,$b.Height; [Drawing.Graphics]::FromImage($bm).CopyFromScreen($b.Location,[Drawing.Point]::Empty,$b.Size); $bm.Save(\'C:\\Temp\\shot.png\')"` (tạo sẵn thư mục đích).',
|
|
3625
|
+
] : []),
|
|
3626
|
+
...(commands.includes('ffmpeg') ? ['- Quay bằng `ffmpeg` khi cần định dạng khác (macOS: `-f avfoundation`, Linux: `-f x11grab`, Windows: `-f gdigrab -i desktop`).'] : []),
|
|
3627
|
+
'',
|
|
3628
|
+
'Chụp xong thì ĐỌC file ảnh bằng tool đọc ảnh để phân tích, rồi xoá file tạm. Lần đầu macOS sẽ hỏi quyền **Screen Recording** cho `node`: nếu ảnh ra đen/rỗng hoặc lệnh lỗi quyền thì nhờ chủ bấm "Cấp quyền chụp/quay màn hình" trong dashboard, đừng thử vòng khác.',
|
|
3629
|
+
] : [];
|
|
3630
|
+
const scriptCommands = commands.filter((c) => c === 'node' || c === 'npx' || c === 'codex' || c === 'claude');
|
|
3631
|
+
const scriptBlock = scriptCommands.length ? [
|
|
3632
|
+
'',
|
|
3633
|
+
'### Chạy script & giao việc cho CLI khác',
|
|
3634
|
+
'',
|
|
3635
|
+
`Chủ đã cho phép: ${scriptCommands.map((c) => `\`${c}\``).join(', ')} — dùng cho việc tự động hoá nhỏ (ví dụ \`node -e "..."\`, \`node script.js\`).`,
|
|
3636
|
+
...(commands.includes('codex') ? [
|
|
3637
|
+
'- Giao việc cho **Codex** (chạy ngầm, lấy kết quả text): `codex exec --skip-git-repo-check "việc cần làm"`. Việc cần nhìn/điều khiển màn hình thì thêm `--sandbox danger-full-access` (xem mục dưới). Lượt này tiêu quota gói ChatGPT của chủ, nên chỉ dùng khi chủ yêu cầu và mô tả việc gọn.',
|
|
3638
|
+
] : []),
|
|
3639
|
+
...(commands.includes('claude') ? [
|
|
3640
|
+
'- Giao việc cho **Claude Code**: `claude -p "việc cần làm"` (một lượt, trả stdout).',
|
|
3641
|
+
] : []),
|
|
3642
|
+
'Đây là quyền chạy mã tuỳ ý trên máy chủ: chỉ chạy khi chủ yêu cầu rõ, không cài thêm gì, không sửa file ngoài phạm vi được yêu cầu.',
|
|
3643
|
+
] : [];
|
|
3644
|
+
const startTag = '<!-- OPENCLAW:HOST_CONTROL:START -->';
|
|
3645
|
+
const endTag = '<!-- OPENCLAW:HOST_CONTROL:END -->';
|
|
3646
|
+
// NATIVE: the bot runs directly on the host with `exec`, so it opens apps with the OS command —
|
|
3647
|
+
// no bridge, no host.docker.internal (which doesn't resolve off-container anyway). DOCKER: the
|
|
3648
|
+
// bot is in a container and can't see the desktop, so it must call the installer's host service.
|
|
3649
|
+
const nativeBlock = [
|
|
3650
|
+
startTag,
|
|
3651
|
+
'',
|
|
3652
|
+
'## 🖥️ Điều khiển máy của chủ (host control — chế độ native)',
|
|
3653
|
+
'',
|
|
3654
|
+
'Bạn chạy TRỰC TIẾP trên máy của chủ và có quyền `exec`, nên mở ứng dụng bằng lệnh hệ điều hành — KHÔNG cần service/bridge nào (đừng dùng host.docker.internal hay curl cổng 18795):',
|
|
3655
|
+
'',
|
|
3656
|
+
'- macOS: `open -a "<Tên app>"` — ví dụ `open -a "TeamViewer"`',
|
|
3657
|
+
'- Linux: `xdg-open <app|url>` hoặc chạy binary trực tiếp',
|
|
3658
|
+
'- Windows: `cmd /c start "" "<app>"`',
|
|
3659
|
+
'',
|
|
3660
|
+
apps.length ? `App chủ gợi ý: ${apps.map((a) => `\`${a}\``).join(', ')}.` : 'Chủ chưa liệt kê app — mở theo tên app có trên máy.',
|
|
3661
|
+
...captureBlock,
|
|
3662
|
+
...scriptBlock,
|
|
3663
|
+
'',
|
|
3664
|
+
...(commands.includes('codex') ? [
|
|
3665
|
+
'### Điều khiển chuột/bàn phím & nhìn màn hình',
|
|
3666
|
+
'',
|
|
3667
|
+
'Bạn KHÔNG có tool điều khiển GUI. Việc đó giao cho **Codex** — nó có sẵn Computer Use. Dùng ĐÚNG script này (đã cài sẵn cờ cần thiết):',
|
|
3668
|
+
'',
|
|
3669
|
+
'```sh',
|
|
3670
|
+
'bin/pc-task.sh "mở TeamViewer, đọc mật khẩu đang hiện trên màn hình rồi in ra"',
|
|
3671
|
+
'```',
|
|
3672
|
+
'',
|
|
3673
|
+
'⚠️ **Đừng gọi `codex exec` trần.** Mặc định nó chạy sandbox read-only và tự từ chối computer-use với lỗi `Computer Use was not approved to use <app>` — KHÔNG phải do thiếu quyền Screen Recording, đừng bảo chủ đi cấp lại quyền. Script trên đã kèm `--sandbox danger-full-access` (mức `workspace-write` không đủ).',
|
|
3674
|
+
'',
|
|
3675
|
+
'Codex tự nhìn màn hình, click, gõ phím rồi trả kết quả text về cho bạn; bạn thuật lại cho chủ. Lưu ý:',
|
|
3676
|
+
'',
|
|
3677
|
+
'- Mô tả việc gọn nhưng đủ (mục tiêu + kết quả cần trả). Mỗi lượt tiêu quota gói ChatGPT của chủ, đừng gọi lung tung.',
|
|
3678
|
+
'- Việc GUI có thể chạy lâu: đặt timeout rộng, đừng kết luận thất bại sớm.',
|
|
3679
|
+
'- Computer Use từ chối vài app vì an toàn (Terminal, chính app ChatGPT/Codex): lỗi ghi rõ `not allowed to use the app ... for safety reasons` — báo chủ tự làm, đừng lách.',
|
|
3680
|
+
'- Điều khiển chuột/bàn phím hiện chỉ chạy trên macOS. Trên Windows/Linux bạn vẫn mở app, chụp màn hình và chạy script được.',
|
|
3681
|
+
'- Lỗi thật sự do thiếu quyền hệ điều hành sẽ nói về Screen Recording/Accessibility; chỉ khi đó mới nhờ chủ bấm nút cấp quyền trong dashboard. Luôn trích **nguyên văn** lỗi cho chủ thay vì đoán nguyên nhân.',
|
|
3682
|
+
'',
|
|
3683
|
+
] : []),
|
|
3684
|
+
'Chỉ mở app, chụp/quay màn hình hoặc điều khiển máy khi chủ yêu cầu rõ. Không tự ý chụp màn hình để "xem thử".',
|
|
3685
|
+
'',
|
|
3686
|
+
endTag,
|
|
3687
|
+
'',
|
|
3688
|
+
].join('\n');
|
|
3689
|
+
const dockerBlock = [
|
|
3690
|
+
startTag,
|
|
3691
|
+
'',
|
|
3692
|
+
'## 🖥️ Điều khiển máy của chủ (host control)',
|
|
3693
|
+
'',
|
|
3694
|
+
'Bạn chạy trong container nên không thấy desktop của chủ. Muốn mở Chrome hay một ứng dụng trên máy thật thì gọi service của installer (chạy trên máy chủ) bằng `exec`:',
|
|
3695
|
+
'',
|
|
3696
|
+
'```sh',
|
|
3697
|
+
`curl -s -X POST ${base}/api/browser/start-chrome -H "x-openclaw-token: ${cfg.token}"`,
|
|
3698
|
+
'```',
|
|
3699
|
+
'',
|
|
3700
|
+
'Mở ứng dụng (chỉ những app có trong danh sách của máy):',
|
|
3701
|
+
'',
|
|
3702
|
+
'```sh',
|
|
3703
|
+
`curl -s -X POST ${base}/api/host/open -H "x-openclaw-token: ${cfg.token}" \\`,
|
|
3704
|
+
' -H "content-type: application/json" -d \'{"app":"teamviewer"}\'',
|
|
3705
|
+
'```',
|
|
3706
|
+
'',
|
|
3707
|
+
'Xem danh sách app đang được phép:',
|
|
3708
|
+
'',
|
|
3709
|
+
'```sh',
|
|
3710
|
+
`curl -s ${base}/api/host/apps -H "x-openclaw-token: ${cfg.token}"`,
|
|
3711
|
+
'```',
|
|
3712
|
+
'',
|
|
3713
|
+
apps.length ? `App khả dụng trên máy này: ${apps.map((a) => `\`${a}\``).join(', ')}.` : 'Máy này chưa khai báo app nào — nhờ chủ thêm vào `.openclaw/host-control.json`.',
|
|
3714
|
+
...execBlock,
|
|
3715
|
+
// Docker only: a screenshot taken on the host lands on the HOST filesystem, which this
|
|
3716
|
+
// container cannot read — say so instead of letting the bot hunt for a missing file.
|
|
3717
|
+
...(hasCapture ? [
|
|
3718
|
+
'',
|
|
3719
|
+
'Chụp/quay màn hình chạy trên MÁY CHỦ nên file ảnh nằm ở ổ đĩa của chủ, container này KHÔNG đọc được. Chụp vào một thư mục đã mount cho bot (nếu có) hoặc nhờ chủ gửi ảnh; đừng đoán nội dung màn hình.',
|
|
3720
|
+
] : []),
|
|
3721
|
+
'',
|
|
3722
|
+
'Nếu trả về `host control is disabled` thì chủ chưa bật quyền này — nói chủ bật trong dashboard,',
|
|
3723
|
+
'đừng cố tìm đường khác. Chỉ mở app hoặc chạy lệnh khi chủ yêu cầu rõ.',
|
|
3724
|
+
'',
|
|
3725
|
+
endTag,
|
|
3726
|
+
'',
|
|
3727
|
+
].join('\n');
|
|
3728
|
+
const block = native ? nativeBlock : dockerBlock;
|
|
3729
|
+
for (const entry of await fsp.readdir(openclawDir).catch(() => [])) {
|
|
3730
|
+
if (!entry.startsWith('workspace')) continue;
|
|
3731
|
+
const toolsMd = join(openclawDir, entry, 'TOOLS.md');
|
|
3732
|
+
if (!existsSync(toolsMd)) continue;
|
|
3733
|
+
const current = await fsp.readFile(toolsMd, 'utf8');
|
|
3734
|
+
const withoutBlock = removeManagedBlockFrom(current, 'HOST_CONTROL');
|
|
3735
|
+
const next = cfg.enabled
|
|
3736
|
+
? `${withoutBlock.trimEnd()}\n\n${block}`
|
|
3737
|
+
: withoutBlock;
|
|
3738
|
+
if (next !== current) await fsp.writeFile(toolsMd, next, 'utf8');
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
/** Strip a managed block by id; shared with the browser-guide cleanup. */
|
|
3743
|
+
function removeManagedBlockFrom(content, blockId) {
|
|
3744
|
+
const startTag = `<!-- OPENCLAW:${blockId}:START -->`;
|
|
3745
|
+
const endTag = `<!-- OPENCLAW:${blockId}:END -->`;
|
|
3746
|
+
const startIdx = content.indexOf(startTag);
|
|
3747
|
+
const endIdx = content.indexOf(endTag);
|
|
3748
|
+
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return content;
|
|
3749
|
+
return `${content.substring(0, startIdx).trimEnd()}\n${content.substring(endIdx + endTag.length).trimStart()}`.trim() + '\n';
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
async function ensureHostControl(projectDir) {
|
|
3753
|
+
// Point the service at the project being enabled (re-points a service already running for
|
|
3754
|
+
// another project — the handler reads _hostControlProjectDir per request).
|
|
3755
|
+
_hostControlProjectDir = projectDir;
|
|
3756
|
+
const cfg = await readHostControlConfig(projectDir);
|
|
3757
|
+
if (!cfg.enabled) return { ok: false, reason: 'disabled' };
|
|
3758
|
+
// Desktop only. Opening TeamViewer or an app needs a GUI, so a headless server has nothing
|
|
3759
|
+
// to control — and, more importantly, it is where 0.0.0.0 would be a real exposure (a VPS
|
|
3760
|
+
// has a public IP). Refusing here means the service never binds on a headless box, so the
|
|
3761
|
+
// public-exposure question does not arise. A rare VPS-with-desktop can override with
|
|
3762
|
+
// OPENCLAW_HOST_CONTROL_ALLOW_HEADLESS=1.
|
|
3763
|
+
if (isHeadlessServer() && process.env.OPENCLAW_HOST_CONTROL_ALLOW_HEADLESS !== '1') {
|
|
3764
|
+
return { ok: false, reason: 'headless server — no desktop to control' };
|
|
3765
|
+
}
|
|
3766
|
+
if (_hostControlServer) return { ok: true, port: HOST_CONTROL_PORT };
|
|
3767
|
+
const bridgeIp = await getDockerBridgeIp().catch(() => null);
|
|
3768
|
+
const server = http.createServer((req, res) => {
|
|
3769
|
+
// Read the CURRENTLY active project each request, so re-pointing takes effect live.
|
|
3770
|
+
handleHostControl(req, res, _hostControlProjectDir || projectDir).catch((err) => json(res, { ok: false, error: err.message }, 500));
|
|
3771
|
+
});
|
|
3772
|
+
// Bind all interfaces: the container reaches the host by different addresses per platform —
|
|
3773
|
+
// docker0 (172.17.0.1) on native Linux, the Docker Desktop gateway (host.docker.internal,
|
|
3774
|
+
// e.g. 192.168.65.254) on macOS/Windows — and binding one misses the others. The token is
|
|
3775
|
+
// the guard here, not the interface: every request needs it, and the service only exists
|
|
3776
|
+
// while the operator has host control switched on.
|
|
3777
|
+
const bindOk = await new Promise((resolveP) => {
|
|
3778
|
+
server.once('error', () => resolveP(false));
|
|
3779
|
+
server.listen(HOST_CONTROL_PORT, '0.0.0.0', () => resolveP(true));
|
|
3780
|
+
});
|
|
3781
|
+
if (!bindOk) return { ok: false, reason: `port ${HOST_CONTROL_PORT} in use` };
|
|
3782
|
+
_hostControlServer = server;
|
|
3783
|
+
sendLog(`[host-control] Nghe ở 0.0.0.0:${HOST_CONTROL_PORT} (cần token) — bot có thể mở Chrome/app trên máy này.`);
|
|
3784
|
+
if (bridgeIp && process.platform === 'linux') {
|
|
3785
|
+
// ufw's default-deny drops container→host traffic silently. Scope the allow rule to the
|
|
3786
|
+
// private bridge address only, so opening the port here does not expose it to the LAN.
|
|
3787
|
+
run('sh', ['-c', `command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active" && ufw allow in to ${bridgeIp} port ${HOST_CONTROL_PORT} proto tcp comment "openclaw host-control (docker bridge only)" || true`])
|
|
3788
|
+
.catch(() => {});
|
|
3789
|
+
}
|
|
3790
|
+
return { ok: true, port: HOST_CONTROL_PORT, host: '0.0.0.0' };
|
|
3791
|
+
}
|
|
3792
|
+
|
|
2431
3793
|
async function ensureChromeRelay() {
|
|
2432
3794
|
if (_chromeRelayServer) return true;
|
|
2433
3795
|
const bridgeIp = await getDockerBridgeIp();
|
|
@@ -2458,6 +3820,77 @@ async function ensureChromeRelay() {
|
|
|
2458
3820
|
// this request. `--remote-allow-origins=*` is required by modern Chrome for cross-origin CDP.
|
|
2459
3821
|
// On a headless VPS there is no Chrome to open here — instead we start the bridge relay and hand
|
|
2460
3822
|
// back copy-paste commands so the user runs Chrome on THEIR machine + a reverse SSH tunnel.
|
|
3823
|
+
// Where Chrome keeps the operator's own profile, per OS. Chrome must not already be running
|
|
3824
|
+
// on it when we attach the debug port, which is why the callers close Chrome first.
|
|
3825
|
+
function defaultChromeProfileDir() {
|
|
3826
|
+
if (process.platform === 'win32') {
|
|
3827
|
+
const localAppData = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
|
|
3828
|
+
return join(localAppData, 'Google', 'Chrome', 'User Data');
|
|
3829
|
+
}
|
|
3830
|
+
if (process.platform === 'darwin') {
|
|
3831
|
+
return join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
|
|
3832
|
+
}
|
|
3833
|
+
return join(os.homedir(), '.config', 'google-chrome');
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
// The profile Chrome is actually launched with. Never the directory above: Chrome 136+ drops
|
|
3837
|
+
// --remote-debugging-port when it IS the default profile, so pointing there means Chrome opens
|
|
3838
|
+
// and port 9222 never answers — the failure the bot reports as "Chrome debug not connected".
|
|
3839
|
+
function debugChromeProfileDir() {
|
|
3840
|
+
if (process.platform === 'win32') {
|
|
3841
|
+
const localAppData = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
|
|
3842
|
+
return join(localAppData, ...CHROME_DEBUG_PROFILE_LEAF_WIN.split('\\'));
|
|
3843
|
+
}
|
|
3844
|
+
if (process.platform === 'darwin') return join(os.homedir(), ...CHROME_DEBUG_PROFILE_LEAF_MAC.split('/'));
|
|
3845
|
+
return join(os.homedir(), ...CHROME_DEBUG_PROFILE_LEAF_LINUX.split('/'));
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
// Seed it from the real profile once, so the bot inherits the operator's cookies, logins,
|
|
3849
|
+
// history and extensions instead of browsing as a brand-new profile (the clearest bot signal
|
|
3850
|
+
// a site can read). Caches are skipped — Chrome rebuilds those, and copying them turns a few
|
|
3851
|
+
// hundred MB into several GB. Best-effort: a profile that fails to copy still opens, just
|
|
3852
|
+
// signed out.
|
|
3853
|
+
async function copyChromeProfileTree(src, dst) {
|
|
3854
|
+
await fsp.mkdir(dst, { recursive: true });
|
|
3855
|
+
const entries = await fsp.readdir(src, { withFileTypes: true });
|
|
3856
|
+
for (const entry of entries) {
|
|
3857
|
+
if (CHROME_PROFILE_CACHE_DIRS.includes(entry.name)) continue;
|
|
3858
|
+
const from = join(src, entry.name);
|
|
3859
|
+
const to = join(dst, entry.name);
|
|
3860
|
+
// Per entry, because Chrome holds Windows locks on the profile it is using: one
|
|
3861
|
+
// unreadable file must not cost the operator the whole profile.
|
|
3862
|
+
try {
|
|
3863
|
+
if (entry.isDirectory()) await copyChromeProfileTree(from, to);
|
|
3864
|
+
else if (entry.isFile()) await fsp.copyFile(from, to);
|
|
3865
|
+
} catch {}
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
async function seedDebugChromeProfile(realDir, debugDir, log = () => {}) {
|
|
3870
|
+
// The marker, not the folder: a copy that ran while Chrome had the cookie database locked
|
|
3871
|
+
// leaves a signed-out profile behind, and that must be retried rather than kept forever.
|
|
3872
|
+
const marker = join(debugDir, '.openclaw-seeded');
|
|
3873
|
+
if (existsSync(marker)) return false;
|
|
3874
|
+
if (!existsSync(join(realDir, 'Default'))) return false;
|
|
3875
|
+
log(`[chrome] Lần đầu: đang chép profile Chrome sang ${debugDir} (bỏ cache)...`);
|
|
3876
|
+
try {
|
|
3877
|
+
await copyChromeProfileTree(join(realDir, 'Default'), join(debugDir, 'Default'));
|
|
3878
|
+
await fsp.copyFile(join(realDir, 'Local State'), join(debugDir, 'Local State')).catch(() => {});
|
|
3879
|
+
const cookiesCopied = ['Network/Cookies', 'Cookies']
|
|
3880
|
+
.some((rel) => existsSync(join(debugDir, 'Default', ...rel.split('/'))));
|
|
3881
|
+
if (!cookiesCopied) {
|
|
3882
|
+
log('[chrome] Chrome đang mở nên chưa chép được cookie/đăng nhập. Đóng hết Chrome rồi bấm lại để chép đủ.');
|
|
3883
|
+
return false;
|
|
3884
|
+
}
|
|
3885
|
+
await fsp.writeFile(marker, new Date().toISOString(), 'utf8').catch(() => {});
|
|
3886
|
+
return true;
|
|
3887
|
+
} catch (e) {
|
|
3888
|
+
log(`[chrome] Không chép được profile (${e.message}); Chrome vẫn mở nhưng chưa đăng nhập sẵn.`);
|
|
3889
|
+
await fsp.mkdir(join(debugDir, 'Default'), { recursive: true }).catch(() => {});
|
|
3890
|
+
return false;
|
|
3891
|
+
}
|
|
3892
|
+
}
|
|
3893
|
+
|
|
2461
3894
|
async function startChromeDebug() {
|
|
2462
3895
|
if (isHeadlessServer()) {
|
|
2463
3896
|
await ensureChromeRelay();
|
|
@@ -2467,8 +3900,10 @@ async function startChromeDebug() {
|
|
|
2467
3900
|
ok: true,
|
|
2468
3901
|
headless: true,
|
|
2469
3902
|
port: 9222,
|
|
2470
|
-
|
|
2471
|
-
|
|
3903
|
+
// Same dedicated profile directories the local button and the generated scripts use, so
|
|
3904
|
+
// an operator who has already run one of those keeps the session they signed in with.
|
|
3905
|
+
chromeCmdMac: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --user-data-dir="$HOME/${CHROME_DEBUG_PROFILE_LEAF_MAC}" --profile-directory=Default --remote-allow-origins='*'`,
|
|
3906
|
+
chromeCmdWin: `"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir="%LOCALAPPDATA%\\${CHROME_DEBUG_PROFILE_LEAF_WIN}" --profile-directory=Default --remote-allow-origins=*`,
|
|
2472
3907
|
tunnelCmd: `ssh -N -R 9222:127.0.0.1:9222 ${user}@${ip}`,
|
|
2473
3908
|
};
|
|
2474
3909
|
}
|
|
@@ -2479,10 +3914,19 @@ async function startChromeDebug() {
|
|
|
2479
3914
|
: 'Không tìm thấy Google Chrome. Hãy cài Chrome rồi thử lại.');
|
|
2480
3915
|
}
|
|
2481
3916
|
const port = 9222;
|
|
2482
|
-
|
|
3917
|
+
// Launch against a dedicated profile seeded from the operator's real one. A throwaway
|
|
3918
|
+
// profile is the clearest bot signal a site can read — no cookies, no logins, no history,
|
|
3919
|
+
// new on every run — and it also means the bot cannot use pages the operator is already
|
|
3920
|
+
// signed in to; the real profile itself cannot be used because Chrome 136+ drops the debug
|
|
3921
|
+
// port on it. The port is not what gets flagged: Chrome started this way carries no
|
|
3922
|
+
// --enable-automation, so navigator.webdriver stays false and there is no banner.
|
|
3923
|
+
// Set OPENCLAW_CHROME_PROFILE_DIR to point somewhere else (anything but the default profile).
|
|
3924
|
+
const userDataDir = process.env.OPENCLAW_CHROME_PROFILE_DIR || debugChromeProfileDir();
|
|
3925
|
+
await seedDebugChromeProfile(defaultChromeProfileDir(), userDataDir, sendLog);
|
|
2483
3926
|
const args = [
|
|
2484
3927
|
`--remote-debugging-port=${port}`,
|
|
2485
3928
|
`--user-data-dir=${userDataDir}`,
|
|
3929
|
+
'--profile-directory=Default',
|
|
2486
3930
|
'--remote-allow-origins=*',
|
|
2487
3931
|
'--no-first-run',
|
|
2488
3932
|
'--no-default-browser-check',
|
|
@@ -2586,11 +4030,20 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
|
|
|
2586
4030
|
state.os = osChoice;
|
|
2587
4031
|
state.startedAt = new Date().toISOString();
|
|
2588
4032
|
try {
|
|
4033
|
+
// Native runs on the host's own ports, so it must not land on the docker defaults: a machine
|
|
4034
|
+
// often has a docker project (or an SSH tunnel to a remote bot) already holding 18789/20128.
|
|
4035
|
+
if (mode === 'native') {
|
|
4036
|
+
if (gatewayPort === 18789) gatewayPort = NATIVE_DEFAULT_GATEWAY_PORT;
|
|
4037
|
+
if (routerPort === 20128) routerPort = NATIVE_DEFAULT_ROUTER_PORT;
|
|
4038
|
+
state.gatewayPort = gatewayPort;
|
|
4039
|
+
state.routerPort = routerPort;
|
|
4040
|
+
}
|
|
2589
4041
|
sendLog('OpenClaw local installer started');
|
|
2590
4042
|
sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
|
|
2591
4043
|
// Make sure Docker is present (auto-install on Linux/VPS) before doing any work — fail fast
|
|
2592
|
-
// with a clear message rather than deep inside `docker compose up`.
|
|
2593
|
-
|
|
4044
|
+
// with a clear message rather than deep inside `docker compose up`. Native mode has no
|
|
4045
|
+
// container, so it skips this entirely (that is much of the point of choosing it).
|
|
4046
|
+
if (mode !== 'native') await ensureDockerInstalled(osChoice);
|
|
2594
4047
|
await writeCoreProject({ projectDir, osChoice, mode, gatewayPort, routerPort, userTimezone });
|
|
2595
4048
|
await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
|
|
2596
4049
|
await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
|
|
@@ -2611,6 +4064,9 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
|
|
|
2611
4064
|
// making a brand-new project appear to crash-loop until the lease expired. The config is
|
|
2612
4065
|
// bind-mounted, so the resolved 9Router key does not require an immediate second recreate.
|
|
2613
4066
|
probeCacheClear();
|
|
4067
|
+
} else if (mode === 'native') {
|
|
4068
|
+
await startNativeRuntime({ projectDir, osChoice, gatewayPort, routerPort });
|
|
4069
|
+
probeCacheClear();
|
|
2614
4070
|
}
|
|
2615
4071
|
state.installed = true;
|
|
2616
4072
|
sendLog('✅ Install completed');
|
|
@@ -2791,6 +4247,33 @@ async function discoverDockerBotProjectRoots() {
|
|
|
2791
4247
|
return [...new Set(roots)];
|
|
2792
4248
|
}
|
|
2793
4249
|
|
|
4250
|
+
// Native installs have no container to inspect, so we can't detect them the way Docker bots are
|
|
4251
|
+
// found. Instead scan for the `.openclaw/native.json` marker one level under the home dir and the
|
|
4252
|
+
// launcher's parent — that covers the folders users actually pick (e.g. ~/openclaw-native, D:\bot)
|
|
4253
|
+
// without a full filesystem walk. Mirrors discoverDockerBotProjectRoots so discoverProjects can
|
|
4254
|
+
// surface native projects even when this install has no saved state for them.
|
|
4255
|
+
async function discoverNativeProjectRoots(rootProjectDir) {
|
|
4256
|
+
const roots = new Set();
|
|
4257
|
+
const bases = new Set();
|
|
4258
|
+
try { bases.add(os.homedir()); } catch {}
|
|
4259
|
+
if (rootProjectDir) bases.add(resolve(rootProjectDir, '..'));
|
|
4260
|
+
for (const base of bases) {
|
|
4261
|
+
let entries = [];
|
|
4262
|
+
try { entries = await fsp.readdir(base, { withFileTypes: true }); } catch { continue; }
|
|
4263
|
+
for (const ent of entries) {
|
|
4264
|
+
if (!ent.isDirectory() || ent.name.startsWith('.')) continue;
|
|
4265
|
+
const dir = join(base, ent.name);
|
|
4266
|
+
if (existsSync(nativeMarkerPath(dir)) && existsSync(join(dir, '.openclaw', 'openclaw.json'))) {
|
|
4267
|
+
roots.add(resolve(dir));
|
|
4268
|
+
}
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
if (rootProjectDir && existsSync(nativeMarkerPath(rootProjectDir)) && existsSync(join(rootProjectDir, '.openclaw', 'openclaw.json'))) {
|
|
4272
|
+
roots.add(resolve(rootProjectDir));
|
|
4273
|
+
}
|
|
4274
|
+
return [...roots];
|
|
4275
|
+
}
|
|
4276
|
+
|
|
2794
4277
|
async function findLatestProject(rootProjectDir) {
|
|
2795
4278
|
const realHome = getRealHomedir();
|
|
2796
4279
|
const roots = [
|
|
@@ -2872,6 +4355,14 @@ async function discoverProjects(rootProjectDir) {
|
|
|
2872
4355
|
}
|
|
2873
4356
|
}
|
|
2874
4357
|
|
|
4358
|
+
// Same idea for native installs — detect by marker since there is no container to inspect.
|
|
4359
|
+
for (const dr of await discoverNativeProjectRoots(rootProjectDir)) {
|
|
4360
|
+
if (!state.projects.some(p => resolve(p.projectDir) === resolve(dr))) {
|
|
4361
|
+
const meta = await buildProjectMeta(dr).catch(() => null);
|
|
4362
|
+
if (meta) state.projects.push(meta);
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
4365
|
+
|
|
2875
4366
|
if (state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json'))) {
|
|
2876
4367
|
const resolved = resolve(state.projectDir);
|
|
2877
4368
|
if (!state.projects.some(p => resolve(p.projectDir) === resolved)) {
|
|
@@ -3006,6 +4497,13 @@ async function deleteProjectFolder(projectDir, rootProjectDir) {
|
|
|
3006
4497
|
const rootHome = resolve(os.homedir());
|
|
3007
4498
|
if (!existsSync(join(resolved, '.openclaw', 'openclaw.json'))) throw httpError(404, 'openclaw.json not found in selected project');
|
|
3008
4499
|
if (resolved === home || resolved === rootHome || /^[A-Za-z]:\\?$/.test(resolved)) throw httpError(403, 'Refusing to delete home/root folder');
|
|
4500
|
+
// Native: uninstall the managed service before the folder goes, otherwise launchd/systemd keeps
|
|
4501
|
+
// relaunching a gateway whose config and workspace no longer exist.
|
|
4502
|
+
if (isNativeProject(resolved)) {
|
|
4503
|
+
sendLog(`[native] Removing gateway service for ${resolved}...`);
|
|
4504
|
+
await removeNativeRuntime(resolved).catch((err) => sendLog(`[native] Warning: ${err.message}`));
|
|
4505
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
4506
|
+
}
|
|
3009
4507
|
// Stop and remove Docker containers first to release host folder locks
|
|
3010
4508
|
const dockerComposeDir = join(resolved, 'docker', 'openclaw');
|
|
3011
4509
|
if (existsSync(join(dockerComposeDir, 'docker-compose.yml'))) {
|
|
@@ -3306,10 +4804,21 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
3306
4804
|
composeDir = join(projectDir, 'docker', 'openclaw');
|
|
3307
4805
|
}
|
|
3308
4806
|
|
|
3309
|
-
if (
|
|
4807
|
+
if (isNativeProject(projectDir)) {
|
|
4808
|
+
// Native: no container — install on the host with the project env (ocCapture) so the
|
|
4809
|
+
// skill lands in this project's workspace, then reload the managed gateway service.
|
|
4810
|
+
sendLog(`[skill] Installing/updating clawhub:${slug} natively for agent ${agentId}...`);
|
|
4811
|
+
const out = await ocCapture(projectDir, ['skills', 'install', slug, '--agent', agentId, '--force', '--acknowledge-clawhub-risk']);
|
|
4812
|
+
for (const line of `${out.stdout}\n${out.stderr}`.split(/\r?\n/).filter(Boolean)) sendLog(line);
|
|
4813
|
+
if (out.code !== 0 && !isSkillFolderExists(projectDir, agentId, slug)) {
|
|
4814
|
+
throw new Error(out.stderr || out.stdout || `Failed to install skill ${slug}.`);
|
|
4815
|
+
}
|
|
4816
|
+
sendLog('[skill] Restarting native gateway to apply skill...');
|
|
4817
|
+
await restartNativeRuntime(projectDir).catch((err) => sendLog(`[skill] restart skipped: ${err.message}`));
|
|
4818
|
+
} else if (composeDir) {
|
|
3310
4819
|
const botContainer = getBotContainerName(projectDir);
|
|
3311
4820
|
sendLog(`[skill] Installing/updating clawhub:${slug} inside container ${botContainer} for agent ${agentId}...`);
|
|
3312
|
-
|
|
4821
|
+
|
|
3313
4822
|
const cmd = `cd /home/node/project && openclaw skills install ${slug} --agent ${agentId} --force --acknowledge-clawhub-risk`;
|
|
3314
4823
|
const cmdOut = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', cmd], { cwd: projectDir, shell: false });
|
|
3315
4824
|
|
|
@@ -3355,10 +4864,19 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
3355
4864
|
// clawhub:latest like other plugins, so the dashboard "Update" button always fetches the newest
|
|
3356
4865
|
// published version (no tag pin to bump each release).
|
|
3357
4866
|
if (id === 'zalo-connect' || id === 'openclaw-zalo-connect') {
|
|
4867
|
+
const native = isNativeProject(projectDir);
|
|
3358
4868
|
let composeDir = null;
|
|
3359
|
-
if (
|
|
3360
|
-
|
|
3361
|
-
|
|
4869
|
+
if (!native) {
|
|
4870
|
+
if (existsSync(join(projectDir, 'docker-compose.yml'))) composeDir = projectDir;
|
|
4871
|
+
else if (existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'))) composeDir = join(projectDir, 'docker', 'openclaw');
|
|
4872
|
+
}
|
|
4873
|
+
if (native) {
|
|
4874
|
+
sendLog(`[zalo-connect] Installing/updating ${ZALO_CONNECT_PLUGIN_SPEC} natively...`);
|
|
4875
|
+
const out = await ocCapture(projectDir, ['plugins', 'install', ZALO_CONNECT_PLUGIN_SPEC, '--force', '--acknowledge-clawhub-risk']);
|
|
4876
|
+
if (out) for (const line of `${out.stdout}\n${out.stderr}`.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
|
|
4877
|
+
const okDir = existsSync(join(projectDir, '.openclaw', 'extensions', 'zalo-connect'));
|
|
4878
|
+
if (out.code !== 0 && !okDir) throw new Error(out.stderr || out.stdout || 'Failed to install zalo-connect.');
|
|
4879
|
+
} else if (composeDir) {
|
|
3362
4880
|
const botContainer = getBotContainerName(projectDir);
|
|
3363
4881
|
sendLog(`[zalo-connect] Installing/updating ${ZALO_CONNECT_PLUGIN_SPEC} inside ${botContainer}...`);
|
|
3364
4882
|
const cmd = `cd /home/node/project && openclaw plugins install ${ZALO_CONNECT_PLUGIN_SPEC} --force --acknowledge-clawhub-risk 2>&1`;
|
|
@@ -3378,7 +4896,10 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
3378
4896
|
if (!cfg.plugins.allow.includes('zalo-connect')) cfg.plugins.allow.push('zalo-connect');
|
|
3379
4897
|
await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
|
|
3380
4898
|
}
|
|
3381
|
-
if (
|
|
4899
|
+
if (native) {
|
|
4900
|
+
sendLog('[zalo-connect] Restarting native gateway to apply...');
|
|
4901
|
+
await restartNativeRuntime(projectDir).catch((err) => sendLog(`[zalo-connect] restart failed: ${err.message}`));
|
|
4902
|
+
} else if (composeDir) {
|
|
3382
4903
|
sendLog('[zalo-connect] Restarting container to apply...');
|
|
3383
4904
|
await run('docker', ['restart', getBotContainerName(projectDir)], { shell: false }).catch(() => {});
|
|
3384
4905
|
}
|
|
@@ -3394,19 +4915,28 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
3394
4915
|
if (installSpec.startsWith('clawhub:')) installArgs.push('--acknowledge-clawhub-risk');
|
|
3395
4916
|
|
|
3396
4917
|
let composeDir = null;
|
|
3397
|
-
if (
|
|
4918
|
+
if (isNativeProject(projectDir)) {
|
|
4919
|
+
// Native: no container to exec into — same CLI, run on the host with the project env so it
|
|
4920
|
+
// installs into this project's .openclaw/extensions instead of the default ~/.openclaw.
|
|
4921
|
+
composeDir = null;
|
|
4922
|
+
} else if (existsSync(join(projectDir, 'docker-compose.yml'))) {
|
|
3398
4923
|
composeDir = projectDir;
|
|
3399
4924
|
} else if (existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'))) {
|
|
3400
4925
|
composeDir = join(projectDir, 'docker', 'openclaw');
|
|
3401
4926
|
}
|
|
3402
4927
|
|
|
3403
|
-
if (composeDir) {
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
4928
|
+
if (composeDir || isNativeProject(projectDir)) {
|
|
4929
|
+
let cmdOut;
|
|
4930
|
+
if (isNativeProject(projectDir)) {
|
|
4931
|
+
sendLog(`[plugin] Installing/updating ${installSpec} natively...`);
|
|
4932
|
+
cmdOut = await ocCapture(projectDir, installArgs);
|
|
4933
|
+
} else {
|
|
4934
|
+
const botContainer = getBotContainerName(projectDir);
|
|
4935
|
+
sendLog(`[plugin] Installing/updating ${installSpec} inside container ${botContainer}...`);
|
|
4936
|
+
const cmd = `cd /home/node/project && openclaw ${installArgs.join(' ')}`;
|
|
4937
|
+
cmdOut = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', cmd], { cwd: projectDir, shell: false });
|
|
4938
|
+
}
|
|
4939
|
+
|
|
3410
4940
|
if (cmdOut) {
|
|
3411
4941
|
for (const line of `${cmdOut.stdout}\n${cmdOut.stderr}`.split(/\r?\n/).filter(Boolean)) sendLog(line);
|
|
3412
4942
|
}
|
|
@@ -3453,7 +4983,7 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
3453
4983
|
|
|
3454
4984
|
// Auto-expose zalo-mod dashboard port in docker-compose.yml
|
|
3455
4985
|
const isZaloMod = id === 'openclaw-zalo-mod' || id === 'zalo-mod';
|
|
3456
|
-
if (isZaloMod) {
|
|
4986
|
+
if (isZaloMod && composeDir) {
|
|
3457
4987
|
const composeFile = join(composeDir, 'docker-compose.yml');
|
|
3458
4988
|
if (existsSync(composeFile)) {
|
|
3459
4989
|
let composeContent = await fsp.readFile(composeFile, 'utf8');
|
|
@@ -3475,7 +5005,11 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
3475
5005
|
|
|
3476
5006
|
// Browser-automation plugin needs Docker rebuild for Playwright/Chromium deps
|
|
3477
5007
|
const isBrowserPlugin = id === 'openclaw-browser-automation' || id === 'browser-automation';
|
|
3478
|
-
if (
|
|
5008
|
+
if (isNativeProject(projectDir)) {
|
|
5009
|
+
// Native: no container — reload the managed gateway service so the plugin loads.
|
|
5010
|
+
sendLog('[plugin] Restarting native gateway to apply plugin...');
|
|
5011
|
+
await restartNativeRuntime(projectDir).catch((err) => sendLog(`[plugin] restart failed: ${err.message}`));
|
|
5012
|
+
} else if (isBrowserPlugin && composeDir) {
|
|
3479
5013
|
await patchBrowserAutomationHostPreference(projectDir, aliases, sendLog);
|
|
3480
5014
|
sendLog(`[plugin] Browser plugin requires Docker rebuild for Playwright/Chromium...`);
|
|
3481
5015
|
const svcName = getBotServiceName(projectDir);
|
|
@@ -3932,9 +5466,84 @@ async function handler(req, res, rootProjectDir) {
|
|
|
3932
5466
|
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
3933
5467
|
return json(res, await addBotMount(projectDir, body.hostPath, body.mountName));
|
|
3934
5468
|
}
|
|
3935
|
-
if (url.pathname === '/api/browser/start-chrome
|
|
5469
|
+
if ((url.pathname === '/api/browser/start-chrome' || url.pathname === '/api/browser/start-chrome-debug')
|
|
5470
|
+
&& req.method === 'POST') {
|
|
5471
|
+
// start-chrome-debug is the old path; kept so an already-open dashboard keeps working.
|
|
3936
5472
|
return json(res, await startChromeDebug());
|
|
3937
5473
|
}
|
|
5474
|
+
// Host control: read/flip the switch and see which apps this machine offers. The bot does
|
|
5475
|
+
// not come through here (the dashboard is loopback-only) — it calls the bridge-bound
|
|
5476
|
+
// service from ensureHostControl.
|
|
5477
|
+
if (url.pathname === '/api/host/control' && req.method === 'GET') {
|
|
5478
|
+
// Target the SELECTED project (not the launch root), so host-control provisions the bot
|
|
5479
|
+
// the operator is actually looking at — a connected project can differ from rootProjectDir.
|
|
5480
|
+
const projectDir = await resolveProjectDir(rootProjectDir, {});
|
|
5481
|
+
const cfg = await readHostControlConfig(projectDir);
|
|
5482
|
+
return json(res, {
|
|
5483
|
+
ok: true,
|
|
5484
|
+
enabled: cfg.enabled,
|
|
5485
|
+
port: HOST_CONTROL_PORT,
|
|
5486
|
+
apps: Object.keys(cfg.apps || {}),
|
|
5487
|
+
commands: Object.keys(cfg.commands || {}),
|
|
5488
|
+
running: Boolean(_hostControlServer),
|
|
5489
|
+
native: isNativeProject(projectDir),
|
|
5490
|
+
// What enabling will additionally grant, so the confirm dialog can spell it out.
|
|
5491
|
+
grants: Object.keys(detectHostCapabilityCommands()),
|
|
5492
|
+
codexApp: detectCodexApp(),
|
|
5493
|
+
});
|
|
5494
|
+
}
|
|
5495
|
+
if (url.pathname === '/api/host/control' && req.method === 'POST') {
|
|
5496
|
+
const body = await readJson(req).catch(() => ({}));
|
|
5497
|
+
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
5498
|
+
const cfg = await readHostControlConfig(projectDir);
|
|
5499
|
+
if (typeof body.enabled === 'boolean') cfg.enabled = body.enabled;
|
|
5500
|
+
if (body.apps && typeof body.apps === 'object') cfg.apps = body.apps;
|
|
5501
|
+
if (body.commands && typeof body.commands === 'object') cfg.commands = body.commands;
|
|
5502
|
+
// Turning PC control ON is the operator's explicit ask, so it is also where the screen
|
|
5503
|
+
// capture / recording and node-script permissions get granted (opt out with grants:false).
|
|
5504
|
+
const granted = cfg.enabled && body.grants !== false ? grantHostCapabilities(cfg) : [];
|
|
5505
|
+
if (granted.length) sendLog(`[host-control] Đã cấp thêm quyền chạy: ${granted.join(', ')}.`);
|
|
5506
|
+
await fsp.writeFile(hostControlConfigPath(projectDir), JSON.stringify(cfg, null, 2), 'utf8');
|
|
5507
|
+
let started = { ok: false, reason: 'disabled' };
|
|
5508
|
+
if (cfg.enabled) started = await ensureHostControl(projectDir);
|
|
5509
|
+
// Always rewrite the workspace guidance: enabling adds the block (with the token),
|
|
5510
|
+
// disabling strips it so a bot never keeps instructions for an endpoint now refusing.
|
|
5511
|
+
await writeHostControlAccess(projectDir, cfg).catch(() => {});
|
|
5512
|
+
sendLog(`[host-control] ${cfg.enabled ? 'Đã BẬT' : 'Đã TẮT'} quyền điều khiển máy cho bot.`);
|
|
5513
|
+
// Make sure the Codex desktop app can actually do GUI work, so `codex exec` is enough for
|
|
5514
|
+
// the bot: install computer-use into the app and repair its MCP registration. Nothing is
|
|
5515
|
+
// installed into the OpenClaw project and the gateway never restarts.
|
|
5516
|
+
let codex = null;
|
|
5517
|
+
if (cfg.enabled && body.codex !== false && (cfg.commands || {}).codex) {
|
|
5518
|
+
const app = detectCodexApp();
|
|
5519
|
+
codex = await ensureCodexComputerUsePlugin(app, detectCodexMarketplace())
|
|
5520
|
+
.then((r) => ({ ...r, app }))
|
|
5521
|
+
.catch((err) => ({ error: err.message, app }));
|
|
5522
|
+
// The wrapper carries the sandbox flag, so a bot cannot get the invocation wrong.
|
|
5523
|
+
codex.taskScript = await writeCodexTaskScript(projectDir, (cfg.commands || {}).codex).catch(() => '');
|
|
5524
|
+
}
|
|
5525
|
+
return json(res, {
|
|
5526
|
+
ok: true,
|
|
5527
|
+
enabled: cfg.enabled,
|
|
5528
|
+
started,
|
|
5529
|
+
apps: Object.keys(cfg.apps || {}),
|
|
5530
|
+
commands: Object.keys(cfg.commands || {}),
|
|
5531
|
+
granted,
|
|
5532
|
+
native: isNativeProject(projectDir),
|
|
5533
|
+
codex,
|
|
5534
|
+
});
|
|
5535
|
+
}
|
|
5536
|
+
// Take the operator to the OS privacy pane PC control needs (screen recording, accessibility).
|
|
5537
|
+
// The OS alone can grant these; `probe` additionally triggers the macOS screen-capture prompt
|
|
5538
|
+
// for this node binary — the same interpreter the native bot runs under.
|
|
5539
|
+
if (url.pathname === '/api/host/permissions' && req.method === 'POST') {
|
|
5540
|
+
const body = await readJson(req).catch(() => ({}));
|
|
5541
|
+
const kind = String(body.kind || 'screen').toLowerCase();
|
|
5542
|
+
const probe = kind === 'screen' && body.probe !== false ? await probeScreenPermission() : { supported: false, granted: null };
|
|
5543
|
+
const opened = openPrivacyPane(kind);
|
|
5544
|
+
sendLog(`[host-control] Mở cài đặt quyền "${kind}"${probe.supported ? ` (screen recording: ${probe.granted ? 'đã cấp' : 'chưa cấp'})` : ''}.`);
|
|
5545
|
+
return json(res, { ok: true, kind, ...opened, screen: probe, platform: process.platform });
|
|
5546
|
+
}
|
|
3938
5547
|
if (url.pathname === '/api/setup/update' && req.method === 'POST') {
|
|
3939
5548
|
const installerDir = resolve(__dirname, '../..');
|
|
3940
5549
|
const isGit = existsSync(resolve(installerDir, '.git'));
|
|
@@ -4311,6 +5920,9 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
4311
5920
|
ensureReopenShortcut();
|
|
4312
5921
|
if (openBrowser) openUrl(url);
|
|
4313
5922
|
printRemoteAccessHint(port).catch(() => {});
|
|
5923
|
+
// Bring the host-control service back up when the operator left it enabled, so the bot's
|
|
5924
|
+
// saved instructions keep working across installer restarts.
|
|
5925
|
+
ensureHostControl(projectDir).catch(() => {});
|
|
4314
5926
|
}
|
|
4315
5927
|
|
|
4316
|
-
export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations };
|
|
5928
|
+
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 };
|