create-openclaw-bot 5.8.17 → 5.8.19

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.
@@ -19,7 +19,7 @@ const { buildDockerArtifacts } = loadSharedModule('../setup/shared/docker-gen.js
19
19
  const { OPENCLAW_NPM_SPEC, NINE_ROUTER_NPM_SPEC, build9RouterProviderConfig, get9RouterBaseUrl } = loadSharedModule('../setup/shared/common-gen.js', '__openclawCommon');
20
20
  const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
21
21
 
22
- async function syncExecApprovals(projectDir, cfg) {
22
+ async function syncExecApprovals(projectDir, cfg) {
23
23
  const openclawHome = join(projectDir, '.openclaw');
24
24
  const agentMetas = (cfg.agents?.list || []).map((a) => ({ agentId: a.id }));
25
25
  const approvals = buildExecApprovalsJson({ agentMetas });
@@ -41,8 +41,189 @@ async function syncExecApprovals(projectDir, cfg) {
41
41
  if (existing.socket) {
42
42
  approvals.socket = existing.socket;
43
43
  }
44
- await fsp.writeFile(path2, JSON.stringify(approvals, null, 2), 'utf8');
45
- }
44
+ await fsp.writeFile(path2, JSON.stringify(approvals, null, 2), 'utf8');
45
+ }
46
+
47
+ async function patchBrowserAutomationHostPreference(projectDir, aliases = [], sendLog = () => {}) {
48
+ const preferredCdpBlock = `const dns = require('dns').promises;
49
+ const DEFAULT_CDP_URLS = [
50
+ 'host-gateway:9222',
51
+ 'http://127.0.0.1:9222',
52
+ ];
53
+ const CDP_URLS = (process.env.OPENCLAW_BROWSER_CDP_URLS || DEFAULT_CDP_URLS.join(','))
54
+ .split(',')
55
+ .map((u) => u.trim())
56
+ .filter(Boolean);
57
+
58
+ async function normalizeCdpUrl(url) {
59
+ if (url === 'host-gateway:9222') {
60
+ try {
61
+ const resolved = await dns.lookup('host.docker.internal');
62
+ return 'http://' + resolved.address + ':9222';
63
+ } catch (_) {
64
+ return 'http://host.docker.internal:9222';
65
+ }
66
+ }
67
+ return url;
68
+ }
69
+
70
+ async function connectPreferredChrome() {
71
+ let lastError;
72
+ for (const rawUrl of CDP_URLS) {
73
+ const url = await normalizeCdpUrl(rawUrl);
74
+ try {
75
+ const connected = await chromium.connectOverCDP(url, { timeout: 2500 });
76
+ console.error('[Browser] Connected CDP: ' + url);
77
+ return connected;
78
+ } catch (err) {
79
+ lastError = err;
80
+ }
81
+ }
82
+ throw lastError || new Error('No Chrome CDP endpoint available');
83
+ }`;
84
+
85
+ const patchContent = (content) => {
86
+ let next = content;
87
+ next = next.replace(
88
+ "const CDP_URL = 'http://127.0.0.1:9222';",
89
+ preferredCdpBlock
90
+ );
91
+ next = next.replace(
92
+ 'browser = await chromium.connectOverCDP(CDP_URL, { timeout: 5000 });',
93
+ 'browser = await connectPreferredChrome();'
94
+ );
95
+ return next;
96
+ };
97
+
98
+ const browserToolCandidates = new Set();
99
+ const extensionDirs = [];
100
+ for (const alias of aliases) {
101
+ const extensionDir = join(projectDir, '.openclaw', 'extensions', alias);
102
+ extensionDirs.push(extensionDir);
103
+ browserToolCandidates.add(join(extensionDir, 'browser-tool.js'));
104
+ }
105
+
106
+ const workspaceDirs = new Set();
107
+ try {
108
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
109
+ if (existsSync(cfgPath)) {
110
+ const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
111
+ for (const a of cfg.agents?.list || []) {
112
+ const workspaceRel = a.workspace || cfg.agents?.defaults?.workspace;
113
+ if (!workspaceRel) continue;
114
+ const workspacePath = workspaceRel.startsWith('/') ? join(projectDir, workspaceRel.replace(/^\/home\/node\/project\/?/, '')) : join(projectDir, workspaceRel);
115
+ workspaceDirs.add(workspacePath);
116
+ browserToolCandidates.add(join(workspacePath, 'plugin-skills', 'browser-automation', 'browser-tool.js'));
117
+ }
118
+ }
119
+ } catch (err) {
120
+ sendLog(`[browser] Warning: could not scan workspaces for browser-tool.js: ${err.message}`);
121
+ }
122
+
123
+ let patched = 0;
124
+ for (const file of browserToolCandidates) {
125
+ if (!existsSync(file)) continue;
126
+ const content = await fsp.readFile(file, 'utf8');
127
+ if (content.includes('connectPreferredChrome')) continue;
128
+ const next = patchContent(content);
129
+ if (next !== content) {
130
+ await fsp.writeFile(file, next, 'utf8');
131
+ patched += 1;
132
+ }
133
+ }
134
+ if (patched > 0) {
135
+ sendLog(`[browser] Patched ${patched} browser-tool.js file(s) to prefer host Chrome debug before headless Chromium.`);
136
+ }
137
+
138
+ const browserMd = `# Browser Automation
139
+
140
+ This plugin skill owns browser automation only. For normal web search, use OpenClaw's built-in \`web_search\` capability.
141
+
142
+ Run commands from this folder or pass the full path from the workspace root:
143
+
144
+ - \`cd plugin-skills/browser-automation && node browser-tool.js status\`
145
+ - \`node plugin-skills/browser-automation/browser-tool.js status\`
146
+
147
+ ## Chrome Debug Mode
148
+
149
+ On a desktop machine, start real Chrome in debug mode before asking the bot to browse:
150
+
151
+ - Windows: run \`start-chrome-debug.bat\`
152
+ - macOS/Linux: run \`./start-chrome-debug.sh\`
153
+
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.
155
+
156
+ ## Browser Commands
157
+
158
+ - \`node plugin-skills/browser-automation/browser-tool.js status\`: check the active browser/tab
159
+ - \`node plugin-skills/browser-automation/browser-tool.js open <url>\`: open a page
160
+ - \`node plugin-skills/browser-automation/browser-tool.js get_text [max_chars]\`: read rendered page text
161
+ - \`node plugin-skills/browser-automation/browser-tool.js get_links [filter]\`: list links
162
+ - \`node plugin-skills/browser-automation/browser-tool.js click "<selector>"\`: click an element
163
+ - \`node plugin-skills/browser-automation/browser-tool.js fill "<selector>" "<text>"\`: fill an input
164
+ - \`node plugin-skills/browser-automation/browser-tool.js scroll [px]\`: scroll the page
165
+ - \`node plugin-skills/browser-automation/browser-tool.js screenshot [path]\`: capture the viewport
166
+ - \`node plugin-skills/browser-automation/browser-tool.js tabs\`: list tabs
167
+
168
+ Do not call \`search-tool.js\`; browser-automation does not own search. Use \`web_search\` for search and this browser tool only when a rendered browser is needed.
169
+ `;
170
+
171
+ const removeManagedBlock = (content, blockId) => {
172
+ const startTag = `<!-- OPENCLAW:${blockId}:START -->`;
173
+ const endTag = `<!-- OPENCLAW:${blockId}:END -->`;
174
+ const startIdx = content.indexOf(startTag);
175
+ const endIdx = content.indexOf(endTag);
176
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return content;
177
+ return `${content.substring(0, startIdx).trimEnd()}\n${content.substring(endIdx + endTag.length).trimStart()}`.trim() + '\n';
178
+ };
179
+
180
+ const hostOs = normalizeHostOs(await resolveProjectHostOs(projectDir));
181
+ 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));
185
+ const sourceBrowserTool = extensionDirs.map((dir) => join(dir, 'browser-tool.js')).find((file) => existsSync(file));
186
+
187
+ let sanitized = 0;
188
+ for (const workspacePath of workspaceDirs) {
189
+ if (!existsSync(workspacePath)) continue;
190
+ const pluginSkillPath = join(workspacePath, 'plugin-skills', 'browser-automation');
191
+ await fsp.mkdir(pluginSkillPath, { recursive: true }).catch(() => {});
192
+ await fsp.rm(join(workspacePath, 'search-tool.js'), { force: true }).catch(() => {});
193
+ await fsp.rm(join(workspacePath, 'browser-tool.js'), { force: true }).catch(() => {});
194
+ await fsp.rm(join(workspacePath, 'BROWSER.md'), { force: true }).catch(() => {});
195
+ await fsp.rm(join(workspacePath, scriptToRemove), { force: true }).catch(() => {});
196
+ await fsp.rm(join(workspacePath, scriptToKeep), { force: true }).catch(() => {});
197
+ await fsp.rm(join(pluginSkillPath, scriptToRemove), { force: true }).catch(() => {});
198
+ if (sourceBrowserTool) {
199
+ const targetBrowserTool = join(pluginSkillPath, 'browser-tool.js');
200
+ await fsp.copyFile(sourceBrowserTool, targetBrowserTool).catch(() => {});
201
+ if (existsSync(targetBrowserTool)) {
202
+ const content = await fsp.readFile(targetBrowserTool, 'utf8');
203
+ const next = content.includes('connectPreferredChrome') ? content : patchContent(content);
204
+ if (next !== content) await fsp.writeFile(targetBrowserTool, next, 'utf8');
205
+ }
206
+ }
207
+ if (sourceScript) {
208
+ await fsp.copyFile(sourceScript, join(pluginSkillPath, scriptToKeep)).catch(() => {});
209
+ if (scriptToKeep.endsWith('.sh')) await fsp.chmod(join(pluginSkillPath, scriptToKeep), 0o755).catch(() => {});
210
+ }
211
+ await fsp.writeFile(join(pluginSkillPath, 'BROWSER.md'), browserMd, 'utf8');
212
+ for (const dirName of ['cl-stealth-search', 'openclaw-smart-search']) {
213
+ await fsp.rm(join(workspacePath, 'plugin-skills', dirName), { recursive: true, force: true }).catch(() => {});
214
+ }
215
+ const toolsMdPath = join(workspacePath, 'TOOLS.md');
216
+ if (existsSync(toolsMdPath)) {
217
+ const toolsContent = await fsp.readFile(toolsMdPath, 'utf8');
218
+ const cleaned = removeManagedBlock(toolsContent, 'STEALTH_BROWSER_GUIDE');
219
+ if (cleaned !== toolsContent) await fsp.writeFile(toolsMdPath, cleaned, 'utf8');
220
+ }
221
+ sanitized += 1;
222
+ }
223
+ if (sanitized > 0) {
224
+ sendLog(`[browser] Sanitized ${sanitized} workspace(s): browser assets are in plugin-skills/browser-automation; web_search remains responsible for search.`);
225
+ }
226
+ }
46
227
 
