create-openclaw-bot 5.14.1 → 5.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 }));
@@ -95,6 +112,150 @@ async function connectPreferredChrome() {
95
112
  return next;
96
113
  };
97
114
 
115
+ // The shipped script is replaced outright rather than tweaked: it launches Chrome against a
116
+ // throwaway profile under %TEMP%, which is the single clearest bot signal a site can read —
117
+ // no cookies, no logins, no history, no extensions, brand new on every run.
118
+ //
119
+ // The obvious fix, pointing --user-data-dir at the operator's real profile, is what earlier
120
+ // versions did and it stopped working: since Chrome 136 the browser silently refuses
121
+ // --remote-debugging-port when --user-data-dir IS the default profile directory. Chrome
122
+ // still opens, port 9222 never comes up, and the bot reports "Chrome debug not connected"
123
+ // no matter how many times the operator restarts it.
124
+ //
125
+ // So: a dedicated profile directory, seeded once from the real one. Cookies, logins,
126
+ // history and extensions come along (that was the point of using the real profile), the
127
+ // debug port is allowed because the directory is not the default one, and the operator's
128
+ // own Chrome can keep running next to it. Set OPENCLAW_CHROME_PROFILE_DIR to override —
129
+ // anything except the default profile directory works.
130
+ const chromeProfileCacheJunk = CHROME_PROFILE_CACHE_DIRS;
131
+
132
+ const startChromeBat = [
133
+ '@echo off',
134
+ `REM ${CHROME_SCRIPT_MARKER}`,
135
+ 'echo ====== OpenClaw - Chrome ======',
136
+ 'echo.',
137
+ '',
138
+ 'set "REAL_PROFILE=%LOCALAPPDATA%\\Google\\Chrome\\User Data"',
139
+ 'REM Chrome 136+ tu choi --remote-debugging-port khi user-data-dir la profile mac dinh,',
140
+ 'REM nen dung mot thu muc rieng (chep tu profile that o lan chay dau).',
141
+ `if "%OPENCLAW_CHROME_PROFILE_DIR%"=="" set "OPENCLAW_CHROME_PROFILE_DIR=%LOCALAPPDATA%\\${CHROME_DEBUG_PROFILE_LEAF_WIN}"`,
142
+ '',
143
+ 'set "CHROME_BIN=C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"',
144
+ 'if not exist "%CHROME_BIN%" set "CHROME_BIN=C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"',
145
+ 'if not exist "%CHROME_BIN%" set "CHROME_BIN=%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe"',
146
+ 'if not exist "%CHROME_BIN%" (',
147
+ ' echo LOI: Khong tim thay Google Chrome. Hay cai Chrome roi chay lai.',
148
+ ' pause',
149
+ ' exit /b 1',
150
+ ')',
151
+ '',
152
+ 'REM Chi dong ban Chrome dieu khien cu neu co - Chrome thuong cua ban van chay binh thuong,',
153
+ 'REM vi ban dieu khien dung profile rieng.',
154
+ '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',
155
+ // `timeout` dies with "Input redirection is not supported" whenever stdin is not a
156
+ // console — which is every run from the dashboard, a scheduled task or SSH. ping waits
157
+ // the same way and does not care.
158
+ 'ping -n 3 127.0.0.1 >nul',
159
+ '',
160
+ 'if not exist "%OPENCLAW_CHROME_PROFILE_DIR%\\Default" (',
161
+ // No parentheses in text inside an if-block: cmd closes the block on the first ")".
162
+ ' echo Lan dau: dang dong Chrome de chep profile - cookie, dang nhap...',
163
+ ' taskkill /F /IM chrome.exe >nul 2>&1',
164
+ ' ping -n 4 127.0.0.1 >nul',
165
+ ' echo Dang chep profile Chrome that sang "%OPENCLAW_CHROME_PROFILE_DIR%" ...',
166
+ ' robocopy "%REAL_PROFILE%\\Default" "%OPENCLAW_CHROME_PROFILE_DIR%\\Default" /E /R:0 /W:0 /NFL /NDL /NJH /NJS /NP ^',
167
+ ` /XD ${chromeProfileCacheJunk.map((n) => (n.includes(' ') ? `"${n}"` : n)).join(' ')} >nul`,
168
+ ' copy /Y "%REAL_PROFILE%\\Local State" "%OPENCLAW_CHROME_PROFILE_DIR%\\Local State" >nul',
169
+ ')',
170
+ '',
171
+ 'echo Dang mo Chrome - profile: %OPENCLAW_CHROME_PROFILE_DIR%',
172
+ 'start "" "%CHROME_BIN%" ^',
173
+ ' --remote-debugging-port=9222 ^',
174
+ ' --remote-allow-origins=* ^',
175
+ ' --user-data-dir="%OPENCLAW_CHROME_PROFILE_DIR%" ^',
176
+ ' --profile-directory=Default ^',
177
+ ' --no-first-run ^',
178
+ ' --no-default-browser-check',
179
+ 'ping -n 6 127.0.0.1 >nul',
180
+ '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 }"',
181
+ 'echo.',
182
+ 'pause',
183
+ '',
184
+ ].join('\r\n');
185
+
186
+ const startChromeSh = [
187
+ '#!/usr/bin/env bash',
188
+ `# ${CHROME_SCRIPT_MARKER}`,
189
+ '# ====== OpenClaw - Chrome (Mac/Linux) ======',
190
+ 'set -e',
191
+ 'echo "====== OpenClaw - Chrome ======"',
192
+ 'echo ""',
193
+ '',
194
+ 'if [[ "$OSTYPE" == "darwin"* ]]; then',
195
+ ' CHROME_BIN="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"',
196
+ ' [ ! -f "$CHROME_BIN" ] && CHROME_BIN="/Applications/Chromium.app/Contents/MacOS/Chromium"',
197
+ ' [ ! -f "$CHROME_BIN" ] && CHROME_BIN="/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary"',
198
+ ' REAL_PROFILE="$HOME/Library/Application Support/Google/Chrome"',
199
+ ` DEFAULT_DEBUG_PROFILE="$HOME/${CHROME_DEBUG_PROFILE_LEAF_MAC}"`,
200
+ 'else',
201
+ " CHROME_BIN=\"$(command -v google-chrome || command -v google-chrome-stable || command -v chromium-browser || command -v chromium || echo '')\"",
202
+ ' REAL_PROFILE="$HOME/.config/google-chrome"',
203
+ ` DEFAULT_DEBUG_PROFILE="$HOME/${CHROME_DEBUG_PROFILE_LEAF_LINUX}"`,
204
+ 'fi',
205
+ '[ -n "$CHROME_DEBUG_BIN" ] && CHROME_BIN="$CHROME_DEBUG_BIN"',
206
+ '',
207
+ 'if [ -z "$CHROME_BIN" ] || { [ ! -f "$CHROME_BIN" ] && [ ! -x "$CHROME_BIN" ]; }; then',
208
+ ' echo -e "\\033[31mERROR: Chrome/Chromium not found.\\033[0m"',
209
+ ' echo "Install Chrome or: export CHROME_DEBUG_BIN=/path/to/chrome"',
210
+ ' exit 1',
211
+ 'fi',
212
+ '',
213
+ '# Chrome 136+ refuses --remote-debugging-port on the default profile directory, so run a',
214
+ '# dedicated one seeded from the real profile (keeps cookies, logins, history, extensions).',
215
+ ': "${OPENCLAW_CHROME_PROFILE_DIR:=$DEFAULT_DEBUG_PROFILE}"',
216
+ '',
217
+ 'echo "Using: $CHROME_BIN"',
218
+ 'echo "Killing existing Chrome debug instances..."',
219
+ 'pkill -f -- "--remote-debugging-port=9222" 2>/dev/null || true',
220
+ 'sleep 2',
221
+ '',
222
+ 'if [ ! -d "$OPENCLAW_CHROME_PROFILE_DIR/Default" ] && [ -d "$REAL_PROFILE/Default" ]; then',
223
+ ' echo "First run: copying the real Chrome profile into $OPENCLAW_CHROME_PROFILE_DIR ..."',
224
+ ' mkdir -p "$OPENCLAW_CHROME_PROFILE_DIR/Default"',
225
+ ' cp -R "$REAL_PROFILE/Default/." "$OPENCLAW_CHROME_PROFILE_DIR/Default/" 2>/dev/null || true',
226
+ ' cp -f "$REAL_PROFILE/Local State" "$OPENCLAW_CHROME_PROFILE_DIR/Local State" 2>/dev/null || true',
227
+ ` for junk in ${chromeProfileCacheJunk.map((n) => `"${n}"`).join(' ')}; do`,
228
+ ' rm -rf "$OPENCLAW_CHROME_PROFILE_DIR/Default/$junk"',
229
+ ' done',
230
+ 'fi',
231
+ 'mkdir -p "$OPENCLAW_CHROME_PROFILE_DIR"',
232
+ '',
233
+ 'echo "Starting Chrome (profile: $OPENCLAW_CHROME_PROFILE_DIR)..."',
234
+ '"$CHROME_BIN" \\',
235
+ ' --remote-debugging-port=9222 \\',
236
+ ' --remote-allow-origins=* \\',
237
+ ' --user-data-dir="$OPENCLAW_CHROME_PROFILE_DIR" \\',
238
+ ' --profile-directory=Default \\',
239
+ ' --no-first-run \\',
240
+ ' --no-default-browser-check &',
241
+ '',
242
+ 'sleep 4',
243
+ 'if curl -s http://localhost:9222/json/version > /dev/null 2>&1; then',
244
+ ' echo -e "\\033[32mOK! Chrome is listening on port 9222.\\033[0m"',
245
+ 'else',
246
+ ' echo -e "\\033[31mERROR: Port 9222 not responding. Quit every Chrome window and run this again.\\033[0m"',
247
+ ' exit 1',
248
+ 'fi',
249
+ '',
250
+ ].join('\n');
251
+
252
+ // Scripts from before the dedicated-profile fix carry OPENCLAW_CHROME_PROFILE_DIR but point
253
+ // it at the default profile, so they are dead on Chrome 136+. The marker — not the variable
254
+ // name — decides whether a script is current; anything older is replaced.
255
+ const patchChromeDebugScript = (content, isBat) => (
256
+ content.includes(CHROME_SCRIPT_MARKER) ? content : (isBat ? startChromeBat : startChromeSh)
257
+ );
258
+
98
259
  const browserToolCandidates = new Set();
99
260
  const extensionDirs = [];
