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.
@@ -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('/') ? join(projectDir, workspaceRel.replace(/^\/home\/node\/project\/?/, '')) : join(projectDir, workspaceRel);
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-debug.bat\`
152
- - macOS/Linux: run \`./start-chrome-debug.sh\`
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 will try real host Chrome first. If Chrome debug is not available, it falls back to local headless Chromium, which is suitable for VPS/server use.
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
- const scriptToKeep = shouldKeepBat ? 'start-chrome-debug.bat' : 'start-chrome-debug.sh';
183
- const scriptToRemove = shouldKeepBat ? 'start-chrome-debug.sh' : 'start-chrome-debug.bat';
184
- const sourceScript = extensionDirs.map((dir) => join(dir, scriptToKeep)).find((file) => existsSync(file));
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
- await fsp.rm(join(workspacePath, scriptToRemove), { force: true }).catch(() => {});
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
- if (sourceScript) {
208
- await fsp.copyFile(sourceScript, join(pluginSkillPath, scriptToKeep)).catch(() => {});
209
- if (scriptToKeep.endsWith('.sh')) await fsp.chmod(join(pluginSkillPath, scriptToKeep), 0o755).catch(() => {});
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
- // Auto-migrate legacy /root/project paths → /home/node/project in openclaw.json
769
- await migrateContainerPaths(projectDir).catch(() => {});
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
- return { ...state, gatewayStatus, routerStatus, bots, credentials, runtimeVersions, activeModel, activeProvider };
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 composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
1746
- if (!existsSync(composeFile)) {
1747
- throw httpError(400, 'Zalo login cần project Docker đang chạy (không tìm thấy docker-compose.yml).');
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
- // NEVER poke the container while it is still booting: OpenClaw runs first-boot
1754
- // migrations under a state lease, and a docker exec/restart mid-migration wedges
1755
- // the lease and crash-loops the gateway. Wait for the container, then for the
1756
- // gateway to report the zalo-connect channel (the entrypoint installs the pinned
1757
- // plugin itself on first boot), and only fall back to an exec-install when the
1758
- // gateway is up but the plugin is genuinely absent (projects created before the
1759
- // backend-aware entrypoint existed).
1760
- const containerUp = await waitForDockerContainer(botContainer, 90000);
1761
- if (!containerUp) sendLog(`[zalo-connect] ${botContainer} chưa chạy sau 90s vẫn thử tiếp...`);
1762
- const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
1763
- if (!gatewayReady) {
1764
- 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' }));
1765
- if (String(check.stdout || '').trim() === 'MISSING') {
1766
- sendLog(`[zalo-connect] Plugin missing — installing ${ZALO_CONNECT_PLUGIN_SPEC}...`);
1767
- const installCmd = `cd /home/node/project && openclaw plugins install ${ZALO_CONNECT_PLUGIN_SPEC} --force --acknowledge-clawhub-risk 2>&1`;
1768
- const inst = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', installCmd], { cwd: projectDir, shell: false });
1769
- const instOut = `${inst.stdout}\n${inst.stderr}`;
1770
- for (const line of instOut.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
1771
- if (/installed plugin/i.test(instOut)) {
1772
- // Gateway must reload to pick the plugin up — safe here: the gateway is past
1773
- // its boot (we only reach this branch when it answered the exec above).
1774
- await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
1775
- await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
1776
- } else {
1777
- 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.');
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 missinginstalling ${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 pushQrFromContainer = async (pngPath) => {
1792
- 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{}`;
1793
- const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', js], { cwd: projectDir, shell: false }).catch(() => ({ stdout: '' }));
1794
- const b64 = extractCompletePngBase64(out.stdout);
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
- pushQrFromContainer(qrFile[1]).catch(() => {});
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 = spawn('docker', ['exec', botContainer, 'sh', '-lc', loginCmd], { cwd: projectDir, shell: false, windowsHide: true });
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
- sendLog(`[zalo-connect] Login saved. Restarting ${botContainer} so the Zalo channel connects...`);
1832
- await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[zalo-connect] Container restart failed: ${err.message}`));
1833
- sendLog(`[zalo-connect] ${botContainer} restarted. Try sending a Zalo message now.`);
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 (containerRunning) {
1979
- try {
1980
- const r = await runCapture('docker', ['exec', botContainer, 'openclaw', 'channels', 'status', '--json'], { cwd: projectDir, shell: false, timeout: 20000 });
1981
- statusJson = parseJsonText(String(r.stdout || '').trim(), null);
1982
- } catch {}
1983
- if (!statusJson) {
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 runCapture('docker', ['exec', botContainer, 'sh', '-lc', 'openclaw channels status 2>&1 || true'], { cwd: projectDir, shell: false, timeout: 20000 });
1986
- textStatus = String(r.stdout || '');
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 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))";
1991
- const r = await runCapture('docker', ['exec', botContainer, 'node', '-e', script], { cwd: projectDir, shell: false, timeout: 8000 });
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
- try {
1995
- const versions = await getContainerExtensionVersions(projectDir);
1996
- const zaloModVersion = versions['zalo-mod'] || versions['openclaw-zalo-mod'] || '';
1997
- if (zaloModVersion) {
1998
- meta.zaloModInstalled = true;
1999
- meta.zaloModVersion = zaloModVersion;
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
- } catch {}
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
- chromeCmdMac: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --user-data-dir="$HOME/.openclaw-chrome-debug" --remote-allow-origins='*'`,
2471
- chromeCmdWin: `"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir=%TEMP%\\openclaw-chrome-debug --remote-allow-origins=*`,
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
- const userDataDir = join(os.tmpdir(), 'openclaw-chrome-debug');
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
- await ensureDockerInstalled(osChoice);
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 (composeDir) {
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 (existsSync(join(projectDir, 'docker-compose.yml'))) composeDir = projectDir;
3360
- else if (existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'))) composeDir = join(projectDir, 'docker', 'openclaw');
3361
- if (composeDir) {
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 (composeDir) {
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 (existsSync(join(projectDir, 'docker-compose.yml'))) {
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
- const botContainer = getBotContainerName(projectDir);
3405
- sendLog(`[plugin] Installing/updating ${installSpec} inside container ${botContainer}...`);
3406
-
3407
- const cmd = `cd /home/node/project && openclaw ${installArgs.join(' ')}`;
3408
- const cmdOut = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', cmd], { cwd: projectDir, shell: false });
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 (isBrowserPlugin && composeDir) {
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-debug' && req.method === 'POST') {
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 };