47
228
  const __dirname = dirname(fileURLToPath(import.meta.url));
48
229
  const WEB_DIR = resolve(__dirname, '../web');
@@ -88,6 +269,7 @@ const state = {
88
269
  mode: null,
89
270
  os: null,
90
271
  startedAt: null,
272
+ projects: null,
91
273
  };
92
274
 
93
275
  function sendLog(line) {
@@ -113,13 +295,39 @@ function extractCompletePngBase64(stdout) {
113
295
  return b64;
114
296
  }
115
297
 
116
- function detectOs() {
117
- const platform = process.platform;
118
- if (platform === 'win32') return 'win';
119
- if (platform === 'darwin') return 'macos';
120
- if (platform === 'linux') return os.release().toLowerCase().includes('microsoft') ? 'linux-desktop' : 'linux-desktop';
121
- return 'linux-desktop';
122
- }
298
+ function detectOs() {
299
+ const platform = process.platform;
300
+ if (platform === 'win32') return 'win';
301
+ if (platform === 'darwin') return 'macos';
302
+ if (platform === 'linux') return os.release().toLowerCase().includes('microsoft') ? 'linux-desktop' : 'linux-desktop';
303
+ return 'linux-desktop';
304
+ }
305
+
306
+ function normalizeHostOs(value = '') {
307
+ const v = String(value || '').trim().toLowerCase();
308
+ if (['win', 'windows', 'win32'].includes(v)) return 'win';
309
+ if (['mac', 'macos', 'darwin'].includes(v)) return 'macos';
310
+ if (['vps', 'server'].includes(v)) return 'vps';
311
+ if (['linux', 'linux-desktop', 'ubuntu', 'debian'].includes(v)) return 'linux-desktop';
312
+ return '';
313
+ }
314
+
315
+ async function resolveProjectHostOs(projectDir = '') {
316
+ try {
317
+ const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
318
+ if (existsSync(cfgPath)) {
319
+ const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
320
+ const pluginEntries = cfg.plugins?.entries || {};
321
+ const browserEntry = pluginEntries['browser-automation'] || pluginEntries['openclaw-browser-automation'] || {};
322
+ const fromConfig = normalizeHostOs(browserEntry.config?.hostOs || cfg.meta?.osChoice || cfg.meta?.hostOs);
323
+ if (fromConfig) return fromConfig;
324
+ }
325
+ } catch {}
326
+ const fromState = normalizeHostOs(state.os);
327
+ if (fromState) return fromState;
328
+ if (/^[A-Za-z]:[\\/]/.test(String(projectDir || ''))) return 'win';
329
+ return detectOs();
330
+ }
123
331
 
124
332
  function getRealHomedir() {
125
333
  const home = os.homedir();
@@ -1074,7 +1282,7 @@ async function createBotInProject(projectDir, body = {}, runtime = {}) {
1074
1282
  const emoji = String(body.emoji || '').trim();
1075
1283
  const userName = String(body.userName || '').trim();
1076
1284
  const userDesc = String(body.userDescription || body.userDesc || '').trim();
1077
- const userInfo = [userName ? `- **Tên:** ${userName}` : '', userDesc ? `- **Mô tả:** ${userDesc}` : ''].filter(Boolean).join('\n');
1285
+ const userInfo = [userName ? `- **Tên:** ${userName}` : '', userDesc ? `- ** tả:** ${userDesc}` : ''].filter(Boolean).join('\n');
1078
1286
 
1079
1287
  const openclawHome = join(projectDir, '.openclaw');
1080
1288
  await fsp.mkdir(openclawHome, { recursive: true });
@@ -1607,8 +1815,9 @@ async function syncDockerInfra(projectDir, force = false) {
1607
1815
  const botContainer = parseComposeServiceContainerName(compose, 'ai-bot') || `openclaw-${slugify(basename(projectDir))}`;
1608
1816
  const routerContainer = parseComposeServiceContainerName(compose, '9router') || `9router-${slugify(basename(projectDir))}`;
1609
1817
  const composeName = (compose.match(/^name:\s*(\S+)/m) || [])[1] || `oc-${slugify(basename(projectDir))}`;
1610
- const gatewayPort = state.gatewayPort || 18789;
1611
- const routerPort = state.routerPort || 20128;
1818
+ const gatewayPort = state.gatewayPort || 18789;
1819
+ const routerPort = state.routerPort || 20128;
1820
+ const osChoice = await resolveProjectHostOs(projectDir);
1612
1821
 
1613
1822
  // Detect features from openclaw.json
1614
1823
  const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
@@ -1623,9 +1832,10 @@ async function syncDockerInfra(projectDir, force = false) {
1623
1832
  is9Router: true,
1624
1833
  openClawNpmSpec: OPENCLAW_NPM_SPEC,
1625
1834
  openClawRuntimePackages: '',
1626
- allSkills: [],
1627
- dockerfilePlugins: [],
1628
- gatewayPort,
1835
+ allSkills: [],
1836
+ dockerfilePlugins: [],
1837
+ osChoice,
1838
+ gatewayPort,
1629
1839
  routerPort,
1630
1840
  singleComposeName: composeName,
1631
1841
  singleAppContainerName: botContainer,
@@ -1944,6 +2154,7 @@ async function saveState(rootProjectDir) {
1944
2154
  gatewayPort: state.gatewayPort,
1945
2155
  routerUrl: state.routerUrl,
1946
2156
  routerPort: state.routerPort,
2157
+ projects: state.projects || [],
1947
2158
  }, null, 2), 'utf8').catch(() => {});
1948
2159
  }
1949
2160
 
@@ -1951,6 +2162,9 @@ async function loadSavedState(rootProjectDir) {
1951
2162
  const file = join(rootProjectDir, STATE_FILE);
1952
2163
  if (!existsSync(file)) return;
1953
2164
  const saved = JSON.parse(await fsp.readFile(file, 'utf8'));
2165
+ if (Array.isArray(saved?.projects)) {
2166
+ state.projects = saved.projects;
2167
+ }
1954
2168
  if (saved?.projectDir && existsSync(join(saved.projectDir, '.openclaw', 'openclaw.json'))) {
1955
2169
  Object.assign(state, saved, { installed: !!saved.installed });
1956
2170
  await syncRuntimeState(state.projectDir);
@@ -1974,20 +2188,31 @@ function isRestrictedSystemDir(dirPath) {
1974
2188
  if (lower.includes(':\\users\\') || lower.endsWith(':\\users')) {
1975
2189
  const home = resolve(getRealHomedir()).toLowerCase();
1976
2190
  if (lower !== home && !lower.startsWith(home + '\\') && !lower.startsWith(home + '/')) {
1977
- return true;
2191
+ const cwd = resolve(process.cwd()).toLowerCase();
2192
+ const match = cwd.match(/^([a-z]:\\users\\[^\\]+)/) || cwd.match(/^(\/mnt\/[a-z]\/users\/[^\/]+)/);
2193
+ const cwdHome = match ? match[1] : '';
2194
+ if (!cwdHome || (lower !== cwdHome && !lower.startsWith(cwdHome + '\\') && !lower.startsWith(cwdHome + '/'))) {
2195
+ return true;
2196
+ }
1978
2197
  }
1979
2198
  }
1980
2199
 
1981
2200
  if (process.platform !== 'win32') {
1982
2201
  const unixBlacklist = new Set([
1983
- 'usr', 'var', 'proc', 'sys', 'dev', 'etc', 'sbin', 'bin', 'lib', 'lib64', 'run', 'tmp', 'boot', 'lost+found', 'srv', 'mnt', 'media', 'opt'
2202
+ 'usr', 'var', 'proc', 'sys', 'dev', 'etc', 'sbin', 'bin', 'lib', 'lib64', 'run', 'tmp', 'boot', 'lost+found', 'srv', 'mnt', 'media', 'opt',
2203
+ 'applications', 'library', 'system', 'volumes', 'private', 'cores', 'network', 'users'
1984
2204
  ]);
1985
2205
  if (unixBlacklist.has(basename(lower))) return true;
1986
2206
 
1987
- if (lower.startsWith('/home') || lower.startsWith('/root')) {
2207
+ if (lower.startsWith('/home') || lower.startsWith('/root') || lower.startsWith('/mnt/') || lower.startsWith('/users/') || lower === '/users') {
1988
2208
  const realHome = resolve(getRealHomedir()).toLowerCase();
1989
2209
  if (lower !== realHome && !lower.startsWith(realHome + '/')) {
1990
- return true;
2210
+ const cwd = resolve(process.cwd()).toLowerCase();
2211
+ const match = cwd.match(/^(\/home\/[^\/]+)/) || cwd.match(/^(\/root)/) || cwd.match(/^(\/mnt\/[a-z]\/users\/[^\/]+)/) || cwd.match(/^(\/users\/[^\/]+)/);
2212
+ const cwdHome = match ? match[1] : '';
2213
+ if (!cwdHome || (lower !== cwdHome && !lower.startsWith(cwdHome + '/'))) {
2214
+ return true;
2215
+ }
1991
2216
  }
1992
2217
  }
1993
2218
  }
@@ -2045,108 +2270,114 @@ async function findLatestProject(rootProjectDir) {
2045
2270
  return candidates[0]?.dir || null;
2046
2271
  }
2047
2272
 
2048
- async function discoverProjects(rootProjectDir) {
2049
- const realHome = getRealHomedir();
2050
- const roots = [
2051
- process.env.OPENCLAW_PROJECT_DIR,
2052
- rootProjectDir,
2053
- dirname(rootProjectDir),
2054
- process.env.OPENCLAW_HOME ? dirname(process.env.OPENCLAW_HOME) : '',
2055
- realHome,
2056
- join(realHome, 'Documents'),
2057
- ].filter(Boolean);
2058
-
2059
- const drives = await getAvailableDrives();
2060
- for (const drive of drives) {
2061
- const entries = await fsp.readdir(drive, { withFileTypes: true }).catch(() => []);
2062
- for (const e of entries) {
2063
- if (e.isDirectory() && !e.name.startsWith('$') && !SYSTEM_DIR_BLACKLIST.has(e.name.toLowerCase())) {
2064
- const fullPath = join(drive, e.name);
2065
- if (!isRestrictedSystemDir(fullPath)) {
2066
- roots.push(fullPath);
2067
- }
2273
+ async function ensureProjectsLoaded(rootProjectDir) {
2274
+ if (state.projects !== null) return;
2275
+ state.projects = [];
2276
+ const file = join(rootProjectDir, STATE_FILE);
2277
+ if (existsSync(file)) {
2278
+ try {
2279
+ const saved = JSON.parse(await fsp.readFile(file, 'utf8'));
2280
+ if (Array.isArray(saved?.projects)) {
2281
+ state.projects = saved.projects;
2068
2282
  }
2069
- }
2070
- }
2071
- const seen = new Set();
2072
- const hits = [];
2073
- async function walk(dir, depth = 0) {
2074
- if (!dir || depth > 2 || !existsSync(dir)) return;
2075
- const full = resolve(dir);
2076
- if (isRestrictedSystemDir(full)) return;
2077
- if (seen.has(full)) return;
2078
- seen.add(full);
2079
- const cfgPath = join(full, '.openclaw', 'openclaw.json');
2080
- if (existsSync(cfgPath)) {
2081
- const st = await fsp.stat(cfgPath).catch(() => null);
2082
- const runtime = await detectRuntime(full).catch(() => ({ mode: 'unknown', gatewayPort: 0, routerPort: 0, syncSource: 'config' }));
2083
- const bots = await listConfiguredBots(full).catch(() => []);
2084
- const uniqueBotCount = new Set(bots.map((b) => b.id)).size;
2085
- const hasDocker = existsSync(join(full, 'docker', 'openclaw', 'docker-compose.yml'));
2086
- const isLikelyProject = uniqueBotCount > 0 || hasDocker || existsSync(join(full, '.env')) || existsSync(join(full, 'package.json'));
2087
- if (!isLikelyProject) return;
2088
- hits.push({
2089
- projectDir: full,
2090
- os: process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'macOS' : 'Linux',
2091
- mode: runtime.mode || 'unknown',
2092
- gatewayPort: runtime.gatewayPort || 0,
2093
- routerPort: runtime.routerPort || 0,
2094
- syncSource: runtime.syncSource || 'config',
2095
- botCount: uniqueBotCount,
2096
- hasDocker,
2097
- updatedAt: st?.mtimeMs || 0,
2098
- });
2099
- return;
2100
- }
2101
- const entries = await fsp.readdir(full, { withFileTypes: true }).catch(() => []);
2102
- for (const e of entries) {
2103
- if (!e.isDirectory()) continue;
2104
- if (e.name === 'node_modules' || e.name.startsWith('.') || SYSTEM_DIR_BLACKLIST.has(e.name.toLowerCase())) continue;
2105
- await walk(join(full, e.name), depth + 1);
2106
- }
2283
+ } catch {}
2107
2284
  }
2108
- for (const root of roots) await walk(root);
2109
- hits.sort((a, b) =>
2110
- (b.botCount - a.botCount) ||
2111
- (Number(b.hasDocker) - Number(a.hasDocker)) ||
2112
- (b.updatedAt - a.updatedAt)
2113
- );
2114
- return hits.slice(0, 20);
2115
2285
  }
2116
2286
 
2117
- async function resolveProjectDir(rootProjectDir, body = {}) {
2118
- if (body.projectDir && existsSync(join(resolve(String(body.projectDir)), '.openclaw', 'openclaw.json'))) {
2119
- state.projectDir = resolve(String(body.projectDir));
2120
- await syncRuntimeState(state.projectDir);
2121
- return state.projectDir;
2122
- }
2123
- const envProjectDir = process.env.OPENCLAW_PROJECT_DIR || (process.env.OPENCLAW_HOME ? dirname(process.env.OPENCLAW_HOME) : '');
2124
- if (envProjectDir && existsSync(join(resolve(String(envProjectDir)), '.openclaw', 'openclaw.json'))) {
2125
- state.projectDir = resolve(String(envProjectDir));
2126
- await syncRuntimeState(state.projectDir);
2127
- return state.projectDir;
2128
- }
2129
- if (state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json'))) {
2130
- await syncRuntimeState(state.projectDir);
2131
- return state.projectDir;
2132
- }
2133
- await loadSavedState(rootProjectDir);
2287
+ async function discoverProjects(rootProjectDir) {
2288
+ await ensureProjectsLoaded(rootProjectDir);
2289
+
2134
2290
  if (state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json'))) {
2135
- await syncRuntimeState(state.projectDir);
2136
- return state.projectDir;
2291
+ const resolved = resolve(state.projectDir);
2292
+ if (!state.projects.some(p => resolve(p.projectDir) === resolved)) {
2293
+ const meta = await buildProjectMeta(resolved).catch(() => null);
2294
+ if (meta) state.projects.push(meta);
2295
+ }
2137
2296
  }
2138
- const found = await findLatestProject(rootProjectDir);
2139
- if (found) {
2140
- await syncRuntimeState(found);
2141
- await saveState(rootProjectDir);
2297
+
2298
+ const updatedProjects = [];
2299
+ for (const p of state.projects) {
2300
+ if (existsSync(join(p.projectDir, '.openclaw', 'openclaw.json'))) {
2301
+ const meta = await buildProjectMeta(p.projectDir).catch(() => null);
2302
+ if (meta) {
2303
+ updatedProjects.push(meta);
2304
+ }
2305
+ }
2142
2306
  }
2307
+ state.projects = updatedProjects;
2308
+
2309
+ state.projects.sort((a, b) => {
2310
+ const aActive = state.projectDir && resolve(state.projectDir) === resolve(a.projectDir);
2311
+ const bActive = state.projectDir && resolve(state.projectDir) === resolve(b.projectDir);
2312
+ if (aActive && !bActive) return -1;
2313
+ if (!aActive && bActive) return 1;
2314
+ return (b.botCount - a.botCount) || (b.updatedAt - a.updatedAt);
2315
+ });
2316
+
2317
+ await saveState(rootProjectDir).catch(() => {});
2318
+ return state.projects.slice(0, 20);
2319
+ }
2320
+
2321
+ async function resolveProjectDir(rootProjectDir, body = {}) {
2322
+ if (body.projectDir && existsSync(join(resolve(String(body.projectDir)), '.openclaw', 'openclaw.json'))) {
2323
+ state.projectDir = resolve(String(body.projectDir));
2324
+ await syncRuntimeState(state.projectDir);
2325
+ return state.projectDir;
2326
+ }
2327
+ if (state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json'))) {
2328
+ await syncRuntimeState(state.projectDir);
2329
+ return state.projectDir;
2330
+ }
2331
+ await loadSavedState(rootProjectDir);
2332
+ if (state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json'))) {
2333
+ await syncRuntimeState(state.projectDir);
2334
+ return state.projectDir;
2335
+ }
2336
+ const envProjectDir = process.env.OPENCLAW_PROJECT_DIR || (process.env.OPENCLAW_HOME ? dirname(process.env.OPENCLAW_HOME) : '');
2337
+ if (envProjectDir && existsSync(join(resolve(String(envProjectDir)), '.openclaw', 'openclaw.json'))) {
2338
+ state.projectDir = resolve(String(envProjectDir));
2339
+ await syncRuntimeState(state.projectDir);
2340
+ return state.projectDir;
2341
+ }
2342
+ const found = await findLatestProject(rootProjectDir);
2343
+ if (found) {
2344
+ await syncRuntimeState(found);
2345
+ await saveState(rootProjectDir);
2346
+ }
2143
2347
  return state.projectDir;
2144
2348
  }
2145
2349
 
2350
+ async function buildProjectMeta(projectDir) {
2351
+ const full = resolve(projectDir);
2352
+ const cfgPath = join(full, '.openclaw', 'openclaw.json');
2353
+ const st = await fsp.stat(cfgPath).catch(() => null);
2354
+ const runtime = await detectRuntime(full).catch(() => ({ mode: 'unknown', gatewayPort: 0, routerPort: 0, syncSource: 'config' }));
2355
+ const bots = await listConfiguredBots(full).catch(() => []);
2356
+ const uniqueBotCount = new Set(bots.map((b) => b.id)).size;
2357
+ const hasDocker = existsSync(join(full, 'docker', 'openclaw', 'docker-compose.yml'));
2358
+ return {
2359
+ projectDir: full,
2360
+ os: process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'macOS' : 'Linux',
2361
+ mode: runtime.mode || 'unknown',
2362
+ gatewayPort: runtime.gatewayPort || 0,
2363
+ routerPort: runtime.routerPort || 0,
2364
+ syncSource: runtime.syncSource || 'config',
2365
+ botCount: uniqueBotCount,
2366
+ hasDocker,
2367
+ updatedAt: st?.mtimeMs || 0,
2368
+ };
2369
+ }
2370
+
2146
2371
  async function connectExistingProject(projectDir, rootProjectDir) {
2147
2372
  const resolved = resolve(String(projectDir || ''));
2148
2373
  if (!existsSync(join(resolved, '.openclaw', 'openclaw.json'))) throw httpError(404, 'openclaw.json not found in selected project');
2149
2374
  await syncRuntimeState(resolved);
2375
+ await ensureProjectsLoaded(rootProjectDir);
2376
+ const meta = await buildProjectMeta(resolved).catch(() => null);
2377
+ if (meta) {
2378
+ state.projects = state.projects.filter(p => resolve(p.projectDir) !== resolved);
2379
+ state.projects.unshift(meta);
2380
+ }
2150
2381
  await saveState(rootProjectDir);
2151
2382
  const bots = await listConfiguredBots(resolved).catch(() => []);
2152
2383
  return {
@@ -2200,6 +2431,8 @@ async function deleteProjectFolder(projectDir, rootProjectDir) {
2200
2431
  state.projectDir = null;
2201
2432
  state.installed = false;
2202
2433
  }
2434
+ await ensureProjectsLoaded(rootProjectDir);
2435
+ state.projects = state.projects.filter(p => resolve(p.projectDir) !== resolved);
2203
2436
  await saveState(rootProjectDir);
2204
2437
  return { ok: true, projectDir: resolved };
2205
2438
  }
@@ -2263,15 +2496,17 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
2263
2496
  cfg.plugins.allow = cfg.plugins.allow || [];
2264
2497
  if (enabled) {
2265
2498
  if (!cfg.plugins.allow.includes(existingKey)) cfg.plugins.allow.push(existingKey);
2266
- } else {
2267
- cfg.plugins.allow = cfg.plugins.allow.filter((x) => x !== existingKey);
2268
- for (const a of cfg.agents.list) {
2269
- const bf = await readWorkspaceText(projectDir, a, 'BROWSER.md');
2270
- if (existsSync(bf.file)) await fsp.rm(bf.file, { force: true });
2271
- const bt = await readWorkspaceText(projectDir, a, 'browser-tool.js');
2272
- if (existsSync(bt.file)) await fsp.rm(bt.file, { force: true });
2273
- }
2274
- }
2499
+ } else {
2500
+ cfg.plugins.allow = cfg.plugins.allow.filter((x) => x !== existingKey);
2501
+ for (const a of cfg.agents.list) {
2502
+ const bf = await readWorkspaceText(projectDir, a, 'BROWSER.md');
2503
+ if (existsSync(bf.file)) await fsp.rm(bf.file, { force: true });
2504
+ const bt = await readWorkspaceText(projectDir, a, 'browser-tool.js');
2505
+ if (existsSync(bt.file)) await fsp.rm(bt.file, { force: true });
2506
+ const rel = workspaceRelForAgent(a, cfg, projectDir);
2507
+ await fsp.rm(join(projectDir, '.openclaw', rel, 'plugin-skills', 'browser-automation'), { recursive: true, force: true }).catch(() => {});
2508
+ }
2509
+ }
2275
2510
 
2276
2511
  // Write cfgPath early so syncDockerInfra reads the updated openclaw.json
2277
2512
  await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
@@ -2562,12 +2797,17 @@ async function installFeature(projectDir, agentId, kind, id) {
2562
2797
  const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
2563
2798
  cfg.plugins = cfg.plugins || { entries: {} };
2564
2799
  cfg.plugins.entries = cfg.plugins.entries || {};
2565
- const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2566
- cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2567
- cfg.plugins.entries[existingKey].enabled = true;
2568
- if (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod') {
2569
- ensureZaloModPluginConfig(cfg.plugins.entries[existingKey], cfg);
2570
- }
2800
+ const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2801
+ cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2802
+ cfg.plugins.entries[existingKey].enabled = true;
2803
+ if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
2804
+ cfg.plugins.entries[existingKey].config = Object.assign({}, cfg.plugins.entries[existingKey].config, {
2805
+ hostOs: await resolveProjectHostOs(projectDir),
2806
+ });
2807
+ }
2808
+ if (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod') {
2809
+ ensureZaloModPluginConfig(cfg.plugins.entries[existingKey], cfg);
2810
+ }
2571
2811
  // Only add the canonical config key to allow list (not all aliases)
2572
2812
  if (!cfg.plugins.allow.includes(existingKey)) cfg.plugins.allow.push(existingKey);
2573
2813
  await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
@@ -2595,14 +2835,15 @@ async function installFeature(projectDir, agentId, kind, id) {
2595
2835
  }
2596
2836
  }
2597
2837
 
2598
- // Browser-automation plugin needs Docker rebuild for Playwright/Chromium deps
2599
- const isBrowserPlugin = id === 'openclaw-browser-automation' || id === 'browser-automation';
2600
- if (isBrowserPlugin && composeDir) {
2601
- sendLog(`[plugin] Browser plugin requires Docker rebuild for Playwright/Chromium...`);
2602
- const svcName = getBotServiceName(projectDir);
2603
- await run('docker', ['compose', '-f', join(composeDir, 'docker-compose.yml'), 'up', '-d', '--build', '--force-recreate', svcName], { shell: false }).catch((err) => {
2604
- sendLog(`[plugin] Docker rebuild failed: ${err.message}. Falling back to restart...`);
2605
- return run('docker', ['restart', botContainer], { shell: false });
2838
+ // Browser-automation plugin needs Docker rebuild for Playwright/Chromium deps
2839
+ const isBrowserPlugin = id === 'openclaw-browser-automation' || id === 'browser-automation';
2840
+ if (isBrowserPlugin && composeDir) {
2841
+ await patchBrowserAutomationHostPreference(projectDir, aliases, sendLog);
2842
+ sendLog(`[plugin] Browser plugin requires Docker rebuild for Playwright/Chromium...`);
2843
+ const svcName = getBotServiceName(projectDir);
2844
+ await run('docker', ['compose', '-f', join(composeDir, 'docker-compose.yml'), 'up', '-d', '--build', '--force-recreate', svcName], { shell: false }).catch((err) => {
2845
+ sendLog(`[plugin] Docker rebuild failed: ${err.message}. Falling back to restart...`);
2846
+ return run('docker', ['restart', botContainer], { shell: false });
2606
2847
  });
2607
2848
  } else if (isZaloMod && composeDir) {
2608
2849
  // Use docker compose up to apply new port mappings from docker-compose.yml
@@ -2638,26 +2879,37 @@ async function installFeature(projectDir, agentId, kind, id) {
2638
2879
 
2639
2880
  // Automatically enable it in config after install
2640
2881
  const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
2641
- if (existsSync(cfgPath)) {
2642
- const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
2643
- cfg.plugins = cfg.plugins || { entries: {} };
2644
- cfg.plugins.entries = cfg.plugins.entries || {};
2645
- const pluginAliasMap = {
2646
- 'openclaw-zalo-mod': ['zalo-mod', 'openclaw-zalo-mod'],
2647
- 'openclaw-facebook-crawler': ['openclaw-facebook-crawler', 'openclaw-n8n-facebook-crawler', 'n8n-facebook-crawler'],
2648
- 'openclaw-n8n-facebook-poster': ['openclaw-n8n-facebook-poster', 'openclaw-facebook-poster', 'facebook-poster'],
2649
- };
2650
- const aliases = pluginAliasMap[id] || [id];
2651
- const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2652
- cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2653
- cfg.plugins.entries[existingKey].enabled = true;
2654
- if (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod') {
2655
- ensureZaloModPluginConfig(cfg.plugins.entries[existingKey], cfg);
2656
- }
2657
- await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
2658
- }
2659
- }
2660
- }
2882
+ if (existsSync(cfgPath)) {
2883
+ const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
2884
+ cfg.plugins = cfg.plugins || { entries: {} };
2885
+ cfg.plugins.entries = cfg.plugins.entries || {};
2886
+ const pluginAliasMap = {
2887
+ 'openclaw-browser-automation': ['browser-automation', 'openclaw-browser-automation'],
2888
+ 'openclaw-zalo-mod': ['zalo-mod', 'openclaw-zalo-mod'],
2889
+ 'openclaw-facebook-crawler': ['openclaw-facebook-crawler', 'openclaw-n8n-facebook-crawler', 'n8n-facebook-crawler'],
2890
+ 'openclaw-n8n-facebook-poster': ['openclaw-n8n-facebook-poster', 'openclaw-facebook-poster', 'facebook-poster'],
2891
+ };
2892
+ const aliases = pluginAliasMap[id] || [id];
2893
+ const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2894
+ cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2895
+ cfg.plugins.entries[existingKey].enabled = true;
2896
+ if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
2897
+ cfg.plugins.entries[existingKey].config = Object.assign({}, cfg.plugins.entries[existingKey].config, {
2898
+ hostOs: await resolveProjectHostOs(projectDir),
2899
+ });
2900
+ }
2901
+ if (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod') {
2902
+ ensureZaloModPluginConfig(cfg.plugins.entries[existingKey], cfg);
2903
+ }
2904
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
2905
+ }
2906
+
2907
+ if (id === 'openclaw-browser-automation' || id === 'browser-automation') {
2908
+ const aliases = ['browser-automation', 'openclaw-browser-automation'];
2909
+ await patchBrowserAutomationHostPreference(projectDir, aliases, sendLog);
2910
+ }
2911
+ }
2912
+ }
2661
2913
  return { ok: true };
2662
2914
  }
2663
2915
 
@@ -2931,10 +3183,10 @@ async function handler(req, res, rootProjectDir) {
2931
3183
  const body = await readJson(req);
2932
3184
  return json(res, await connectPickedProject(body.projectName, rootProjectDir));
2933
3185
  }
2934
- if (url.pathname === '/api/bot/status' && req.method === 'GET') {
2935
- await resolveProjectDir(rootProjectDir);
2936
- return json(res, await buildBotStatus());
2937
- }
3186
+ if (url.pathname === '/api/bot/status' && req.method === 'GET') {
3187
+ await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3188
+ return json(res, await buildBotStatus());
3189
+ }
2938
3190
  if (url.pathname === '/api/bot/credentials' && req.method === 'PUT') {
2939
3191
  const body = await readJson(req);
2940
3192
  const projectDir = await resolveProjectDir(rootProjectDir, body);
@@ -3061,29 +3313,34 @@ async function handler(req, res, rootProjectDir) {
3061
3313
  });
3062
3314
  return json(res, { ok: true, message: 'Zalo login initiated. QR will appear in UI.' });
3063
3315
  }
3064
- if (url.pathname.startsWith('/api/bot/') && req.method === 'DELETE' && !url.pathname.startsWith('/api/bot/files/')) {
3065
- const agentId = decodeURIComponent(url.pathname.replace('/api/bot/', ''));
3066
- const projectDir = await resolveProjectDir(rootProjectDir);
3067
- const result = await deleteBotInProject(projectDir, agentId);
3068
- sendLog(`? Bot deleted: ${agentId}`);
3069
- await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] recreate skipped/failed: ${err.message}`));
3070
- return json(res, result);
3071
- }
3072
- if (url.pathname === '/api/bot/files' && req.method === 'GET') {
3073
- await resolveProjectDir(rootProjectDir);
3074
- return json(res, { files: state.projectDir ? await listMarkdownFiles(state.projectDir, url.searchParams.get('agentId') || '') : [] });
3075
- }
3076
- if (url.pathname.startsWith('/api/bot/files/') && state.projectDir) {
3077
- const name = decodeURIComponent(url.pathname.replace('/api/bot/files/', ''));
3078
- const file = safeJoin(state.projectDir, name);
3079
- if (req.method === 'GET') return json(res, { name, content: await fsp.readFile(file, 'utf8') });
3080
- if (req.method === 'PUT') {
3081
- if (!name.endsWith('.md')) throw httpError(400, 'Only markdown files (.md) can be modified');
3082
- const body = await readJson(req);
3083
- await fsp.writeFile(file, String(body.content || ''), 'utf8');
3084
- return json(res, { ok: true });
3085
- }
3086
- }
3316
+ if (url.pathname.startsWith('/api/bot/') && req.method === 'DELETE' && !url.pathname.startsWith('/api/bot/files/')) {
3317
+ const agentId = decodeURIComponent(url.pathname.replace('/api/bot/', ''));
3318
+ const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3319
+ const result = await deleteBotInProject(projectDir, agentId);
3320
+ sendLog(`? Bot deleted: ${agentId}`);
3321
+ await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] recreate skipped/failed: ${err.message}`));
3322
+ return json(res, result);
3323
+ }
3324
+ if (url.pathname === '/api/bot/files' && req.method === 'GET') {
3325
+ await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3326
+ return json(res, { files: state.projectDir ? await listMarkdownFiles(state.projectDir, url.searchParams.get('agentId') || '') : [] });
3327
+ }
3328
+ if (url.pathname.startsWith('/api/bot/files/')) {
3329
+ const name = decodeURIComponent(url.pathname.replace('/api/bot/files/', ''));
3330
+ if (req.method === 'GET') {
3331
+ const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3332
+ const file = safeJoin(projectDir, name);
3333
+ return json(res, { name, content: await fsp.readFile(file, 'utf8') });
3334
+ }
3335
+ if (req.method === 'PUT') {
3336
+ if (!name.endsWith('.md')) throw httpError(400, 'Only markdown files (.md) can be modified');
3337
+ const body = await readJson(req);
3338
+ const projectDir = await resolveProjectDir(rootProjectDir, body);
3339
+ const file = safeJoin(projectDir, name);
3340
+ await fsp.writeFile(file, String(body.content || ''), 'utf8');
3341
+ return json(res, { ok: true });
3342
+ }
3343
+ }
3087
3344
  if (url.pathname === '/api/catalog' && req.method === 'GET') return json(res, {
3088
3345
  skills: [
3089
3346
  { name: 'Browser', slug: 'browser' },
@@ -3098,20 +3355,20 @@ async function handler(req, res, rootProjectDir) {
3098
3355
  { name: 'openclaw-n8n-facebook-poster', package: 'openclaw-n8n-facebook-poster' },
3099
3356
  ]
3100
3357
  });
3101
- if (url.pathname === '/api/features' && req.method === 'GET') {
3102
- const projectDir = await resolveProjectDir(rootProjectDir);
3103
- return json(res, await getFeatureFlags(projectDir, url.searchParams.get('agentId') || ''));
3104
- }
3105
- if (url.pathname === '/api/features/toggle' && req.method === 'POST') {
3106
- const body = await readJson(req);
3107
- const projectDir = await resolveProjectDir(rootProjectDir);
3108
- return json(res, await applyFeatureToggle(projectDir, body.agentId || '', body.kind, body.id, !!body.enabled));
3109
- }
3110
- if (url.pathname === '/api/features/install' && req.method === 'POST') {
3111
- const body = await readJson(req);
3112
- const projectDir = await resolveProjectDir(rootProjectDir);
3113
- return json(res, await installFeature(projectDir, body.agentId || '', body.kind, body.id));
3114
- }
3358
+ if (url.pathname === '/api/features' && req.method === 'GET') {
3359
+ const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3360
+ return json(res, await getFeatureFlags(projectDir, url.searchParams.get('agentId') || ''));
3361
+ }
3362
+ if (url.pathname === '/api/features/toggle' && req.method === 'POST') {
3363
+ const body = await readJson(req);
3364
+ const projectDir = await resolveProjectDir(rootProjectDir, body);
3365
+ return json(res, await applyFeatureToggle(projectDir, body.agentId || '', body.kind, body.id, !!body.enabled));
3366
+ }
3367
+ if (url.pathname === '/api/features/install' && req.method === 'POST') {
3368
+ const body = await readJson(req);
3369
+ const projectDir = await resolveProjectDir(rootProjectDir, body);
3370
+ return json(res, await installFeature(projectDir, body.agentId || '', body.kind, body.id));
3371
+ }
3115
3372
  if (await serveStatic(req, res)) return;
3116
3373
  json(res, { error: 'Not found' }, 404);
3117
3374
  } catch (err) {