100
261
  for (const alias of aliases) {
@@ -111,7 +272,9 @@ async function connectPreferredChrome() {
111
272
  for (const a of cfg.agents?.list || []) {
112
273
  const workspaceRel = a.workspace || cfg.agents?.defaults?.workspace;
113
274
  if (!workspaceRel) continue;
114
- const workspacePath = workspaceRel.startsWith('/') ? join(projectDir, workspaceRel.replace(/^\/home\/node\/project\/?/, '')) : join(projectDir, workspaceRel);
275
+ const workspacePath = workspaceRel.startsWith('/')
276
+ ? (resolve(workspaceRel).startsWith(resolve(projectDir)) ? workspaceRel : join(projectDir, workspaceRel.replace(/^\/home\/node\/project\/?/, '').replace(/^\/root\/project\/?/, '')))
277
+ : join(projectDir, workspaceRel);
115
278
  workspaceDirs.add(workspacePath);
116
279
  browserToolCandidates.add(join(workspacePath, 'plugin-skills', 'browser-automation', 'browser-tool.js'));
117
280
  }
@@ -135,6 +298,25 @@ async function connectPreferredChrome() {
135
298
  sendLog(`[browser] Patched ${patched} browser-tool.js file(s) to prefer host Chrome debug before headless Chromium.`);
136
299
  }
137
300
 
301
+ // Patch the plugin's own copies too: the plugin re-syncs its skill folder into every
302
+ // workspace on startup, so an unpatched source would undo the profile change below.
303
+ let scriptsPatched = 0;
304
+ for (const dir of extensionDirs) {
305
+ for (const name of ['start-chrome-debug.bat', 'start-chrome-debug.sh']) {
306
+ const file = join(dir, name);
307
+ if (!existsSync(file)) continue;
308
+ const content = await fsp.readFile(file, 'utf8');
309
+ const next = patchChromeDebugScript(content, name.endsWith('.bat'));
310
+ if (next !== content) {
311
+ await fsp.writeFile(file, next, 'utf8');
312
+ scriptsPatched += 1;
313
+ }
314
+ }
315
+ }
316
+ if (scriptsPatched > 0) {
317
+ sendLog(`[browser] Patched ${scriptsPatched} start-chrome script(s) to use the real Chrome profile (set OPENCLAW_CHROME_PROFILE_DIR to override).`);
318
+ }
319
+
138
320
  const browserMd = `# Browser Automation
139
321
 
140
322
  This plugin skill owns browser automation only. For normal web search, use OpenClaw's built-in \`web_search\` capability.
@@ -148,10 +330,14 @@ Run commands from this folder or pass the full path from the workspace root:
148
330
 
149
331
  On a desktop machine, start real Chrome in debug mode before asking the bot to browse:
150
332
 
151
- - Windows: run \`start-chrome-debug.bat\`
152
- - macOS/Linux: run \`./start-chrome-debug.sh\`
333
+ - Windows: run \`start-chrome.bat\`
334
+ - macOS/Linux: run \`./start-chrome.sh\`
153
335
 
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.
336
+ 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.
337
+
338
+ 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.
339
+
340
+ **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
341
 
156
342
  ## Browser Commands
157
343
 
@@ -179,9 +365,13 @@ Do not call \`search-tool.js\`; browser-automation does not own search. Use \`we
179
365
 
180
366
  const hostOs = normalizeHostOs(await resolveProjectHostOs(projectDir));
181
367
  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));
368
+ // Shipped as start-chrome-debug.* by the plugin; delivered to the workspace as
369
+ // start-chrome.* it launches the operator's normal Chrome (with the debug port open),
370
+ // so "debug" in the name only ever made people think it was a developer-only thing.
371
+ const sourceName = shouldKeepBat ? 'start-chrome-debug.bat' : 'start-chrome-debug.sh';
372
+ const scriptToKeep = shouldKeepBat ? 'start-chrome.bat' : 'start-chrome.sh';
373
+ const legacyScripts = ['start-chrome-debug.bat', 'start-chrome-debug.sh', shouldKeepBat ? 'start-chrome.sh' : 'start-chrome.bat'];
374
+ const sourceScript = extensionDirs.map((dir) => join(dir, sourceName)).find((file) => existsSync(file));
185
375
  const sourceBrowserTool = extensionDirs.map((dir) => join(dir, 'browser-tool.js')).find((file) => existsSync(file));
186
376
 
187
377
  let sanitized = 0;
@@ -192,9 +382,11 @@ Do not call \`search-tool.js\`; browser-automation does not own search. Use \`we
192
382
  await fsp.rm(join(workspacePath, 'search-tool.js'), { force: true }).catch(() => {});
193
383
  await fsp.rm(join(workspacePath, 'browser-tool.js'), { force: true }).catch(() => {});
194
384
  await fsp.rm(join(workspacePath, 'BROWSER.md'), { force: true }).catch(() => {});
195
- await fsp.rm(join(workspacePath, scriptToRemove), { force: true }).catch(() => {});
385
+ for (const legacy of legacyScripts) {
386
+ await fsp.rm(join(workspacePath, legacy), { force: true }).catch(() => {});
387
+ await fsp.rm(join(pluginSkillPath, legacy), { force: true }).catch(() => {});
388
+ }
196
389
  await fsp.rm(join(workspacePath, scriptToKeep), { force: true }).catch(() => {});
197
- await fsp.rm(join(pluginSkillPath, scriptToRemove), { force: true }).catch(() => {});
198
390
  if (sourceBrowserTool) {
199
391
  const targetBrowserTool = join(pluginSkillPath, 'browser-tool.js');
200
392
  await fsp.copyFile(sourceBrowserTool, targetBrowserTool).catch(() => {});
@@ -205,8 +397,14 @@ Do not call \`search-tool.js\`; browser-automation does not own search. Use \`we
205
397
  }
206
398
  }
207
399
  if (sourceScript) {
208
- await fsp.copyFile(sourceScript, join(pluginSkillPath, scriptToKeep)).catch(() => {});
209
- if (scriptToKeep.endsWith('.sh')) await fsp.chmod(join(pluginSkillPath, scriptToKeep), 0o755).catch(() => {});
400
+ const targetScript = join(pluginSkillPath, scriptToKeep);
401
+ await fsp.copyFile(sourceScript, targetScript).catch(() => {});
402
+ if (existsSync(targetScript)) {
403
+ const scriptContent = await fsp.readFile(targetScript, 'utf8');
404
+ const nextScript = patchChromeDebugScript(scriptContent, scriptToKeep.endsWith('.bat'));
405
+ if (nextScript !== scriptContent) await fsp.writeFile(targetScript, nextScript, 'utf8');
406
+ }
407
+ if (scriptToKeep.endsWith('.sh')) await fsp.chmod(targetScript, 0o755).catch(() => {});
210
408
  }
211
409
  await fsp.writeFile(join(pluginSkillPath, 'BROWSER.md'), browserMd, 'utf8');
212
410
  for (const dirName of ['cl-stealth-search', 'openclaw-smart-search']) {
@@ -765,8 +963,15 @@ async function syncRuntimeState(projectDir, { full = false } = {}) {
765
963
  await removeEmptyWorkspaceAttestations(projectDir).catch(() => {});
766
964
  const firstSync = full || !_runtimeSynced.has(projectDir);
767
965
  if (firstSync) {
768
- // Auto-migrate legacy /root/project paths → /home/node/project in openclaw.json
769
- await migrateContainerPaths(projectDir).catch(() => {});
966
+ if (isNativeProject(projectDir)) {
967
+ // Native has no container: rewrite any Docker/legacy container path
968
+ // (/home/node/project, /root/project) to project-relative, or the gateway tries to
969
+ // mkdir '/home/node' on the host and fails every turn (bot never replies).
970
+ await migrateNativePaths(projectDir).catch(() => {});
971
+ } else {
972
+ // Auto-migrate legacy /root/project paths → /home/node/project in openclaw.json
973
+ await migrateContainerPaths(projectDir).catch(() => {});
974
+ }
770
975
  await applyResolved9RouterApiKey(projectDir).catch(() => {});
771
976
  }
772
977
  const rt = await detectRuntime(projectDir).catch(() => null);
@@ -799,6 +1004,43 @@ async function removeEmptyWorkspaceAttestations(projectDir) {
799
1004
  return true;
800
1005
  }
801
1006
 
1007
+ /**
1008
+ * Native counterpart of migrateContainerPaths. A native bot runs on the host with cwd = the
1009
+ * project dir, so any Docker/legacy container path baked into openclaw.json (e.g. an agent
1010
+ * `workspace` of "/home/node/project/.openclaw/workspace-x", left over from a bot created by an
1011
+ * older build or carried over from Docker) points at a directory that does not exist on the host —
1012
+ * the gateway then fails every turn with `ENOENT: mkdir '/home/node'` and the bot never replies.
1013
+ * Strip the container prefix so the path becomes project-relative (what bot-config-gen now emits).
1014
+ */
1015
+ async function migrateNativePaths(projectDir) {
1016
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
1017
+ if (!existsSync(cfgPath)) return;
1018
+ let cfg;
1019
+ try { cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8')); } catch { return; }
1020
+ // The native gateway runs with cwd = OPENCLAW_HOME (projectDir/.openclaw), while the setup
1021
+ // writes the workspace under projectDir/.openclaw/<name>. A relative value can't satisfy both:
1022
+ // ".openclaw/workspace-x" → runtime doubles it to .openclaw/.openclaw/workspace-x (blank persona)
1023
+ // "workspace-x" → setup's own resolver looks in projectDir/workspace-x
1024
+ // Only an ABSOLUTE host path is correct for both — the direct parallel of Docker's absolute
1025
+ // "/home/node/project/.openclaw/workspace-x". Normalise every agent's workspace to it.
1026
+ const wsRoot = join(projectDir, '.openclaw');
1027
+ let changed = false;
1028
+ const fix = (obj) => {
1029
+ if (!obj || typeof obj.workspace !== 'string' || !obj.workspace) return;
1030
+ const base = basename(obj.workspace.replace(/[\\/]+$/, ''));
1031
+ if (!base || base === '.' || base === '.openclaw') return;
1032
+ const abs = join(wsRoot, base);
1033
+ if (obj.workspace !== abs) { obj.workspace = abs; changed = true; }
1034
+ };
1035
+ for (const a of (cfg.agents?.list || [])) fix(a);
1036
+ fix(cfg.agents?.defaults);
1037
+ if (changed) {
1038
+ await fsp.copyFile(cfgPath, `${cfgPath}.bak`).catch(() => {});
1039
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
1040
+ sendLog('[migrate] Native: normalized agent workspace paths → absolute project paths (fixes doubled/container paths).');
1041
+ }
1042
+ }
1043
+
802
1044
  /**
803
1045
  * Migrate legacy /root/project/ paths to /home/node/project/ in openclaw.json.
804
1046
  * Old projects may have been created with /root/project/ which doesn't match the
@@ -1314,6 +1556,17 @@ async function deleteBotInProject(projectDir, agentId) {
1314
1556
  }
1315
1557
  if (cfg.channels?.telegram?.accounts?.[agentId]) delete cfg.channels.telegram.accounts[agentId];
1316
1558
 
1559
+ // Drop any channel orphaned by this deletion — no binding references it and it has no accounts
1560
+ // (e.g. a Telegram channel whose only bot was just removed). An enabled channel with no account
1561
+ // keeps erroring in `channels status` ("not configured") and shows a broken card.
1562
+ const stillReferenced = new Set((cfg.bindings || []).map((b) => b.match?.channel).filter(Boolean));
1563
+ for (const ch of new Set(removedBindings.map((b) => b.match?.channel).filter(Boolean))) {
1564
+ const chCfg = cfg.channels?.[ch];
1565
+ if (chCfg && !stillReferenced.has(ch) && Object.keys(chCfg.accounts || {}).length === 0) {
1566
+ delete cfg.channels[ch];
1567
+ }
1568
+ }
1569
+
1317
1570
  if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
1318
1571
  await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
1319
1572
  await syncExecApprovals(projectDir, cfg);
@@ -1385,7 +1638,10 @@ async function buildBotStatus() {
1385
1638
  const cap = (s) => String(s).toLowerCase() === 'openai' ? 'OpenAI' : String(s).toLowerCase() === '9router' ? '9Router' : s;
1386
1639
  activeProvider = cap(activeProvider);
1387
1640
 
1388
- return { ...state, gatewayStatus, routerStatus, bots, credentials, runtimeVersions, activeModel, activeProvider };
1641
+ // Resolved per project, not from the installer-wide state.mode: the operator can switch between
1642
+ // a docker project and a native one, and the UI hides/shows container-only actions on this.
1643
+ const deployMode = projectDeployMode(state.projectDir);
1644
+ return { ...state, deployMode, gatewayStatus, routerStatus, bots, credentials, runtimeVersions, activeModel, activeProvider };
1389
1645
  }
1390
1646
 
1391
1647
  async function createBotInProject(projectDir, body = {}, runtime = {}) {
@@ -1506,6 +1762,10 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
1506
1762
  validateOpenclawConfig(cfg);
1507
1763
  if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
1508
1764
  await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
1765
+ // Native gateway resolves a relative workspace against OPENCLAW_HOME (=projectDir/.openclaw),
1766
+ // so the generator's ".openclaw/workspace-x" would double. Rewrite to an absolute path now so
1767
+ // the bot reads its persona on the very first turn (not only after the next runtime sync).
1768
+ if (isNativeProject(projectDir)) await migrateNativePaths(projectDir).catch(() => {});
1509
1769
  await syncExecApprovals(projectDir, cfg);
1510
1770
 
1511
1771
  const hasScheduler = !!(cfg.tools?.alsoAllow || []).includes('group:automation');
@@ -1536,6 +1796,10 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
1536
1796
  // the token); don't pollute other channels' bot-meta.json with an empty appId.
1537
1797
  if (channel === 'fb-messenger') botMeta.appId = fbAppId;
1538
1798
  await writeBotMeta(projectDir, workspaceDir, botMeta);
1799
+ // PC control is granted per PROJECT, so a bot added afterwards must get the same instructions —
1800
+ // its TOOLS.md was just written fresh and would otherwise have no host-control block at all.
1801
+ const hostCfg = await readHostControlConfig(projectDir).catch(() => null);
1802
+ if (hostCfg?.enabled) await writeHostControlAccess(projectDir, hostCfg).catch(() => {});
1539
1803
 
1540
1804
  return { ok: true, agentId, accountId, channel, workspace: `.openclaw/${workspaceDir}`, warning };
1541
1805
  }
@@ -1721,6 +1985,30 @@ async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 900
1721
1985
  return ready;
1722
1986
  }
1723
1987
 
1988
+ // Native equivalent of waitForGatewayZaloReady: no container to exec into, so probe the
1989
+ // gateway's /health over loopback and read `channels status` through the host CLI (ocCapture).
1990
+ async function waitForNativeGatewayZaloReady(projectDir, timeoutMs = 90000, channelKeywords = ['zalo-connect', 'openclaw zalo connect']) {
1991
+ const started = Date.now();
1992
+ const meta = readNativeMeta(projectDir) || {};
1993
+ const port = String(meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
1994
+ let ready = false;
1995
+ let attempts = 0;
1996
+ while (Date.now() - started < timeoutMs) {
1997
+ attempts++;
1998
+ if (await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500)) {
1999
+ const st = await ocCapture(projectDir, ['channels', 'status']).catch(() => ({ stdout: '', stderr: '' }));
2000
+ const output = ((st.stdout || '') + ' ' + (st.stderr || '')).toLowerCase();
2001
+ if (channelKeywords.some((kw) => output.includes(kw))) { ready = true; break; }
2002
+ if (attempts > 2) sendLog('[zalo-connect] Gateway healthy but Zalo Connect is not loaded yet (' + Math.round((Date.now() - started) / 1000) + 's)...');
2003
+ } else if (attempts > 2 && attempts % 3 === 0) {
2004
+ sendLog('[zalo-connect] Waiting for native gateway... (' + Math.round((Date.now() - started) / 1000) + 's)');
2005
+ }
2006
+ await new Promise((r) => setTimeout(r, 5000));
2007
+ }
2008
+ if (!ready) sendLog('[zalo-connect] Native gateway readiness timeout after ' + Math.round(timeoutMs / 1000) + 's — proceeding anyway.');
2009
+ return ready;
2010
+ }
2011
+
1724
2012
  async function startZaloLogin(projectDir, agentId = "") {
1725
2013
  const cfgPath = join(projectDir, ".openclaw", "openclaw.json");
1726
2014
  if (!existsSync(cfgPath)) throw httpError(404, "openclaw.json not found");
@@ -1742,39 +2030,64 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
1742
2030
  if (zaloLoginInFlight) {
1743
2031
  return { message: 'Zalo login is already running. Keep this modal open...' };
1744
2032
  }
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).');
2033
+ const native = isNativeProject(projectDir);
2034
+ if (!native) {
2035
+ const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
2036
+ if (!existsSync(composeFile)) {
2037
+ throw httpError(400, 'Zalo login cần project Docker đang chạy (không tìm thấy docker-compose.yml).');
2038
+ }
1748
2039
  }
1749
2040
  zaloLoginInFlight = true;
1750
- const botContainer = getBotContainerName(projectDir);
2041
+ const botContainer = native ? '' : getBotContainerName(projectDir);
1751
2042
  sendLog(`[zalo-connect] Preparing QR login for account [${accountId}]...`);
1752
2043
  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.');
2044
+ if (native) {
2045
+ // No container: the gateway runs as a managed service on the host. Wait for it to
2046
+ // report the zalo-connect channel; if it never does and the plugin folder is absent,
2047
+ // install it on the host (into this project's .openclaw/extensions) and reload.
2048
+ const gatewayReady = await waitForNativeGatewayZaloReady(projectDir, 180000, ['zalo-connect']);
2049
+ if (!gatewayReady) {
2050
+ const extDir = join(projectDir, '.openclaw', 'extensions', 'zalo-connect');
2051
+ if (!existsSync(extDir)) {
2052
+ sendLog(`[zalo-connect] Plugin missinginstalling ${ZALO_CONNECT_PLUGIN_SPEC} natively...`);
2053
+ const inst = await ocCapture(projectDir, ['plugins', 'install', ZALO_CONNECT_PLUGIN_SPEC, '--force', '--acknowledge-clawhub-risk']);
2054
+ const instOut = `${inst.stdout}\n${inst.stderr}`;
2055
+ for (const line of instOut.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
2056
+ if (/installed plugin/i.test(instOut) || existsSync(extDir)) {
2057
+ await restartNativeRuntime(projectDir).catch((err) => sendLog(`[native] restart skipped/failed: ${err.message}`));
2058
+ await waitForNativeGatewayZaloReady(projectDir, 180000, ['zalo-connect']);
2059
+ } else {
2060
+ sendLog('[zalo-connect] Cài plugin không thành công — thử lại bằng nút "Đăng nhập Zalo".');
2061
+ }
2062
+ }
2063
+ }
2064
+ } else {
2065
+ // NEVER poke the container while it is still booting: OpenClaw runs first-boot
2066
+ // migrations under a state lease, and a docker exec/restart mid-migration wedges
2067
+ // the lease and crash-loops the gateway. Wait for the container, then for the
2068
+ // gateway to report the zalo-connect channel (the entrypoint installs the pinned
2069
+ // plugin itself on first boot), and only fall back to an exec-install when the
2070
+ // gateway is up but the plugin is genuinely absent (projects created before the
2071
+ // backend-aware entrypoint existed).
2072
+ const containerUp = await waitForDockerContainer(botContainer, 90000);
2073
+ if (!containerUp) sendLog(`[zalo-connect] ${botContainer} chưa chạy sau 90s — vẫn thử tiếp...`);
2074
+ const gatewayReady = await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
2075
+ if (!gatewayReady) {
2076
+ 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' }));
2077
+ if (String(check.stdout || '').trim() === 'MISSING') {
2078
+ sendLog(`[zalo-connect] Plugin missing — installing ${ZALO_CONNECT_PLUGIN_SPEC}...`);
2079
+ const installCmd = `cd /home/node/project && openclaw plugins install ${ZALO_CONNECT_PLUGIN_SPEC} --force --acknowledge-clawhub-risk 2>&1`;
2080
+ const inst = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', installCmd], { cwd: projectDir, shell: false });
2081
+ const instOut = `${inst.stdout}\n${inst.stderr}`;
2082
+ for (const line of instOut.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
2083
+ if (/installed plugin/i.test(instOut)) {
2084
+ // Gateway must reload to pick the plugin up — safe here: the gateway is past
2085
+ // its boot (we only reach this branch when it answered the exec above).
2086
+ await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
2087
+ await waitForGatewayZaloReady(botContainer, projectDir, 180000, ['zalo-connect']);
2088
+ } else {
2089
+ 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.');
2090
+ }
1778
2091
  }
1779
2092
  }
1780
2093
  }
@@ -1788,10 +2101,19 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
1788
2101
  let qrSent = false;
1789
2102
  let loginDone = false;
1790
2103
 
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);
2104
+ const pushQr = async (pngPath) => {
2105
+ let b64 = '';
2106
+ if (native) {
2107
+ // The CLI ran on the host, so the QR PNG is a real host path — read it directly.
2108
+ try {
2109
+ const st = await fsp.stat(pngPath);
2110
+ if (st.size > 100) b64 = (await fsp.readFile(pngPath)).toString('base64');
2111
+ } catch {}
2112
+ } else {
2113
+ 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{}`;
2114
+ const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', js], { cwd: projectDir, shell: false }).catch(() => ({ stdout: '' }));
2115
+ b64 = extractCompletePngBase64(out.stdout);
2116
+ }
1795
2117
  if (b64.length > 100) {
1796
2118
  qrSent = true;
1797
2119
  sendLog(`[zalo-connect:qr] data:image/png;base64,${b64}`);
@@ -1803,7 +2125,7 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
1803
2125
  const handleLine = (line) => {
1804
2126
  const qrFile = line.match(/QR image saved at:\s*(\S+\.png)/i);
1805
2127
  if (qrFile) {
1806
- pushQrFromContainer(qrFile[1]).catch(() => {});
2128
+ pushQr(qrFile[1]).catch(() => {});
1807
2129
  return;
1808
2130
  }
1809
2131
  if (isQrAsciiArt(line)) return; // don't flood the UI modal with terminal QR art
@@ -1818,7 +2140,9 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
1818
2140
  const runAttempt = () => {
1819
2141
  attempt++;
1820
2142
  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 });
2143
+ const child = native
2144
+ ? spawn(resolveBinPath('openclaw'), ['channels', 'login', '--channel', 'zalo-connect', '--account', accountId, '--verbose'], { cwd: projectDir, shell: false, windowsHide: true, env: { ...process.env, ...nativeEnv(projectDir) } })
2145
+ : spawn('docker', ['exec', botContainer, 'sh', '-lc', loginCmd], { cwd: projectDir, shell: false, windowsHide: true });
1822
2146
  zaloLoginChild = child;
1823
2147
  child.stdout.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLine));
1824
2148
  child.stderr.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLine));
@@ -1828,9 +2152,15 @@ async function startZaloConnectLogin(projectDir, accountId = 'default') {
1828
2152
  if (zaloLoginChild === child) zaloLoginChild = null;
1829
2153
  sendLog(`[zalo-connect] Login process exited ${code}`);
1830
2154
  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.`);
2155
+ if (native) {
2156
+ sendLog('[zalo-connect] Login saved. Restarting native gateway so the Zalo channel connects...');
2157
+ await restartNativeRuntime(projectDir).catch((err) => sendLog(`[zalo-connect] Gateway restart failed: ${err.message}`));
2158
+ sendLog('[zalo-connect] Gateway restarted. Try sending a Zalo message now.');
2159
+ } else {
2160
+ sendLog(`[zalo-connect] Login saved. Restarting ${botContainer} so the Zalo channel connects...`);
2161
+ await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[zalo-connect] Container restart failed: ${err.message}`));
2162
+ sendLog(`[zalo-connect] ${botContainer} restarted. Try sending a Zalo message now.`);
2163
+ }
1834
2164
  zaloLoginInFlight = false;
1835
2165
  } else if (code !== 0 && !qrSent && !wasCancelled && attempt < MAX_ATTEMPTS) {
1836
2166
  const delay = RETRY_DELAYS[attempt] || 15000;
@@ -1967,38 +2297,68 @@ async function getZaloHealth(projectDir) {
1967
2297
  }
1968
2298
  }
1969
2299
 
2300
+ const native = isNativeProject(projectDir);
1970
2301
  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
2302
  let statusJson = null;
1976
2303
  let textStatus = '';
1977
2304
  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) {
2305
+ if (native) {
2306
+ // "containerRunning" here means "runtime up": for native, probe the managed gateway's
2307
+ // /health over loopback, then read channel status + credentials directly on the host.
2308
+ const nmeta = readNativeMeta(projectDir) || {};
2309
+ const port = String(nmeta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
2310
+ containerRunning = await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500);
2311
+ if (containerRunning) {
1984
2312
  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 || '');
2313
+ const r = await ocCapture(projectDir, ['channels', 'status', '--json'], { timeout: 20000 });
2314
+ statusJson = parseJsonText(String(r.stdout || '').trim(), null);
1987
2315
  } catch {}
2316
+ if (!statusJson) {
2317
+ try {
2318
+ const r = await ocCapture(projectDir, ['channels', 'status'], { timeout: 20000 });
2319
+ textStatus = `${r.stdout || ''}\n${r.stderr || ''}`;
2320
+ } catch {}
2321
+ }
2322
+ // zalo-connect writes credentials under the project's .openclaw (OPENCLAW_HOME); fall
2323
+ // back to the real home dir in case the plugin used os.homedir() instead.
2324
+ const credRe = /^zalo-connect-credentials(?:-[^.]+)?\.json$/i;
2325
+ for (const dir of [join(projectDir, '.openclaw'), join(os.homedir(), '.openclaw')]) {
2326
+ try {
2327
+ const names = (await fsp.readdir(dir)).filter((n) => credRe.test(n));
2328
+ if (names.length) { credentialNames = names; break; }
2329
+ } catch {}
2330
+ }
1988
2331
  }
2332
+ } else {
1989
2333
  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(), []);
2334
+ const r = await runCapture('docker', ['inspect', '-f', '{{.State.Running}}', botContainer], { shell: false, timeout: 8000 });
2335
+ containerRunning = String(r.stdout || '').trim() === 'true';
1993
2336
  } 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;
2337
+ if (containerRunning) {
2338
+ try {
2339
+ const r = await runCapture('docker', ['exec', botContainer, 'openclaw', 'channels', 'status', '--json'], { cwd: projectDir, shell: false, timeout: 20000 });
2340
+ statusJson = parseJsonText(String(r.stdout || '').trim(), null);
2341
+ } catch {}
2342
+ if (!statusJson) {
2343
+ try {
2344
+ const r = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', 'openclaw channels status 2>&1 || true'], { cwd: projectDir, shell: false, timeout: 20000 });
2345
+ textStatus = String(r.stdout || '');
2346
+ } catch {}
2000
2347
  }
2001
- } catch {}
2348
+ try {
2349
+ 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))";
2350
+ const r = await runCapture('docker', ['exec', botContainer, 'node', '-e', script], { cwd: projectDir, shell: false, timeout: 8000 });
2351
+ credentialNames = parseJsonText(String(r.stdout || '[]').trim(), []);
2352
+ } catch {}
2353
+ try {
2354
+ const versions = await getContainerExtensionVersions(projectDir);
2355
+ const zaloModVersion = versions['zalo-mod'] || versions['openclaw-zalo-mod'] || '';
2356
+ if (zaloModVersion) {
2357
+ meta.zaloModInstalled = true;
2358
+ meta.zaloModVersion = zaloModVersion;
2359
+ }
2360
+ } catch {}
2361
+ }
2002
2362
  }
2003
2363
  return { ...buildZaloHealthSnapshot(cfg, statusJson, credentialNames, { containerRunning, textStatus }), ...meta };
2004
2364
  }
@@ -2019,6 +2379,239 @@ function getBotServiceName(projectDir) {
2019
2379
  return 'ai-bot';
2020
2380
  }
2021
2381
 
2382
+ // ═══════════════════════════════════════════════════════════════════════════════
2383
+ // Native runtime — openclaw + 9router straight on the host, no Docker
2384
+ // ═══════════════════════════════════════════════════════════════════════════════
2385
+ // Two things replace the container:
2386
+ // 1. `docker exec <container> openclaw …` → `openclaw …` carrying the project env.
2387
+ // Without that env the CLI silently reads ~/.openclaw instead of the project, so every
2388
+ // native invocation MUST go through ocRun/ocCapture rather than calling openclaw directly.
2389
+ // 2. container lifecycle → `openclaw daemon …` (launchd on macOS, systemd on Linux,
2390
+ // schtasks on Windows). The generated service keeps the project env via its own
2391
+ // env-wrapper and sets KeepAlive, which is the native equivalent of `restart: always`.
2392
+ // Service identity is per project (OPENCLAW_LAUNCHD_LABEL/…): the CLI's default label is a
2393
+ // single fixed one, so without this a second native project would take over the first's service.
2394
+
2395
+ const NATIVE_MARKER = 'native.json';
2396
+ // Native ports sit one hundred above the docker ones (18789/20128) so a native project can run
2397
+ // next to a docker project — or next to an SSH tunnel forwarding a remote bot's ports — untouched.
2398
+ const NATIVE_DEFAULT_GATEWAY_PORT = 18889;
2399
+ const NATIVE_DEFAULT_ROUTER_PORT = 20228;
2400
+
2401
+ function nativeMarkerPath(projectDir) {
2402
+ return join(projectDir || state.projectDir || '', '.openclaw', NATIVE_MARKER);
2403
+ }
2404
+
2405
+ function readNativeMeta(projectDir) {
2406
+ try { return JSON.parse(fs.readFileSync(nativeMarkerPath(projectDir), 'utf8')); } catch (e) { return null; }
2407
+ }
2408
+
2409
+ /** Per-project deploy mode. The marker file wins; a compose file means docker; else fall back. */
2410
+ function projectDeployMode(projectDir) {
2411
+ const dir = projectDir || state.projectDir || '';
2412
+ if (!dir) return state.mode || 'docker';
2413
+ if (existsSync(nativeMarkerPath(dir))) return 'native';
2414
+ if (existsSync(join(dir, 'docker', 'openclaw', 'docker-compose.yml'))) return 'docker';
2415
+ return state.mode || 'docker';
2416
+ }
2417
+
2418
+ function isNativeProject(projectDir) {
2419
+ return projectDeployMode(projectDir) === 'native';
2420
+ }
2421
+
2422
+ /** launchd label / systemd unit / scheduled-task name — unique per project so installs coexist. */
2423
+ function nativeServiceLabel(projectDir) {
2424
+ const meta = readNativeMeta(projectDir);
2425
+ if (meta && meta.label) return meta.label;
2426
+ const id = slugify(basename(projectDir || 'openclaw'), 'bot');
2427
+ return `ai.openclaw.gateway.${id}`;
2428
+ }
2429
+
2430
+ /** The env every native CLI call needs (mirrors the docker runtime env in docker-gen.js). */
2431
+ function nativeEnv(projectDir, extra = {}) {
2432
+ const dir = projectDir || state.projectDir || '';
2433
+ const home = join(dir, '.openclaw');
2434
+ const meta = readNativeMeta(dir) || {};
2435
+ const gatewayPort = String(meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
2436
+ const label = nativeServiceLabel(dir);
2437
+ return {
2438
+ OPENCLAW_HOME: home,
2439
+ OPENCLAW_STATE_DIR: home,
2440
+ DATA_DIR: join(dir, '.9router'),
2441
+ OPENCLAW_GATEWAY_PORT: gatewayPort,
2442
+ OPENCLAW_PORT: gatewayPort,
2443
+ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: '1',
2444
+ OPENCLAW_SETUP_OS: meta.osChoice || state.os || '',
2445
+ OPENCLAW_BROWSER_HOST_OS: meta.osChoice || state.os || '',
2446
+ OPENCLAW_LAUNCHD_LABEL: label,
2447
+ OPENCLAW_SYSTEMD_UNIT: `${label}.service`,
2448
+ OPENCLAW_WINDOWS_TASK_NAME: label,
2449
+ ...extra,
2450
+ };
2451
+ }
2452
+
2453
+ /** Resolve `openclaw <args>` for whichever runtime this project uses. */
2454
+ function ocArgv(projectDir, args) {
2455
+ if (isNativeProject(projectDir)) {
2456
+ return { cmd: 'openclaw', args, opts: { cwd: projectDir, env: nativeEnv(projectDir) } };
2457
+ }
2458
+ return { cmd: 'docker', args: ['exec', getBotContainerName(projectDir), 'openclaw', ...args], opts: { cwd: projectDir } };
2459
+ }
2460
+
2461
+ function ocRun(projectDir, args, opts = {}) {
2462
+ const a = ocArgv(projectDir, args);
2463
+ return run(a.cmd, a.args, { ...a.opts, ...opts });
2464
+ }
2465
+
2466
+ function ocCapture(projectDir, args, opts = {}) {
2467
+ const a = ocArgv(projectDir, args);
2468
+ return runCapture(a.cmd, a.args, { shell: false, ...a.opts, ...opts, env: { ...(a.opts.env || {}), ...(opts.env || {}) } });
2469
+ }
2470
+
2471
+ /**
2472
+ * Restart the native gateway service.
2473
+ *
2474
+ * `daemon restart` is the obvious call, but on Windows it dies with
2475
+ * `ERR_UNKNOWN_SIGNAL: Unknown signal: SIGUSR1` (verified on a real box: the old pid survives and
2476
+ * newly installed plugins never load, silently). stop+start is what actually works there, and it
2477
+ * works everywhere else too, so Windows takes that path and other systems keep `restart` with
2478
+ * stop+start as a fallback.
2479
+ */
2480
+ async function restartNativeRuntime(projectDir) {
2481
+ const env = nativeEnv(projectDir);
2482
+ const stopStart = async () => {
2483
+ await run('openclaw', ['daemon', 'stop'], { cwd: projectDir, env }).catch(() => {});
2484
+ await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
2485
+ };
2486
+ if (process.platform === 'win32') return stopStart();
2487
+ try {
2488
+ await run('openclaw', ['daemon', 'restart'], { cwd: projectDir, env });
2489
+ } catch (e) {
2490
+ sendLog(`[native] daemon restart failed (${e.message}); falling back to stop+start`);
2491
+ await stopStart();
2492
+ }
2493
+ }
2494
+
2495
+ /** Fire-and-forget background process (9router has no service wrapper of its own). */
2496
+ function startDetached(cmd, args, opts = {}) {
2497
+ sendLog(`$ ${cmd} ${args.join(' ')} &`);
2498
+ const shell = process.platform === 'win32';
2499
+ const rawBin = resolveBinPath(cmd);
2500
+ const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
2501
+ const child = spawn(bin, args, {
2502
+ cwd: opts.cwd,
2503
+ shell,
2504
+ detached: true,
2505
+ stdio: 'ignore',
2506
+ windowsHide: opts.windowsHide ?? true,
2507
+ env: { ...process.env, ...(opts.env || {}) },
2508
+ });
2509
+ child.on('error', (err) => sendLog(`[native] Failed to start "${cmd}": ${err.message}`));
2510
+ child.unref();
2511
+ return child.pid;
2512
+ }
2513
+
2514
+ /** Kill whatever is listening on a port. `pkill` does not exist on Windows, so resolve pid → kill. */
2515
+ async function killListenerOnPort(port) {
2516
+ if (process.platform === 'win32') {
2517
+ const out = await runCapture('powershell', ['-NoProfile', '-Command', `(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1).OwningProcess`], { shell: false, timeout: 15000 });
2518
+ const pid = String(out.stdout || '').trim();
2519
+ if (/^\d+$/.test(pid)) await run('taskkill', ['/F', '/PID', pid], { shell: false }).catch(() => {});
2520
+ return;
2521
+ }
2522
+ const out = await runCapture('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { shell: false, timeout: 10000 });
2523
+ const pid = String(out.stdout || '').trim().split(/\s+/)[0];
2524
+ if (/^\d+$/.test(pid)) await run('kill', [pid], { shell: false }).catch(() => {});
2525
+ }
2526
+
2527
+ async function probeHttpOk(url, timeoutMs = 2000) {
2528
+ const r = await runCapture('curl', ['-s', '-m', String(Math.ceil(timeoutMs / 1000)), '-o', '/dev/null', '-w', '%{http_code}', url], { shell: false, timeout: timeoutMs + 2000 });
2529
+ return /^[23]/.test((r.stdout || '').trim());
2530
+ }
2531
+
2532
+ /**
2533
+ * Start 9router for a native project. Bound to loopback on purpose: openclaw talks to it over
2534
+ * localhost (see get9RouterBaseUrl), so exposing the LLM proxy on 0.0.0.0 would only create an
2535
+ * open relay — on a VPS that is a real risk. Data lives in the project so projects stay separate.
2536
+ */
2537
+ async function startNative9Router(projectDir, { restart = false } = {}) {
2538
+ const meta = readNativeMeta(projectDir) || {};
2539
+ const routerPort = meta.routerPort || state.routerPort || NATIVE_DEFAULT_ROUTER_PORT;
2540
+ const dataDir = join(projectDir, '.9router');
2541
+ await fsp.mkdir(dataDir, { recursive: true }).catch(() => {});
2542
+ if (restart) {
2543
+ // No service wrapper for 9router: stop the old listener before rebinding the same port.
2544
+ await killListenerOnPort(routerPort);
2545
+ } else if (await probeHttpOk(`http://127.0.0.1:${routerPort}/`)) {
2546
+ sendLog(`[native] 9router already listening on ${routerPort}`);
2547
+ return routerPort;
2548
+ }
2549
+ startDetached('9router', ['-n', '-l', '-H', '127.0.0.1', '-p', String(routerPort), '--skip-update'], {
2550
+ cwd: projectDir,
2551
+ env: nativeEnv(projectDir, { DATA_DIR: dataDir }),
2552
+ });
2553
+ return routerPort;
2554
+ }
2555
+
2556
+ /**
2557
+ * Bring a native project up end to end: 9router → smart-route sync → resolve its API key into
2558
+ * openclaw.json → install the gateway as a managed service. Order matters: the gateway must boot
2559
+ * after 9router is reachable and after the key is on disk, or its first turn has no model.
2560
+ */
2561
+ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, routerPort }) {
2562
+ const gwPort = gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT;
2563
+ const rtPort = routerPort || NATIVE_DEFAULT_ROUTER_PORT;
2564
+ const label = nativeServiceLabel(projectDir);
2565
+ // Marker first: nativeEnv()/isNativeProject() read it, and everything below depends on them.
2566
+ await fsp.mkdir(join(projectDir, '.openclaw'), { recursive: true });
2567
+ await fsp.writeFile(
2568
+ nativeMarkerPath(projectDir),
2569
+ JSON.stringify({ mode: 'native', gatewayPort: gwPort, routerPort: rtPort, osChoice, label }, null, 2),
2570
+ 'utf8',
2571
+ );
2572
+
2573
+ await startNative9Router(projectDir);
2574
+
2575
+ // Same smart-route sync the docker sidecar runs, pointed at the native DB path. Without it
2576
+ // 9router keeps its login gate and the default `smart-route` combo has no backing models.
2577
+ try {
2578
+ const artifacts = buildDockerArtifacts({ is9Router: true, osChoice, openClawNpmSpec: OPENCLAW_NPM_SPEC, gatewayPort: gwPort, routerPort: rtPort });
2579
+ if (artifacts && artifacts.syncScript) {
2580
+ const dataDir = join(projectDir, '.9router');
2581
+ const syncPath = join(dataDir, 'sync.js');
2582
+ await fsp.writeFile(syncPath, artifacts.syncScript, 'utf8');
2583
+ startDetached(process.execPath, [syncPath], {
2584
+ cwd: dataDir,
2585
+ env: nativeEnv(projectDir, { NINEROUTER_DB_PATH: join(dataDir, 'db', 'data.sqlite'), PORT: String(rtPort) }),
2586
+ });
2587
+ sendLog('[native] 9router smart-route sync started');
2588
+ }
2589
+ } catch (e) {
2590
+ sendLog(`[native] smart-route sync skipped: ${e.message}`);
2591
+ }
2592
+
2593
+ await new Promise((r) => setTimeout(r, 8000));
2594
+ await applyResolved9RouterApiKey(projectDir).catch(() => {});
2595
+
2596
+ // Managed service = auto-restart (KeepAlive/Restart=always) and start-at-login, the native
2597
+ // equivalent of docker's `restart: always`. --force so re-running install updates the port.
2598
+ const env = nativeEnv(projectDir);
2599
+ await run('openclaw', ['daemon', 'install', '--force', '--port', String(gwPort)], { cwd: projectDir, env });
2600
+ await run('openclaw', ['daemon', 'start'], { cwd: projectDir, env });
2601
+ sendLog(`[native] gateway service "${label}" running on 127.0.0.1:${gwPort}, 9router on 127.0.0.1:${rtPort}`);
2602
+ return { gatewayPort: gwPort, routerPort: rtPort, label };
2603
+ }
2604
+
2605
+ /** Tear down a native project's service (used when deleting the project). */
2606
+ async function removeNativeRuntime(projectDir) {
2607
+ if (!isNativeProject(projectDir)) return false;
2608
+ const env = nativeEnv(projectDir);
2609
+ await run('openclaw', ['daemon', 'uninstall'], { cwd: projectDir, env }).catch((e) => sendLog(`[native] daemon uninstall: ${e.message}`));
2610
+ const routerPort = (readNativeMeta(projectDir) || {}).routerPort || NATIVE_DEFAULT_ROUTER_PORT;
2611
+ await killListenerOnPort(routerPort);
2612
+ return true;
2613
+ }
2614
+
2022
2615
  function getBotContainerName(projectDir) {
2023
2616
  const composeFile = join(projectDir || state.projectDir || '', 'docker', 'openclaw', 'docker-compose.yml');
2024
2617
  if (!existsSync(composeFile)) return 'openclaw-bot';
@@ -2150,6 +2743,14 @@ async function syncDockerInfra(projectDir, force = false) {
2150
2743
  }
2151
2744
 
2152
2745
  async function recreateDockerBot(projectDir) {
2746
+ // Native: there is no image to rebuild — the gateway reads openclaw.json from disk on boot, so
2747
+ // reloading config after a bot/plugin change is just a service restart. Callers stay unchanged.
2748
+ if (isNativeProject(projectDir)) {
2749
+ sendLog('[native] Reloading gateway to pick up openclaw.json changes...');
2750
+ await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart failed: ${e.message}`));
2751
+ probeCacheClear();
2752
+ return true;
2753
+ }
2153
2754
  const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
2154
2755
  if (!existsSync(composeFile)) return false;
2155
2756
  const depDir = join(projectDir, '.openclaw', 'plugin-runtime-deps');
@@ -2168,6 +2769,17 @@ async function recreateDockerBot(projectDir) {
2168
2769
  async function updateRuntime(target, projectDir) {
2169
2770
  const isRouter = target === '9router';
2170
2771
  const spec = isRouter ? NINE_ROUTER_NPM_SPEC : OPENCLAW_NPM_SPEC;
2772
+ // Native: the runtime is a global npm package, not an image. Reinstall it, then restart the
2773
+ // service so the new binary is the one actually serving. This is what replaces "Rebuild".
2774
+ if (isNativeProject(projectDir)) {
2775
+ sendLog(`[native] Updating ${target} → ${spec}`);
2776
+ await run('npm', ['install', '-g', spec]);
2777
+ if (isRouter) await startNative9Router(projectDir, { restart: true }).catch((e) => sendLog(`[native] 9router restart: ${e.message}`));
2778
+ else await restartNativeRuntime(projectDir);
2779
+ await syncRuntimeState(projectDir, { full: true }).catch(() => {});
2780
+ probeCacheClear();
2781
+ return { ok: true, target, spec, mode: 'native' };
2782
+ }
2171
2783
  if (state.mode === 'docker' && projectDir) {
2172
2784
  const dockerDir = join(projectDir, 'docker', 'openclaw');
2173
2785
  if (isRouter) {
@@ -2189,6 +2801,14 @@ async function updateRuntime(target, projectDir) {
2189
2801
  }
2190
2802
 
2191
2803
  async function restartDockerBotContainer(projectDir = state.projectDir) {
2804
+ // Native projects have no container: the gateway runs as a managed service, so restarting it
2805
+ // is `openclaw daemon restart` (which also clears any stale gateway process holding the port).
2806
+ if (isNativeProject(projectDir)) {
2807
+ sendLog('[native] Restarting gateway service...');
2808
+ await restartNativeRuntime(projectDir);
2809
+ probeCacheClear(`runtime:${projectDir}`);
2810
+ return true;
2811
+ }
2192
2812
  const containerName = getBotContainerName(projectDir);
2193
2813
  sendLog(`[docker] Restarting ${containerName} container...`);
2194
2814
  await run('docker', ['restart', containerName], { shell: false });
@@ -2428,6 +3048,695 @@ async function getDockerBridgeIp() {
2428
3048
  } catch {}
2429
3049
  return '172.17.0.1';
2430
3050
  }
3051
+ // ── Host control ────────────────────────────────────────────────────────────────
3052
+ // The bot runs inside a container: it has no view of the host desktop and cannot start a
3053
+ // program there, which is why asking it to open TeamViewer gets a refusal. The installer,
3054
+ // though, already runs ON the host and already spawns processes (it launches Chrome). This
3055
+ // exposes that ability to the bot over a small HTTP service.
3056
+ //
3057
+ // Reachability: the dashboard itself binds to 127.0.0.1, which a container cannot reach, so
3058
+ // this listens on the Docker bridge address as well — the same approach the Chrome relay
3059
+ // uses, private to this machine and not routable from outside.
3060
+ //
3061
+ // Everything is gated: the service only starts when hostControl.enabled is true, every
3062
+ // request needs the per-project token, and `open` accepts a key from the operator's own app
3063
+ // list rather than an arbitrary command line. Opening apps on the host is a real capability,
3064
+ // so it stays opt-in and enumerable instead of a general shell.
3065
+ const HOST_CONTROL_PORT = 18795;
3066
+ let _hostControlServer = null;
3067
+ // The project the running host-control service serves. Tracked separately from the server
3068
+ // singleton so enabling from a different (connected) project re-points the service without a
3069
+ // restart — the request handler reads config from THIS dir, not a value captured at first-start.
3070
+ let _hostControlProjectDir = null;
3071
+
3072
+ function hostControlConfigPath(projectDir) {
3073
+ return join(projectDir, '.openclaw', 'host-control.json');
3074
+ }
3075
+
3076
+ /** Common install locations, so the app list is useful before anyone edits it. */
3077
+ function detectHostApps() {
3078
+ const apps = {};
3079
+ const add = (key, candidates) => {
3080
+ for (const candidate of candidates) {
3081
+ if (candidate && existsSync(candidate)) {
3082
+ apps[key] = candidate;
3083
+ return;
3084
+ }
3085
+ }
3086
+ };
3087
+ if (process.platform === 'win32') {
3088
+ const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
3089
+ const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
3090
+ const local = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
3091
+ add('teamviewer', [join(pf, 'TeamViewer', 'TeamViewer.exe'), join(pf86, 'TeamViewer', 'TeamViewer.exe')]);
3092
+ add('chrome', [join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'), join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe')]);
3093
+ add('zalo', [join(local, 'Programs', 'Zalo', 'Zalo.exe'), join(local, 'Zalo', 'Zalo.exe')]);
3094
+ add('explorer', ['C:\\Windows\\explorer.exe']);
3095
+ add('notepad', ['C:\\Windows\\System32\\notepad.exe']);
3096
+ } else if (process.platform === 'darwin') {
3097
+ add('teamviewer', ['/Applications/TeamViewer.app']);
3098
+ add('chrome', ['/Applications/Google Chrome.app']);
3099
+ add('zalo', ['/Applications/Zalo.app']);
3100
+ add('finder', ['/System/Library/CoreServices/Finder.app']);
3101
+ }
3102
+ return apps;
3103
+ }
3104
+
3105
+ /** Resolve an executable on PATH synchronously (returns absolute path or ''). */
3106
+ function whichSync(name) {
3107
+ try {
3108
+ const finder = process.platform === 'win32' ? 'where' : 'which';
3109
+ const out = execFileSync(finder, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
3110
+ const first = String(out).split(/\r?\n/).map((s) => s.trim()).find(Boolean);
3111
+ return first || '';
3112
+ } catch (_) {
3113
+ return '';
3114
+ }
3115
+ }
3116
+
3117
+ /**
3118
+ * CLI tools the bot may RUN (not just open) via /api/host/exec — output is captured and
3119
+ * returned. Kept as a name→path allow-list, mirroring detectHostApps: the executable is fixed,
3120
+ * only allow-listed names run. Auto-detects Claude Code CLI; add more by editing
3121
+ * `.openclaw/host-control.json` → `commands`.
3122
+ */
3123
+ function detectHostCommands() {
3124
+ const commands = {};
3125
+ const claude = whichSync('claude');
3126
+ if (claude) commands.claude = claude;
3127
+ return commands;
3128
+ }
3129
+
3130
+ /**
3131
+ * Extra capabilities the operator grants together with PC control: seeing the screen
3132
+ * (screenshot / screen recording) and running scripts through node or the Codex CLI.
3133
+ *
3134
+ * Kept out of detectHostCommands() on purpose. That one is the default list every project gets
3135
+ * as soon as the dashboard reads host-control state; these are only merged in when the operator
3136
+ * actually flips PC control on, so nothing is granted before they ask for it. `node` in
3137
+ * particular runs arbitrary code, which is why it takes an explicit act.
3138
+ */
3139
+ function detectHostCapabilityCommands() {
3140
+ const commands = {};
3141
+ // The installer is itself node, so this path is guaranteed to exist and to be the same
3142
+ // interpreter the native bot runs under (the one macOS will attach the screen permission to).
3143
+ commands.node = process.execPath;
3144
+ for (const name of ['npx', 'codex', 'claude', 'ffmpeg']) {
3145
+ const bin = whichSync(name);
3146
+ if (bin) commands[name] = bin; // ffmpeg = screen recording on Linux/macOS
3147
+ }
3148
+ // The Codex CLI usually is not on PATH — it ships inside the desktop app. With it allow-listed
3149
+ // the bot can hand a job to Codex headlessly (`codex exec "…"`) and read the answer back.
3150
+ if (!commands.codex) {
3151
+ const bundledCodex = resolveCodexCli(detectCodexApp());
3152
+ if (bundledCodex) commands.codex = bundledCodex;
3153
+ }
3154
+ if (process.platform === 'darwin') {
3155
+ // Both a screenshot (`-x`) and a screen recording (`-v -V <secs>`) tool.
3156
+ if (existsSync('/usr/sbin/screencapture')) commands.screencapture = '/usr/sbin/screencapture';
3157
+ } else if (process.platform === 'linux') {
3158
+ for (const name of ['gnome-screenshot', 'spectacle', 'scrot', 'import']) {
3159
+ const bin = whichSync(name);
3160
+ if (bin) { commands.screenshot = bin; break; }
3161
+ }
3162
+ }
3163
+ return commands;
3164
+ }
3165
+
3166
+ /**
3167
+ * Merge the capability commands into the project's allow-list, and report what was added so the
3168
+ * dashboard can name it. Existing entries are left alone: an operator who pointed `node` at a
3169
+ * specific interpreter keeps that path.
3170
+ */
3171
+ function grantHostCapabilities(cfg) {
3172
+ const detected = detectHostCapabilityCommands();
3173
+ const added = [];
3174
+ cfg.commands = cfg.commands || {};
3175
+ for (const [name, bin] of Object.entries(detected)) {
3176
+ if (!cfg.commands[name]) {
3177
+ cfg.commands[name] = bin;
3178
+ added.push(name);
3179
+ }
3180
+ }
3181
+ return added;
3182
+ }
3183
+
3184
+ // Mouse/keyboard/screen control comes from the Codex desktop app's own `computer-use` plugin.
3185
+ // The bot reaches it by running `codex exec "<task>"`, which is a normal allow-listed command —
3186
+ // no OpenClaw-side harness, no second agent, no gateway restart. All this code has to do is make
3187
+ // sure the desktop app itself has computer-use installed and wired.
3188
+ //
3189
+ /** Where the desktop app that ships the Codex CLI + computer-use bundle lives. */
3190
+ function detectCodexApp() {
3191
+ const candidates = process.platform === 'darwin'
3192
+ ? [
3193
+ { app: '/Applications/Codex.app', bundle: '/Applications/Codex.app/Contents/Resources/plugins/openai-bundled' },
3194
+ { app: '/Applications/ChatGPT.app', bundle: '/Applications/ChatGPT.app/Contents/Resources/plugins/openai-bundled' },
3195
+ ]
3196
+ : process.platform === 'win32'
3197
+ ? [
3198
+ { app: join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'Programs', 'Codex'), bundle: '' },
3199
+ { app: join(process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local'), 'Programs', 'ChatGPT'), bundle: '' },
3200
+ ]
3201
+ : [];
3202
+ for (const candidate of candidates) {
3203
+ if (existsSync(candidate.app)) {
3204
+ return { present: true, app: candidate.app, bundle: candidate.bundle && existsSync(candidate.bundle) ? candidate.bundle : '' };
3205
+ }
3206
+ }
3207
+ return { present: false, app: '', bundle: '' };
3208
+ }
3209
+
3210
+ /**
3211
+ * Find a marketplace the Codex app-server has ALREADY registered that carries the computer-use
3212
+ * plugin, by reading its own `~/.codex/config.toml`.
3213
+ *
3214
+ * This matters because auto-install refuses to add new sources: pointing the plugin at a
3215
+ * marketplace directory it has not discovered fails with "auto-install only uses marketplaces
3216
+ * Codex app-server has already discovered … run /codex computer-use install". Naming a discovered
3217
+ * marketplace instead keeps provisioning fully automatic.
3218
+ */
3219
+ function detectCodexMarketplace() {
3220
+ const codexHome = process.env.CODEX_HOME || join(getRealHomedir(), '.codex');
3221
+ const configPath = join(codexHome, 'config.toml');
3222
+ if (!existsSync(configPath)) return null;
3223
+ let toml = '';
3224
+ try {
3225
+ toml = fs.readFileSync(configPath, 'utf8');
3226
+ } catch (_) {
3227
+ return null;
3228
+ }
3229
+ // Minimal line-based TOML read: [marketplaces.<name>] headers and their `source = "..."`. A full
3230
+ // TOML parser is not worth pulling in for two fields of someone else's config.
3231
+ let name = '';
3232
+ for (const rawLine of toml.split(/\r?\n/)) {
3233
+ const line = rawLine.trim();
3234
+ const header = line.match(/^\[([^\]]+)\]$/);
3235
+ if (header) {
3236
+ const section = header[1];
3237
+ name = section.startsWith('marketplaces.') ? section.slice('marketplaces.'.length).replace(/^["']|["']$/g, '') : '';
3238
+ continue;
3239
+ }
3240
+ if (!name) continue;
3241
+ const source = (line.match(/^source\s*=\s*"([^"]+)"$/) || [])[1];
3242
+ if (source && existsSync(join(source, 'plugins', 'computer-use'))) return { name, source };
3243
+ }
3244
+ return null;
3245
+ }
3246
+
3247
+ /** The Codex CLI that ships inside the desktop app (or one on PATH). */
3248
+ function resolveCodexCli(app) {
3249
+ const bundled = app && app.app ? join(app.app, 'Contents', 'Resources', 'codex') : '';
3250
+ if (bundled && existsSync(bundled)) return bundled;
3251
+ return whichSync('codex');
3252
+ }
3253
+
3254
+ /**
3255
+ * Last mile on the Codex side: the OpenClaw plugin can only USE computer-use, it cannot install it
3256
+ * into the desktop app. Two things have to be true there, and both are fixable with the app's own
3257
+ * CLI (verified on a real machine):
3258
+ * - the `computer-use` plugin is installed from a discovered marketplace, and
3259
+ * - the `computer-use` MCP server points at that installed plugin. A stale global entry (left by
3260
+ * an earlier manual attempt) shadows the plugin's own and exposes zero tools, which surfaces as
3261
+ * the confusing "Computer Use is ready" with nothing behind it.
3262
+ */
3263
+ async function ensureCodexComputerUsePlugin(app, marketplace) {
3264
+ const result = { cli: resolveCodexCli(app), pluginInstalled: false, installedNow: false, mcpRepaired: false };
3265
+ if (!result.cli || !marketplace) return result;
3266
+ const list = await runCapture(result.cli, ['plugin', 'list'], { shell: false }).catch(() => null);
3267
+ if (!list) return result;
3268
+ const ref = `computer-use@${marketplace.name}`;
3269
+ const row = `${list.stdout || ''}\n${list.stderr || ''}`.split(/\r?\n/).find((line) => line.trim().startsWith(ref));
3270
+ if (!row) return result;
3271
+ result.pluginInstalled = /\binstalled\b/.test(row) && !/not installed/.test(row);
3272
+ if (!result.pluginInstalled) {
3273
+ sendLog(`[computer-use] Cài plugin ${ref} vào app Codex…`);
3274
+ const add = await runCapture(result.cli, ['plugin', 'add', ref], { shell: false }).catch((err) => ({ code: 1, stderr: err.message }));
3275
+ result.installedNow = add.code === 0;
3276
+ if (!result.installedNow) result.error = (add.stderr || add.stdout || '').trim().split(/\r?\n/).slice(-2).join(' ');
3277
+ else result.pluginInstalled = true;
3278
+ }
3279
+ // Repair the MCP registration only when it clearly is NOT the plugin's own (its cwd lives under
3280
+ // the plugin cache). Removing the global entry lets the plugin-provided server take over.
3281
+ const mcp = await runCapture(result.cli, ['mcp', 'get', 'computer-use'], { shell: false }).catch(() => null);
3282
+ const mcpText = mcp ? `${mcp.stdout || ''}${mcp.stderr || ''}` : '';
3283
+ if (mcpText && !/plugins\/cache\//.test(mcpText)) {
3284
+ sendLog('[computer-use] Gỡ khai báo MCP computer-use cũ (trỏ sai chỗ) để dùng bản của plugin…');
3285
+ const removed = await runCapture(result.cli, ['mcp', 'remove', 'computer-use'], { shell: false }).catch(() => ({ code: 1 }));
3286
+ result.mcpRepaired = removed.code === 0;
3287
+ }
3288
+ return result;
3289
+ }
3290
+
3291
+ /**
3292
+ * Drop a tiny wrapper next to each workspace so GUI hand-off is one fixed command.
3293
+ *
3294
+ * Relying on the model to remember `--sandbox danger-full-access` does not work: a running session
3295
+ * still holds the TOOLS.md it loaded at session start, so a bot mid-conversation keeps calling
3296
+ * plain `codex exec`, gets "Computer Use was not approved to use <app>", and then invents a reason
3297
+ * (observed twice: it told the operator to grant Screen Recording, which was already granted).
3298
+ * With the wrapper the flags live on disk instead of in the prompt.
3299
+ */
3300
+ async function writeCodexTaskScript(projectDir, cliPath) {
3301
+ const openclawDir = join(projectDir, '.openclaw');
3302
+ if (!existsSync(openclawDir) || !cliPath) return '';
3303
+ const body = [
3304
+ '#!/bin/sh',
3305
+ '# Managed by create-openclaw-bot — hand a desktop/GUI job to Codex and print its answer.',
3306
+ '# Usage: pc-task.sh "mở TeamViewer và đọc ID trên màn hình"',
3307
+ '# The sandbox flag is REQUIRED: the default read-only sandbox makes Codex refuse computer-use',
3308
+ '# with "Computer Use was not approved to use <app>".',
3309
+ 'if [ $# -eq 0 ]; then echo "usage: pc-task.sh \\"việc cần làm\\"" >&2; exit 2; fi',
3310
+ `exec ${JSON.stringify(cliPath)} exec --skip-git-repo-check --sandbox danger-full-access "$@"`,
3311
+ '',
3312
+ ].join('\n');
3313
+ let written = '';
3314
+ for (const entry of await fsp.readdir(openclawDir).catch(() => [])) {
3315
+ if (!entry.startsWith('workspace')) continue;
3316
+ const binDir = join(openclawDir, entry, 'bin');
3317
+ await fsp.mkdir(binDir, { recursive: true }).catch(() => {});
3318
+ const path = join(binDir, 'pc-task.sh');
3319
+ await fsp.writeFile(path, body, 'utf8').catch(() => {});
3320
+ await fsp.chmod(path, 0o755).catch(() => {});
3321
+ written = path;
3322
+ }
3323
+ return written;
3324
+ }
3325
+
3326
+ /**
3327
+ * macOS/Windows privacy panes for the permissions PC control needs. The OS never lets an app
3328
+ * grant these for you (that is the point of TCC), so the best we can do is take the operator
3329
+ * straight to the right pane and — for screen capture — poke the API so the system prompt appears.
3330
+ */
3331
+ function openPrivacyPane(kind) {
3332
+ const macPanes = {
3333
+ screen: 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
3334
+ accessibility: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility',
3335
+ automation: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Automation',
3336
+ files: 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles',
3337
+ };
3338
+ const winPanes = {
3339
+ screen: 'ms-settings:privacy-general',
3340
+ accessibility: 'ms-settings:easeofaccess',
3341
+ automation: 'ms-settings:privacy-general',
3342
+ files: 'ms-settings:privacy-broadfilesystemaccess',
3343
+ };
3344
+ if (process.platform === 'darwin') {
3345
+ const url = macPanes[kind] || macPanes.screen;
3346
+ spawnDetached('open', [url]);
3347
+ return { opened: true, pane: url };
3348
+ }
3349
+ if (process.platform === 'win32') {
3350
+ const url = winPanes[kind] || winPanes.screen;
3351
+ spawnDetached('cmd', ['/c', 'start', '', url]);
3352
+ return { opened: true, pane: url };
3353
+ }
3354
+ return { opened: false, pane: '', reason: 'unsupported-platform' };
3355
+ }
3356
+
3357
+ /**
3358
+ * Ask macOS for a screenshot. First call raises the Screen Recording prompt for THIS node binary
3359
+ * (the same one the native bot runs under); afterwards a non-empty file means the permission is
3360
+ * granted, and a failure/empty file means it is not.
3361
+ */
3362
+ async function probeScreenPermission() {
3363
+ if (process.platform !== 'darwin') return { supported: false, granted: null };
3364
+ const shot = join(os.tmpdir(), `openclaw-screen-probe-${Date.now()}.png`);
3365
+ try {
3366
+ await run('/usr/sbin/screencapture', ['-x', '-t', 'png', shot], { shell: false });
3367
+ } catch (_) {
3368
+ // non-zero exit → denied (screencapture exits with an error when TCC blocks it)
3369
+ }
3370
+ let granted = false;
3371
+ try {
3372
+ granted = existsSync(shot) && (await fsp.stat(shot)).size > 1024;
3373
+ } catch (_) {
3374
+ granted = false;
3375
+ }
3376
+ await fsp.unlink(shot).catch(() => {});
3377
+ return { supported: true, granted };
3378
+ }
3379
+
3380
+ async function readHostControlConfig(projectDir) {
3381
+ const path = hostControlConfigPath(projectDir);
3382
+ let cfg = {};
3383
+ try {
3384
+ if (existsSync(path)) cfg = JSON.parse(await fsp.readFile(path, 'utf8'));
3385
+ } catch (_) {
3386
+ cfg = {};
3387
+ }
3388
+ let changed = false;
3389
+ if (typeof cfg.enabled !== 'boolean') {
3390
+ cfg.enabled = false;
3391
+ changed = true;
3392
+ }
3393
+ if (!cfg.token) {
3394
+ cfg.token = _require('crypto').randomBytes(24).toString('hex');
3395
+ changed = true;
3396
+ }
3397
+ if (!cfg.apps || typeof cfg.apps !== 'object') {
3398
+ cfg.apps = detectHostApps();
3399
+ changed = true;
3400
+ }
3401
+ if (!cfg.commands || typeof cfg.commands !== 'object') {
3402
+ cfg.commands = detectHostCommands();
3403
+ changed = true;
3404
+ }
3405
+ if (changed) {
3406
+ await fsp.mkdir(dirname(path), { recursive: true }).catch(() => {});
3407
+ await fsp.writeFile(path, JSON.stringify(cfg, null, 2), 'utf8').catch(() => {});
3408
+ }
3409
+ return cfg;
3410
+ }
3411
+
3412
+ /** Launch a host program detached, so it outlives this request. */
3413
+ function spawnDetached(command, args) {
3414
+ const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: false });
3415
+ child.on('error', (err) => sendLog(`[host-control] Không chạy được "${command}": ${err.message}`));
3416
+ child.unref();
3417
+ }
3418
+
3419
+ function openHostApp(target) {
3420
+ if (process.platform === 'win32') {
3421
+ // `start` needs a shell; the empty "" is the window title cmd expects before the path.
3422
+ spawnDetached('cmd', ['/c', 'start', '', target]);
3423
+ return;
3424
+ }
3425
+ if (process.platform === 'darwin') {
3426
+ spawnDetached('open', [target]);
3427
+ return;
3428
+ }
3429
+ spawnDetached('xdg-open', [target]);
3430
+ }
3431
+
3432
+ /**
3433
+ * Run an allow-listed CLI (e.g. Claude Code) and return its output. Unlike openHostApp this is
3434
+ * NOT detached: we wait for it, capture stdout/stderr (capped), and enforce a timeout. No shell
3435
+ * (shell:false) so args are literal — no injection; the executable is fixed by the allow-list.
3436
+ */
3437
+ function runHostCommand(res, name, bin, args, input, timeoutMs) {
3438
+ const MAX_OUT = 200_000; // ~200 KB cap per stream, so a runaway process can't flood the reply
3439
+ return new Promise((resolveP) => {
3440
+ let out = '';
3441
+ let err = '';
3442
+ let settled = false;
3443
+ const finish = (payload, status) => {
3444
+ if (settled) return;
3445
+ settled = true;
3446
+ clearTimeout(timer);
3447
+ json(res, payload, status);
3448
+ resolveP();
3449
+ };
3450
+ let child;
3451
+ try {
3452
+ child = spawn(bin, args, { shell: false, windowsHide: true });
3453
+ } catch (e) {
3454
+ return finish({ ok: false, error: e.message }, 500);
3455
+ }
3456
+ const timer = setTimeout(() => {
3457
+ try { child.kill('SIGKILL'); } catch (_) {}
3458
+ finish({ ok: false, error: `timeout after ${timeoutMs}ms`, timedOut: true, stdout: out.slice(0, MAX_OUT), stderr: err.slice(0, MAX_OUT) }, 504);
3459
+ }, timeoutMs);
3460
+ child.stdout?.on('data', (d) => { if (out.length < MAX_OUT) out += d.toString(); });
3461
+ child.stderr?.on('data', (d) => { if (err.length < MAX_OUT) err += d.toString(); });
3462
+ child.on('error', (e) => finish({ ok: false, error: e.message }, 500));
3463
+ child.on('close', (code) => {
3464
+ sendLog(`[host-control] Đã chạy "${name}" (exit ${code}).`);
3465
+ finish({ ok: code === 0, command: name, code, stdout: out.slice(0, MAX_OUT), stderr: err.slice(0, MAX_OUT) }, 200);
3466
+ });
3467
+ if (input != null) { try { child.stdin.write(input); } catch (_) {} }
3468
+ try { child.stdin.end(); } catch (_) {}
3469
+ });
3470
+ }
3471
+
3472
+ async function handleHostControl(req, res, projectDir) {
3473
+ const cfg = await readHostControlConfig(projectDir);
3474
+ const url = new URL(req.url, 'http://localhost');
3475
+ const presented = req.headers['x-openclaw-token'] || url.searchParams.get('token') || '';
3476
+ if (!cfg.enabled) return json(res, { ok: false, error: 'host control is disabled' }, 403);
3477
+ if (presented !== cfg.token) return json(res, { ok: false, error: 'invalid token' }, 401);
3478
+
3479
+ if (url.pathname === '/api/browser/start-chrome' && req.method === 'POST') {
3480
+ try {
3481
+ return json(res, await startChromeDebug());
3482
+ } catch (err) {
3483
+ return json(res, { ok: false, error: err.message }, err.status || 500);
3484
+ }
3485
+ }
3486
+ if (url.pathname === '/api/host/apps' && req.method === 'GET') {
3487
+ return json(res, { ok: true, apps: Object.keys(cfg.apps || {}), commands: Object.keys(cfg.commands || {}), platform: process.platform });
3488
+ }
3489
+ if (url.pathname === '/api/host/exec' && req.method === 'POST') {
3490
+ const body = await readJson(req).catch(() => ({}));
3491
+ const name = String(body.command || '').trim().toLowerCase();
3492
+ if (!name) return json(res, { ok: false, error: 'missing "command"' }, 400);
3493
+ const bin = (cfg.commands || {})[name];
3494
+ if (!bin) {
3495
+ return json(res, {
3496
+ ok: false,
3497
+ error: `"${name}" is not in this machine's command list`,
3498
+ commands: Object.keys(cfg.commands || {}),
3499
+ }, 404);
3500
+ }
3501
+ // Args are passed literally (spawn with shell:false) so nothing in them is re-interpreted
3502
+ // by a shell — the executable is fixed to the allow-listed path, callers cannot pick a
3503
+ // different binary or inject a second command.
3504
+ const args = Array.isArray(body.args) ? body.args.map((a) => String(a)) : [];
3505
+ const input = body.input != null ? String(body.input) : null;
3506
+ const timeoutMs = Math.min(Math.max(Number(body.timeoutMs) || 180000, 1000), 600000);
3507
+ return runHostCommand(res, name, bin, args, input, timeoutMs);
3508
+ }
3509
+ if (url.pathname === '/api/host/open' && req.method === 'POST') {
3510
+ const body = await readJson(req).catch(() => ({}));
3511
+ const key = String(body.app || body.target || '').trim();
3512
+ if (!key) return json(res, { ok: false, error: 'missing "app"' }, 400);
3513
+ const path = (cfg.apps || {})[key.toLowerCase()];
3514
+ if (!path) {
3515
+ return json(res, {
3516
+ ok: false,
3517
+ error: `"${key}" is not in this machine's app list`,
3518
+ apps: Object.keys(cfg.apps || {}),
3519
+ }, 404);
3520
+ }
3521
+ openHostApp(path);
3522
+ sendLog(`[host-control] Đã mở "${key}" trên máy (${path}).`);
3523
+ return json(res, { ok: true, app: key, path });
3524
+ }
3525
+ return json(res, { ok: false, error: 'unknown endpoint' }, 404);
3526
+ }
3527
+
3528
+ /**
3529
+ * Teach every bot in the project how to reach the host-control service, and hand it the
3530
+ * token. Written into TOOLS.md as a managed block so flipping the switch off removes it
3531
+ * again — a bot that still had the instructions would keep trying an endpoint that now
3532
+ * refuses. `host.docker.internal` resolves in the container on every OS because the
3533
+ * generated compose maps it to host-gateway.
3534
+ */
3535
+ async function writeHostControlAccess(projectDir, cfg) {
3536
+ const openclawDir = join(projectDir, '.openclaw');
3537
+ if (!existsSync(openclawDir)) return;
3538
+ const native = isNativeProject(projectDir);
3539
+ // Native bots run on the host itself; host.docker.internal only resolves from inside a container,
3540
+ // so a native bot curling it fails ("could not connect"). Use loopback there instead.
3541
+ const base = native ? `http://127.0.0.1:${HOST_CONTROL_PORT}` : `http://host.docker.internal:${HOST_CONTROL_PORT}`;
3542
+ const apps = Object.keys(cfg.apps || {});
3543
+ const commands = Object.keys(cfg.commands || {});
3544
+ const execBlock = commands.length ? [
3545
+ '',
3546
+ '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}`).',
3547
+ 'Dùng để giao việc cho công cụ dòng lệnh, ví dụ Claude Code:',
3548
+ '',
3549
+ '```sh',
3550
+ `curl -s -X POST ${base}/api/host/exec -H "x-openclaw-token: ${cfg.token}" \\`,
3551
+ ' -H "content-type: application/json" -d \'{"command":"claude","args":["-p","tóm tắt repo hiện tại"]}\'',
3552
+ '```',
3553
+ '',
3554
+ `Lệnh khả dụng: ${commands.map((c) => `\`${c}\``).join(', ')}. Lệnh mặc định timeout 180s, output tối đa ~200KB/luồng.`,
3555
+ ] : [];
3556
+ // Screen capture / recording — only advertised when the operator granted the matching tool, so
3557
+ // the bot never tries a binary that is not on this machine's allow-list.
3558
+ // Windows has no capture binary to allow-list (PowerShell does it inline), so the section shows
3559
+ // up there too — a native bot runs the command itself, the allow-list only gates the bridge.
3560
+ const hasCapture = commands.includes('screencapture') || commands.includes('screenshot') || commands.includes('ffmpeg') || (native && process.platform === 'win32');
3561
+ const captureBlock = hasCapture ? [
3562
+ '',
3563
+ '### Chụp / quay màn hình',
3564
+ '',
3565
+ ...(commands.includes('screencapture') ? [
3566
+ '- 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ổ).',
3567
+ '- Quay: `screencapture -v -V 10 /tmp/rec.mov` (quay 10 giây rồi tự dừng).',
3568
+ ] : []),
3569
+ ...(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.'] : []),
3570
+ ...(native && process.platform === 'win32' ? [
3571
+ '- 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).',
3572
+ ] : []),
3573
+ ...(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`).'] : []),
3574
+ '',
3575
+ '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.',
3576
+ ] : [];
3577
+ const scriptCommands = commands.filter((c) => c === 'node' || c === 'npx' || c === 'codex' || c === 'claude');
3578
+ const scriptBlock = scriptCommands.length ? [
3579
+ '',
3580
+ '### Chạy script & giao việc cho CLI khác',
3581
+ '',
3582
+ `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\`).`,
3583
+ ...(commands.includes('codex') ? [
3584
+ '- 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.',
3585
+ ] : []),
3586
+ ...(commands.includes('claude') ? [
3587
+ '- Giao việc cho **Claude Code**: `claude -p "việc cần làm"` (một lượt, trả stdout).',
3588
+ ] : []),
3589
+ 'Đâ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.',
3590
+ ] : [];
3591
+ const startTag = '<!-- OPENCLAW:HOST_CONTROL:START -->';
3592
+ const endTag = '<!-- OPENCLAW:HOST_CONTROL:END -->';
3593
+ // NATIVE: the bot runs directly on the host with `exec`, so it opens apps with the OS command —
3594
+ // no bridge, no host.docker.internal (which doesn't resolve off-container anyway). DOCKER: the
3595
+ // bot is in a container and can't see the desktop, so it must call the installer's host service.
3596
+ const nativeBlock = [
3597
+ startTag,
3598
+ '',
3599
+ '## 🖥️ Điều khiển máy của chủ (host control — chế độ native)',
3600
+ '',
3601
+ '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):',
3602
+ '',
3603
+ '- macOS: `open -a "<Tên app>"` — ví dụ `open -a "TeamViewer"`',
3604
+ '- Linux: `xdg-open <app|url>` hoặc chạy binary trực tiếp',
3605
+ '- Windows: `cmd /c start "" "<app>"`',
3606
+ '',
3607
+ 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.',
3608
+ ...captureBlock,
3609
+ ...scriptBlock,
3610
+ '',
3611
+ ...(commands.includes('codex') ? [
3612
+ '### Điều khiển chuột/bàn phím & nhìn màn hình',
3613
+ '',
3614
+ '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):',
3615
+ '',
3616
+ '```sh',
3617
+ 'bin/pc-task.sh "mở TeamViewer, đọc mật khẩu đang hiện trên màn hình rồi in ra"',
3618
+ '```',
3619
+ '',
3620
+ '⚠️ **Đừ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 đủ).',
3621
+ '',
3622
+ '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 ý:',
3623
+ '',
3624
+ '- 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.',
3625
+ '- Việc GUI có thể chạy lâu: đặt timeout rộng, đừng kết luận thất bại sớm.',
3626
+ '- 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.',
3627
+ '- Đ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.',
3628
+ '- 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.',
3629
+ '',
3630
+ ] : []),
3631
+ '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ử".',
3632
+ '',
3633
+ endTag,
3634
+ '',
3635
+ ].join('\n');
3636
+ const dockerBlock = [
3637
+ startTag,
3638
+ '',
3639
+ '## 🖥️ Điều khiển máy của chủ (host control)',
3640
+ '',
3641
+ '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`:',
3642
+ '',
3643
+ '```sh',
3644
+ `curl -s -X POST ${base}/api/browser/start-chrome -H "x-openclaw-token: ${cfg.token}"`,
3645
+ '```',
3646
+ '',
3647
+ 'Mở ứng dụng (chỉ những app có trong danh sách của máy):',
3648
+ '',
3649
+ '```sh',
3650
+ `curl -s -X POST ${base}/api/host/open -H "x-openclaw-token: ${cfg.token}" \\`,
3651
+ ' -H "content-type: application/json" -d \'{"app":"teamviewer"}\'',
3652
+ '```',
3653
+ '',
3654
+ 'Xem danh sách app đang được phép:',
3655
+ '',
3656
+ '```sh',
3657
+ `curl -s ${base}/api/host/apps -H "x-openclaw-token: ${cfg.token}"`,
3658
+ '```',
3659
+ '',
3660
+ 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`.',
3661
+ ...execBlock,
3662
+ // Docker only: a screenshot taken on the host lands on the HOST filesystem, which this
3663
+ // container cannot read — say so instead of letting the bot hunt for a missing file.
3664
+ ...(hasCapture ? [
3665
+ '',
3666
+ '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.',
3667
+ ] : []),
3668
+ '',
3669
+ '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,',
3670
+ 'đừng cố tìm đường khác. Chỉ mở app hoặc chạy lệnh khi chủ yêu cầu rõ.',
3671
+ '',
3672
+ endTag,
3673
+ '',
3674
+ ].join('\n');
3675
+ const block = native ? nativeBlock : dockerBlock;
3676
+ for (const entry of await fsp.readdir(openclawDir).catch(() => [])) {
3677
+ if (!entry.startsWith('workspace')) continue;
3678
+ const toolsMd = join(openclawDir, entry, 'TOOLS.md');
3679
+ if (!existsSync(toolsMd)) continue;
3680
+ const current = await fsp.readFile(toolsMd, 'utf8');
3681
+ const withoutBlock = removeManagedBlockFrom(current, 'HOST_CONTROL');
3682
+ const next = cfg.enabled
3683
+ ? `${withoutBlock.trimEnd()}\n\n${block}`
3684
+ : withoutBlock;
3685
+ if (next !== current) await fsp.writeFile(toolsMd, next, 'utf8');
3686
+ }
3687
+ }
3688
+
3689
+ /** Strip a managed block by id; shared with the browser-guide cleanup. */
3690
+ function removeManagedBlockFrom(content, blockId) {
3691
+ const startTag = `<!-- OPENCLAW:${blockId}:START -->`;
3692
+ const endTag = `<!-- OPENCLAW:${blockId}:END -->`;
3693
+ const startIdx = content.indexOf(startTag);
3694
+ const endIdx = content.indexOf(endTag);
3695
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return content;
3696
+ return `${content.substring(0, startIdx).trimEnd()}\n${content.substring(endIdx + endTag.length).trimStart()}`.trim() + '\n';
3697
+ }
3698
+
3699
+ async function ensureHostControl(projectDir) {
3700
+ // Point the service at the project being enabled (re-points a service already running for
3701
+ // another project — the handler reads _hostControlProjectDir per request).
3702
+ _hostControlProjectDir = projectDir;
3703
+ const cfg = await readHostControlConfig(projectDir);
3704
+ if (!cfg.enabled) return { ok: false, reason: 'disabled' };
3705
+ // Desktop only. Opening TeamViewer or an app needs a GUI, so a headless server has nothing
3706
+ // to control — and, more importantly, it is where 0.0.0.0 would be a real exposure (a VPS
3707
+ // has a public IP). Refusing here means the service never binds on a headless box, so the
3708
+ // public-exposure question does not arise. A rare VPS-with-desktop can override with
3709
+ // OPENCLAW_HOST_CONTROL_ALLOW_HEADLESS=1.
3710
+ if (isHeadlessServer() && process.env.OPENCLAW_HOST_CONTROL_ALLOW_HEADLESS !== '1') {
3711
+ return { ok: false, reason: 'headless server — no desktop to control' };
3712
+ }
3713
+ if (_hostControlServer) return { ok: true, port: HOST_CONTROL_PORT };
3714
+ const bridgeIp = await getDockerBridgeIp().catch(() => null);
3715
+ const server = http.createServer((req, res) => {
3716
+ // Read the CURRENTLY active project each request, so re-pointing takes effect live.
3717
+ handleHostControl(req, res, _hostControlProjectDir || projectDir).catch((err) => json(res, { ok: false, error: err.message }, 500));
3718
+ });
3719
+ // Bind all interfaces: the container reaches the host by different addresses per platform —
3720
+ // docker0 (172.17.0.1) on native Linux, the Docker Desktop gateway (host.docker.internal,
3721
+ // e.g. 192.168.65.254) on macOS/Windows — and binding one misses the others. The token is
3722
+ // the guard here, not the interface: every request needs it, and the service only exists
3723
+ // while the operator has host control switched on.
3724
+ const bindOk = await new Promise((resolveP) => {
3725
+ server.once('error', () => resolveP(false));
3726
+ server.listen(HOST_CONTROL_PORT, '0.0.0.0', () => resolveP(true));
3727
+ });
3728
+ if (!bindOk) return { ok: false, reason: `port ${HOST_CONTROL_PORT} in use` };
3729
+ _hostControlServer = server;
3730
+ 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.`);
3731
+ if (bridgeIp && process.platform === 'linux') {
3732
+ // ufw's default-deny drops container→host traffic silently. Scope the allow rule to the
3733
+ // private bridge address only, so opening the port here does not expose it to the LAN.
3734
+ 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`])
3735
+ .catch(() => {});
3736
+ }
3737
+ return { ok: true, port: HOST_CONTROL_PORT, host: '0.0.0.0' };
3738
+ }
3739
+
2431
3740
  async function ensureChromeRelay() {
2432
3741
  if (_chromeRelayServer) return true;
2433
3742
  const bridgeIp = await getDockerBridgeIp();
@@ -2458,6 +3767,77 @@ async function ensureChromeRelay() {
2458
3767
  // this request. `--remote-allow-origins=*` is required by modern Chrome for cross-origin CDP.
2459
3768
  // On a headless VPS there is no Chrome to open here — instead we start the bridge relay and hand
2460
3769
  // back copy-paste commands so the user runs Chrome on THEIR machine + a reverse SSH tunnel.
3770
+ // Where Chrome keeps the operator's own profile, per OS. Chrome must not already be running
3771
+ // on it when we attach the debug port, which is why the callers close Chrome first.
3772
+ function defaultChromeProfileDir() {
3773
+ if (process.platform === 'win32') {
3774
+ const localAppData = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
3775
+ return join(localAppData, 'Google', 'Chrome', 'User Data');
3776
+ }
3777
+ if (process.platform === 'darwin') {
3778
+ return join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome');
3779
+ }
3780
+ return join(os.homedir(), '.config', 'google-chrome');
3781
+ }
3782
+
3783
+ // The profile Chrome is actually launched with. Never the directory above: Chrome 136+ drops
3784
+ // --remote-debugging-port when it IS the default profile, so pointing there means Chrome opens
3785
+ // and port 9222 never answers — the failure the bot reports as "Chrome debug not connected".
3786
+ function debugChromeProfileDir() {
3787
+ if (process.platform === 'win32') {
3788
+ const localAppData = process.env.LOCALAPPDATA || join(os.homedir(), 'AppData', 'Local');
3789
+ return join(localAppData, ...CHROME_DEBUG_PROFILE_LEAF_WIN.split('\\'));
3790
+ }
3791
+ if (process.platform === 'darwin') return join(os.homedir(), ...CHROME_DEBUG_PROFILE_LEAF_MAC.split('/'));
3792
+ return join(os.homedir(), ...CHROME_DEBUG_PROFILE_LEAF_LINUX.split('/'));
3793
+ }
3794
+
3795
+ // Seed it from the real profile once, so the bot inherits the operator's cookies, logins,
3796
+ // history and extensions instead of browsing as a brand-new profile (the clearest bot signal
3797
+ // a site can read). Caches are skipped — Chrome rebuilds those, and copying them turns a few
3798
+ // hundred MB into several GB. Best-effort: a profile that fails to copy still opens, just
3799
+ // signed out.
3800
+ async function copyChromeProfileTree(src, dst) {
3801
+ await fsp.mkdir(dst, { recursive: true });
3802
+ const entries = await fsp.readdir(src, { withFileTypes: true });
3803
+ for (const entry of entries) {
3804
+ if (CHROME_PROFILE_CACHE_DIRS.includes(entry.name)) continue;
3805
+ const from = join(src, entry.name);
3806
+ const to = join(dst, entry.name);
3807
+ // Per entry, because Chrome holds Windows locks on the profile it is using: one
3808
+ // unreadable file must not cost the operator the whole profile.
3809
+ try {
3810
+ if (entry.isDirectory()) await copyChromeProfileTree(from, to);
3811
+ else if (entry.isFile()) await fsp.copyFile(from, to);
3812
+ } catch {}
3813
+ }
3814
+ }
3815
+
3816
+ async function seedDebugChromeProfile(realDir, debugDir, log = () => {}) {
3817
+ // The marker, not the folder: a copy that ran while Chrome had the cookie database locked
3818
+ // leaves a signed-out profile behind, and that must be retried rather than kept forever.
3819
+ const marker = join(debugDir, '.openclaw-seeded');
3820
+ if (existsSync(marker)) return false;
3821
+ if (!existsSync(join(realDir, 'Default'))) return false;
3822
+ log(`[chrome] Lần đầu: đang chép profile Chrome sang ${debugDir} (bỏ cache)...`);
3823
+ try {
3824
+ await copyChromeProfileTree(join(realDir, 'Default'), join(debugDir, 'Default'));
3825
+ await fsp.copyFile(join(realDir, 'Local State'), join(debugDir, 'Local State')).catch(() => {});
3826
+ const cookiesCopied = ['Network/Cookies', 'Cookies']
3827
+ .some((rel) => existsSync(join(debugDir, 'Default', ...rel.split('/'))));
3828
+ if (!cookiesCopied) {
3829
+ 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 đủ.');
3830
+ return false;
3831
+ }
3832
+ await fsp.writeFile(marker, new Date().toISOString(), 'utf8').catch(() => {});
3833
+ return true;
3834
+ } catch (e) {
3835
+ log(`[chrome] Không chép được profile (${e.message}); Chrome vẫn mở nhưng chưa đăng nhập sẵn.`);
3836
+ await fsp.mkdir(join(debugDir, 'Default'), { recursive: true }).catch(() => {});
3837
+ return false;
3838
+ }
3839
+ }
3840
+
2461
3841
  async function startChromeDebug() {
2462
3842
  if (isHeadlessServer()) {
2463
3843
  await ensureChromeRelay();
@@ -2479,10 +3859,19 @@ async function startChromeDebug() {
2479
3859
  : 'Không tìm thấy Google Chrome. Hãy cài Chrome rồi thử lại.');
2480
3860
  }
2481
3861
  const port = 9222;
2482
- const userDataDir = join(os.tmpdir(), 'openclaw-chrome-debug');
3862
+ // Launch against a dedicated profile seeded from the operator's real one. A throwaway
3863
+ // profile is the clearest bot signal a site can read — no cookies, no logins, no history,
3864
+ // new on every run — and it also means the bot cannot use pages the operator is already
3865
+ // signed in to; the real profile itself cannot be used because Chrome 136+ drops the debug
3866
+ // port on it. The port is not what gets flagged: Chrome started this way carries no
3867
+ // --enable-automation, so navigator.webdriver stays false and there is no banner.
3868
+ // Set OPENCLAW_CHROME_PROFILE_DIR to point somewhere else (anything but the default profile).
3869
+ const userDataDir = process.env.OPENCLAW_CHROME_PROFILE_DIR || debugChromeProfileDir();
3870
+ await seedDebugChromeProfile(defaultChromeProfileDir(), userDataDir, sendLog);
2483
3871
  const args = [
2484
3872
  `--remote-debugging-port=${port}`,
2485
3873
  `--user-data-dir=${userDataDir}`,
3874
+ '--profile-directory=Default',
2486
3875
  '--remote-allow-origins=*',
2487
3876
  '--no-first-run',
2488
3877
  '--no-default-browser-check',
@@ -2586,11 +3975,20 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
2586
3975
  state.os = osChoice;
2587
3976
  state.startedAt = new Date().toISOString();
2588
3977
  try {
3978
+ // Native runs on the host's own ports, so it must not land on the docker defaults: a machine
3979
+ // often has a docker project (or an SSH tunnel to a remote bot) already holding 18789/20128.
3980
+ if (mode === 'native') {
3981
+ if (gatewayPort === 18789) gatewayPort = NATIVE_DEFAULT_GATEWAY_PORT;
3982
+ if (routerPort === 20128) routerPort = NATIVE_DEFAULT_ROUTER_PORT;
3983
+ state.gatewayPort = gatewayPort;
3984
+ state.routerPort = routerPort;
3985
+ }
2589
3986
  sendLog('OpenClaw local installer started');
2590
3987
  sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
2591
3988
  // 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);
3989
+ // with a clear message rather than deep inside `docker compose up`. Native mode has no
3990
+ // container, so it skips this entirely (that is much of the point of choosing it).
3991
+ if (mode !== 'native') await ensureDockerInstalled(osChoice);
2594
3992
  await writeCoreProject({ projectDir, osChoice, mode, gatewayPort, routerPort, userTimezone });
2595
3993
  await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
2596
3994
  await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
@@ -2611,6 +4009,9 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
2611
4009
  // making a brand-new project appear to crash-loop until the lease expired. The config is
2612
4010
  // bind-mounted, so the resolved 9Router key does not require an immediate second recreate.
2613
4011
  probeCacheClear();
4012
+ } else if (mode === 'native') {
4013
+ await startNativeRuntime({ projectDir, osChoice, gatewayPort, routerPort });
4014
+ probeCacheClear();
2614
4015
  }
2615
4016
  state.installed = true;
2616
4017
  sendLog('✅ Install completed');
@@ -2791,6 +4192,33 @@ async function discoverDockerBotProjectRoots() {
2791
4192
  return [...new Set(roots)];
2792
4193
  }
2793
4194
 
4195
+ // Native installs have no container to inspect, so we can't detect them the way Docker bots are
4196
+ // found. Instead scan for the `.openclaw/native.json` marker one level under the home dir and the
4197
+ // launcher's parent — that covers the folders users actually pick (e.g. ~/openclaw-native, D:\bot)
4198
+ // without a full filesystem walk. Mirrors discoverDockerBotProjectRoots so discoverProjects can
4199
+ // surface native projects even when this install has no saved state for them.
4200
+ async function discoverNativeProjectRoots(rootProjectDir) {
4201
+ const roots = new Set();
4202
+ const bases = new Set();
4203
+ try { bases.add(os.homedir()); } catch {}
4204
+ if (rootProjectDir) bases.add(resolve(rootProjectDir, '..'));
4205
+ for (const base of bases) {
4206
+ let entries = [];
4207
+ try { entries = await fsp.readdir(base, { withFileTypes: true }); } catch { continue; }
4208
+ for (const ent of entries) {
4209
+ if (!ent.isDirectory() || ent.name.startsWith('.')) continue;
4210
+ const dir = join(base, ent.name);
4211
+ if (existsSync(nativeMarkerPath(dir)) && existsSync(join(dir, '.openclaw', 'openclaw.json'))) {
4212
+ roots.add(resolve(dir));
4213
+ }
4214
+ }
4215
+ }
4216
+ if (rootProjectDir && existsSync(nativeMarkerPath(rootProjectDir)) && existsSync(join(rootProjectDir, '.openclaw', 'openclaw.json'))) {
4217
+ roots.add(resolve(rootProjectDir));
4218
+ }
4219
+ return [...roots];
4220
+ }
4221
+
2794
4222
  async function findLatestProject(rootProjectDir) {
2795
4223
  const realHome = getRealHomedir();
2796
4224
  const roots = [
@@ -2872,6 +4300,14 @@ async function discoverProjects(rootProjectDir) {
2872
4300
  }
2873
4301
  }
2874
4302
 
4303
+ // Same idea for native installs — detect by marker since there is no container to inspect.
4304
+ for (const dr of await discoverNativeProjectRoots(rootProjectDir)) {
4305
+ if (!state.projects.some(p => resolve(p.projectDir) === resolve(dr))) {
4306
+ const meta = await buildProjectMeta(dr).catch(() => null);
4307
+ if (meta) state.projects.push(meta);
4308
+ }
4309
+ }
4310
+
2875
4311
  if (state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json'))) {
2876
4312
  const resolved = resolve(state.projectDir);
2877
4313
  if (!state.projects.some(p => resolve(p.projectDir) === resolved)) {
@@ -3006,6 +4442,13 @@ async function deleteProjectFolder(projectDir, rootProjectDir) {
3006
4442
  const rootHome = resolve(os.homedir());
3007
4443
  if (!existsSync(join(resolved, '.openclaw', 'openclaw.json'))) throw httpError(404, 'openclaw.json not found in selected project');
3008
4444
  if (resolved === home || resolved === rootHome || /^[A-Za-z]:\\?$/.test(resolved)) throw httpError(403, 'Refusing to delete home/root folder');
4445
+ // Native: uninstall the managed service before the folder goes, otherwise launchd/systemd keeps
4446
+ // relaunching a gateway whose config and workspace no longer exist.
4447
+ if (isNativeProject(resolved)) {
4448
+ sendLog(`[native] Removing gateway service for ${resolved}...`);
4449
+ await removeNativeRuntime(resolved).catch((err) => sendLog(`[native] Warning: ${err.message}`));
4450
+ await new Promise((r) => setTimeout(r, 1500));
4451
+ }
3009
4452
  // Stop and remove Docker containers first to release host folder locks
3010
4453
  const dockerComposeDir = join(resolved, 'docker', 'openclaw');
3011
4454
  if (existsSync(join(dockerComposeDir, 'docker-compose.yml'))) {
@@ -3306,10 +4749,21 @@ async function installFeature(projectDir, agentId, kind, id) {
3306
4749
  composeDir = join(projectDir, 'docker', 'openclaw');
3307
4750
  }
3308
4751
 
3309
- if (composeDir) {
4752
+ if (isNativeProject(projectDir)) {
4753
+ // Native: no container — install on the host with the project env (ocCapture) so the
4754
+ // skill lands in this project's workspace, then reload the managed gateway service.
4755
+ sendLog(`[skill] Installing/updating clawhub:${slug} natively for agent ${agentId}...`);
4756
+ const out = await ocCapture(projectDir, ['skills', 'install', slug, '--agent', agentId, '--force', '--acknowledge-clawhub-risk']);
4757
+ for (const line of `${out.stdout}\n${out.stderr}`.split(/\r?\n/).filter(Boolean)) sendLog(line);
4758
+ if (out.code !== 0 && !isSkillFolderExists(projectDir, agentId, slug)) {
4759
+ throw new Error(out.stderr || out.stdout || `Failed to install skill ${slug}.`);
4760
+ }
4761
+ sendLog('[skill] Restarting native gateway to apply skill...');
4762
+ await restartNativeRuntime(projectDir).catch((err) => sendLog(`[skill] restart skipped: ${err.message}`));
4763
+ } else if (composeDir) {
3310
4764
  const botContainer = getBotContainerName(projectDir);
3311
4765
  sendLog(`[skill] Installing/updating clawhub:${slug} inside container ${botContainer} for agent ${agentId}...`);
3312
-
4766
+
3313
4767
  const cmd = `cd /home/node/project && openclaw skills install ${slug} --agent ${agentId} --force --acknowledge-clawhub-risk`;
3314
4768
  const cmdOut = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', cmd], { cwd: projectDir, shell: false });
3315
4769
 
@@ -3355,10 +4809,19 @@ async function installFeature(projectDir, agentId, kind, id) {
3355
4809
  // clawhub:latest like other plugins, so the dashboard "Update" button always fetches the newest
3356
4810
  // published version (no tag pin to bump each release).
3357
4811
  if (id === 'zalo-connect' || id === 'openclaw-zalo-connect') {
4812
+ const native = isNativeProject(projectDir);
3358
4813
  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) {
4814
+ if (!native) {
4815
+ if (existsSync(join(projectDir, 'docker-compose.yml'))) composeDir = projectDir;
4816
+ else if (existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'))) composeDir = join(projectDir, 'docker', 'openclaw');
4817
+ }
4818
+ if (native) {
4819
+ sendLog(`[zalo-connect] Installing/updating ${ZALO_CONNECT_PLUGIN_SPEC} natively...`);
4820
+ const out = await ocCapture(projectDir, ['plugins', 'install', ZALO_CONNECT_PLUGIN_SPEC, '--force', '--acknowledge-clawhub-risk']);
4821
+ if (out) for (const line of `${out.stdout}\n${out.stderr}`.split(/\r?\n/).filter(Boolean)) sendLog(`[zalo-connect] ${line}`);
4822
+ const okDir = existsSync(join(projectDir, '.openclaw', 'extensions', 'zalo-connect'));
4823
+ if (out.code !== 0 && !okDir) throw new Error(out.stderr || out.stdout || 'Failed to install zalo-connect.');
4824
+ } else if (composeDir) {
3362
4825
  const botContainer = getBotContainerName(projectDir);
3363
4826
  sendLog(`[zalo-connect] Installing/updating ${ZALO_CONNECT_PLUGIN_SPEC} inside ${botContainer}...`);
3364
4827
  const cmd = `cd /home/node/project && openclaw plugins install ${ZALO_CONNECT_PLUGIN_SPEC} --force --acknowledge-clawhub-risk 2>&1`;
@@ -3378,7 +4841,10 @@ async function installFeature(projectDir, agentId, kind, id) {
3378
4841
  if (!cfg.plugins.allow.includes('zalo-connect')) cfg.plugins.allow.push('zalo-connect');
3379
4842
  await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
3380
4843
  }
3381
- if (composeDir) {
4844
+ if (native) {
4845
+ sendLog('[zalo-connect] Restarting native gateway to apply...');
4846
+ await restartNativeRuntime(projectDir).catch((err) => sendLog(`[zalo-connect] restart failed: ${err.message}`));
4847
+ } else if (composeDir) {
3382
4848
  sendLog('[zalo-connect] Restarting container to apply...');
3383
4849
  await run('docker', ['restart', getBotContainerName(projectDir)], { shell: false }).catch(() => {});
3384
4850
  }
@@ -3394,19 +4860,28 @@ async function installFeature(projectDir, agentId, kind, id) {
3394
4860
  if (installSpec.startsWith('clawhub:')) installArgs.push('--acknowledge-clawhub-risk');
3395
4861
 
3396
4862
  let composeDir = null;
3397
- if (existsSync(join(projectDir, 'docker-compose.yml'))) {
4863
+ if (isNativeProject(projectDir)) {
4864
+ // Native: no container to exec into — same CLI, run on the host with the project env so it
4865
+ // installs into this project's .openclaw/extensions instead of the default ~/.openclaw.
4866
+ composeDir = null;
4867
+ } else if (existsSync(join(projectDir, 'docker-compose.yml'))) {
3398
4868
  composeDir = projectDir;
3399
4869
  } else if (existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'))) {
3400
4870
  composeDir = join(projectDir, 'docker', 'openclaw');
3401
4871
  }
3402
4872
 
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
-
4873
+ if (composeDir || isNativeProject(projectDir)) {
4874
+ let cmdOut;
4875
+ if (isNativeProject(projectDir)) {
4876
+ sendLog(`[plugin] Installing/updating ${installSpec} natively...`);
4877
+ cmdOut = await ocCapture(projectDir, installArgs);
4878
+ } else {
4879
+ const botContainer = getBotContainerName(projectDir);
4880
+ sendLog(`[plugin] Installing/updating ${installSpec} inside container ${botContainer}...`);
4881
+ const cmd = `cd /home/node/project && openclaw ${installArgs.join(' ')}`;
4882
+ cmdOut = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', cmd], { cwd: projectDir, shell: false });
4883
+ }
4884
+
3410
4885
  if (cmdOut) {
3411
4886
  for (const line of `${cmdOut.stdout}\n${cmdOut.stderr}`.split(/\r?\n/).filter(Boolean)) sendLog(line);
3412
4887
  }
@@ -3453,7 +4928,7 @@ async function installFeature(projectDir, agentId, kind, id) {
3453
4928
 
3454
4929
  // Auto-expose zalo-mod dashboard port in docker-compose.yml
3455
4930
  const isZaloMod = id === 'openclaw-zalo-mod' || id === 'zalo-mod';
3456
- if (isZaloMod) {
4931
+ if (isZaloMod && composeDir) {
3457
4932
  const composeFile = join(composeDir, 'docker-compose.yml');
3458
4933
  if (existsSync(composeFile)) {
3459
4934
  let composeContent = await fsp.readFile(composeFile, 'utf8');
@@ -3475,7 +4950,11 @@ async function installFeature(projectDir, agentId, kind, id) {
3475
4950
 
3476
4951
  // Browser-automation plugin needs Docker rebuild for Playwright/Chromium deps
3477
4952
  const isBrowserPlugin = id === 'openclaw-browser-automation' || id === 'browser-automation';
3478
- if (isBrowserPlugin && composeDir) {
4953
+ if (isNativeProject(projectDir)) {
4954
+ // Native: no container — reload the managed gateway service so the plugin loads.
4955
+ sendLog('[plugin] Restarting native gateway to apply plugin...');
4956
+ await restartNativeRuntime(projectDir).catch((err) => sendLog(`[plugin] restart failed: ${err.message}`));
4957
+ } else if (isBrowserPlugin && composeDir) {
3479
4958
  await patchBrowserAutomationHostPreference(projectDir, aliases, sendLog);
3480
4959
  sendLog(`[plugin] Browser plugin requires Docker rebuild for Playwright/Chromium...`);
3481
4960
  const svcName = getBotServiceName(projectDir);
@@ -3932,9 +5411,84 @@ async function handler(req, res, rootProjectDir) {
3932
5411
  const projectDir = await resolveProjectDir(rootProjectDir, body);
3933
5412
  return json(res, await addBotMount(projectDir, body.hostPath, body.mountName));
3934
5413
  }
3935
- if (url.pathname === '/api/browser/start-chrome-debug' && req.method === 'POST') {
5414
+ if ((url.pathname === '/api/browser/start-chrome' || url.pathname === '/api/browser/start-chrome-debug')
5415
+ && req.method === 'POST') {
5416
+ // start-chrome-debug is the old path; kept so an already-open dashboard keeps working.
3936
5417
  return json(res, await startChromeDebug());
3937
5418
  }
5419
+ // Host control: read/flip the switch and see which apps this machine offers. The bot does
5420
+ // not come through here (the dashboard is loopback-only) — it calls the bridge-bound
5421
+ // service from ensureHostControl.
5422
+ if (url.pathname === '/api/host/control' && req.method === 'GET') {
5423
+ // Target the SELECTED project (not the launch root), so host-control provisions the bot
5424
+ // the operator is actually looking at — a connected project can differ from rootProjectDir.
5425
+ const projectDir = await resolveProjectDir(rootProjectDir, {});
5426
+ const cfg = await readHostControlConfig(projectDir);
5427
+ return json(res, {
5428
+ ok: true,
5429
+ enabled: cfg.enabled,
5430
+ port: HOST_CONTROL_PORT,
5431
+ apps: Object.keys(cfg.apps || {}),
5432
+ commands: Object.keys(cfg.commands || {}),
5433
+ running: Boolean(_hostControlServer),
5434
+ native: isNativeProject(projectDir),
5435
+ // What enabling will additionally grant, so the confirm dialog can spell it out.
5436
+ grants: Object.keys(detectHostCapabilityCommands()),
5437
+ codexApp: detectCodexApp(),
5438
+ });
5439
+ }
5440
+ if (url.pathname === '/api/host/control' && req.method === 'POST') {
5441
+ const body = await readJson(req).catch(() => ({}));
5442
+ const projectDir = await resolveProjectDir(rootProjectDir, body);
5443
+ const cfg = await readHostControlConfig(projectDir);
5444
+ if (typeof body.enabled === 'boolean') cfg.enabled = body.enabled;
5445
+ if (body.apps && typeof body.apps === 'object') cfg.apps = body.apps;
5446
+ if (body.commands && typeof body.commands === 'object') cfg.commands = body.commands;
5447
+ // Turning PC control ON is the operator's explicit ask, so it is also where the screen
5448
+ // capture / recording and node-script permissions get granted (opt out with grants:false).
5449
+ const granted = cfg.enabled && body.grants !== false ? grantHostCapabilities(cfg) : [];
5450
+ if (granted.length) sendLog(`[host-control] Đã cấp thêm quyền chạy: ${granted.join(', ')}.`);
5451
+ await fsp.writeFile(hostControlConfigPath(projectDir), JSON.stringify(cfg, null, 2), 'utf8');
5452
+ let started = { ok: false, reason: 'disabled' };
5453
+ if (cfg.enabled) started = await ensureHostControl(projectDir);
5454
+ // Always rewrite the workspace guidance: enabling adds the block (with the token),
5455
+ // disabling strips it so a bot never keeps instructions for an endpoint now refusing.
5456
+ await writeHostControlAccess(projectDir, cfg).catch(() => {});
5457
+ sendLog(`[host-control] ${cfg.enabled ? 'Đã BẬT' : 'Đã TẮT'} quyền điều khiển máy cho bot.`);
5458
+ // Make sure the Codex desktop app can actually do GUI work, so `codex exec` is enough for
5459
+ // the bot: install computer-use into the app and repair its MCP registration. Nothing is
5460
+ // installed into the OpenClaw project and the gateway never restarts.
5461
+ let codex = null;
5462
+ if (cfg.enabled && body.codex !== false && (cfg.commands || {}).codex) {
5463
+ const app = detectCodexApp();
5464
+ codex = await ensureCodexComputerUsePlugin(app, detectCodexMarketplace())
5465
+ .then((r) => ({ ...r, app }))
5466
+ .catch((err) => ({ error: err.message, app }));
5467
+ // The wrapper carries the sandbox flag, so a bot cannot get the invocation wrong.
5468
+ codex.taskScript = await writeCodexTaskScript(projectDir, (cfg.commands || {}).codex).catch(() => '');
5469
+ }
5470
+ return json(res, {
5471
+ ok: true,
5472
+ enabled: cfg.enabled,
5473
+ started,
5474
+ apps: Object.keys(cfg.apps || {}),
5475
+ commands: Object.keys(cfg.commands || {}),
5476
+ granted,
5477
+ native: isNativeProject(projectDir),
5478
+ codex,
5479
+ });
5480
+ }
5481
+ // Take the operator to the OS privacy pane PC control needs (screen recording, accessibility).
5482
+ // The OS alone can grant these; `probe` additionally triggers the macOS screen-capture prompt
5483
+ // for this node binary — the same interpreter the native bot runs under.
5484
+ if (url.pathname === '/api/host/permissions' && req.method === 'POST') {
5485
+ const body = await readJson(req).catch(() => ({}));
5486
+ const kind = String(body.kind || 'screen').toLowerCase();
5487
+ const probe = kind === 'screen' && body.probe !== false ? await probeScreenPermission() : { supported: false, granted: null };
5488
+ const opened = openPrivacyPane(kind);
5489
+ sendLog(`[host-control] Mở cài đặt quyền "${kind}"${probe.supported ? ` (screen recording: ${probe.granted ? 'đã cấp' : 'chưa cấp'})` : ''}.`);
5490
+ return json(res, { ok: true, kind, ...opened, screen: probe, platform: process.platform });
5491
+ }
3938
5492
  if (url.pathname === '/api/setup/update' && req.method === 'POST') {
3939
5493
  const installerDir = resolve(__dirname, '../..');
3940
5494
  const isGit = existsSync(resolve(installerDir, '.git'));
@@ -4311,6 +5865,9 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
4311
5865
  ensureReopenShortcut();
4312
5866
  if (openBrowser) openUrl(url);
4313
5867
  printRemoteAccessHint(port).catch(() => {});
5868
+ // Bring the host-control service back up when the operator left it enabled, so the bot's
5869
+ // saved instructions keep working across installer restarts.
5870
+ ensureHostControl(projectDir).catch(() => {});
4314
5871
  }
4315
5872
 
4316
- export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations };
5873
+ 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 };