create-openclaw-bot 5.8.20 → 5.8.23

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,189 +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
- }
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
- }
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
+ }
227
227
 
228
228
  const __dirname = dirname(fileURLToPath(import.meta.url));
229
229
  const WEB_DIR = resolve(__dirname, '../web');
@@ -295,39 +295,39 @@ function extractCompletePngBase64(stdout) {
295
295
  return b64;
296
296
  }
297
297
 
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
- }
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
+ }
331
331
 
332
332
  function getRealHomedir() {
333
333
  const home = os.homedir();
@@ -617,6 +617,12 @@ function openclawProjectEnv(projectDir) {
617
617
  };
618
618
  }
619
619
 
620
+ function parseJsonText(text, fallback = undefined) {
621
+ const clean = String(text || '').replace(/^\uFEFF/, '');
622
+ if (!clean.trim() && fallback !== undefined) return fallback;
623
+ return JSON.parse(clean);
624
+ }
625
+
620
626
  async function runOpenclawJson(projectDir, args = [], timeout = 12000) {
621
627
  const out = await runCapture('openclaw', args, {
622
628
  cwd: projectDir,
@@ -626,7 +632,7 @@ async function runOpenclawJson(projectDir, args = [], timeout = 12000) {
626
632
  });
627
633
  if (out.code !== 0) throw new Error((out.stderr || out.stdout || `openclaw ${args.join(' ')} failed`).trim());
628
634
  const text = String(out.stdout || '').trim();
629
- return text ? JSON.parse(text) : null;
635
+ return text ? parseJsonText(text) : null;
630
636
  }
631
637
 
632
638
  async function readComposeText(projectDir) {
@@ -674,7 +680,7 @@ function parseBaseUrlPort(baseUrl = '') {
674
680
 
675
681
  async function detectRuntime(projectDir) {
676
682
  const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
677
- const cfg = existsSync(cfgPath) ? JSON.parse(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}')) : {};
683
+ const cfg = existsSync(cfgPath) ? parseJsonText(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}'), {}) : {};
678
684
  let cliGatewayStatus = null;
679
685
  let cliGatewayPort = 0;
680
686
  let cliRouterPort = 0;
@@ -1043,7 +1049,7 @@ async function applyResolved9RouterApiKey(projectDir, cfg = null) {
1043
1049
  if (!projectDir) return '';
1044
1050
  const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
1045
1051
  if (!existsSync(cfgPath)) return '';
1046
- const current = cfg || ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
1052
+ const current = cfg || ensureConfigShape(parseJsonText(await fsp.readFile(cfgPath, 'utf8')));
1047
1053
  const apiKey = await resolveProject9RouterApiKey(projectDir, current);
1048
1054
  if (!apiKey) return '';
1049
1055
  current.models = current.models || { mode: 'merge', providers: {} };
@@ -1059,7 +1065,7 @@ async function applyResolved9RouterApiKey(projectDir, cfg = null) {
1059
1065
  async function readBotCredentials(projectDir) {
1060
1066
  const found = readProjectConfig(projectDir);
1061
1067
  if (!found) return { openclawToken: '', nineRouterApiKey: '' };
1062
- const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(found.cfgPath, 'utf8')));
1068
+ const cfg = ensureConfigShape(parseJsonText(await fsp.readFile(found.cfgPath, 'utf8')));
1063
1069
  return {
1064
1070
  openclawToken: cfg.gateway?.auth?.token || '',
1065
1071
  nineRouterApiKey: await resolveProject9RouterApiKey(projectDir, cfg),
@@ -1070,7 +1076,7 @@ async function updateBotCredentials(projectDir, body = {}) {
1070
1076
  const found = readProjectConfig(projectDir);
1071
1077
  if (!found) throw httpError(400, 'Install project not found');
1072
1078
  const raw = await fsp.readFile(found.cfgPath, 'utf8');
1073
- const cfg = ensureConfigShape(JSON.parse(raw));
1079
+ const cfg = ensureConfigShape(parseJsonText(raw));
1074
1080
  const nineRouterApiKey = String(body.nineRouterApiKey || '').trim();
1075
1081
  if (Object.prototype.hasOwnProperty.call(body, 'nineRouterApiKey')) {
1076
1082
  cfg.models = cfg.models || { mode: 'merge', providers: {} };
@@ -1142,7 +1148,7 @@ async function listConfiguredBots(projectDir) {
1142
1148
  const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
1143
1149
  if (!projectDir || !existsSync(cfgPath)) return [];
1144
1150
  const raw = await fsp.readFile(cfgPath, 'utf8');
1145
- const cfg = ensureConfigShape(JSON.parse(raw));
1151
+ const cfg = ensureConfigShape(parseJsonText(raw));
1146
1152
  const normalized = JSON.stringify(cfg, null, 2) + '\n';
1147
1153
  if (normalized !== raw) await fsp.writeFile(cfgPath, normalized, 'utf8');
1148
1154
  const rows = await Promise.all(cfg.agents.list.map(async (agent) => {
@@ -1177,7 +1183,7 @@ async function deleteBotInProject(projectDir, agentId) {
1177
1183
  const openclawHome = join(projectDir, '.openclaw');
1178
1184
  const cfgPath = join(openclawHome, 'openclaw.json');
1179
1185
  if (!existsSync(cfgPath)) throw httpError(404, 'openclaw.json not found');
1180
- const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
1186
+ const cfg = ensureConfigShape(parseJsonText(await fsp.readFile(cfgPath, 'utf8')));
1181
1187
  const agent = cfg.agents.list.find((a) => a.id === agentId);
1182
1188
  if (!agent) throw httpError(404, 'Bot not found');
1183
1189
 
@@ -1815,9 +1821,9 @@ async function syncDockerInfra(projectDir, force = false) {
1815
1821
  const botContainer = parseComposeServiceContainerName(compose, 'ai-bot') || `openclaw-${slugify(basename(projectDir))}`;
1816
1822
  const routerContainer = parseComposeServiceContainerName(compose, '9router') || `9router-${slugify(basename(projectDir))}`;
1817
1823
  const composeName = (compose.match(/^name:\s*(\S+)/m) || [])[1] || `oc-${slugify(basename(projectDir))}`;
1818
- const gatewayPort = state.gatewayPort || 18789;
1819
- const routerPort = state.routerPort || 20128;
1820
- const osChoice = await resolveProjectHostOs(projectDir);
1824
+ const gatewayPort = state.gatewayPort || 18789;
1825
+ const routerPort = state.routerPort || 20128;
1826
+ const osChoice = await resolveProjectHostOs(projectDir);
1821
1827
 
1822
1828
  // Detect features from openclaw.json
1823
1829
  const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
@@ -1832,10 +1838,10 @@ async function syncDockerInfra(projectDir, force = false) {
1832
1838
  is9Router: true,
1833
1839
  openClawNpmSpec: OPENCLAW_NPM_SPEC,
1834
1840
  openClawRuntimePackages: '',
1835
- allSkills: [],
1836
- dockerfilePlugins: [],
1837
- osChoice,
1838
- gatewayPort,
1841
+ allSkills: [],
1842
+ dockerfilePlugins: [],
1843
+ osChoice,
1844
+ gatewayPort,
1839
1845
  routerPort,
1840
1846
  singleComposeName: composeName,
1841
1847
  singleAppContainerName: botContainer,
@@ -1971,7 +1977,7 @@ async function readJson(req) {
1971
1977
  const chunks = [];
1972
1978
  for await (const chunk of req) chunks.push(chunk);
1973
1979
  if (!chunks.length) return {};
1974
- return JSON.parse(Buffer.concat(chunks).toString('utf8'));
1980
+ return parseJsonText(Buffer.concat(chunks).toString('utf8'));
1975
1981
  }
1976
1982
 
1977
1983
  function json(res, data, status = 200) {
@@ -2204,7 +2210,7 @@ function isRestrictedSystemDir(dirPath) {
2204
2210
  ]);
2205
2211
  if (unixBlacklist.has(basename(lower))) return true;
2206
2212
 
2207
- if (lower.startsWith('/home') || lower.startsWith('/root') || lower.startsWith('/mnt/') || lower.startsWith('/users/') || lower === '/users') {
2213
+ if (lower.startsWith('/mnt/') || lower.startsWith('/users/') || lower === '/users') {
2208
2214
  const realHome = resolve(getRealHomedir()).toLowerCase();
2209
2215
  if (lower !== realHome && !lower.startsWith(realHome + '/')) {
2210
2216
  const cwd = resolve(process.cwd()).toLowerCase();
@@ -2318,32 +2324,32 @@ async function discoverProjects(rootProjectDir) {
2318
2324
  return state.projects.slice(0, 20);
2319
2325
  }
2320
2326
 
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
- }
2327
+ async function resolveProjectDir(rootProjectDir, body = {}) {
2328
+ if (body.projectDir && existsSync(join(resolve(String(body.projectDir)), '.openclaw', 'openclaw.json'))) {
2329
+ state.projectDir = resolve(String(body.projectDir));
2330
+ await syncRuntimeState(state.projectDir);
2331
+ return state.projectDir;
2332
+ }
2333
+ if (state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json'))) {
2334
+ await syncRuntimeState(state.projectDir);
2335
+ return state.projectDir;
2336
+ }
2337
+ await loadSavedState(rootProjectDir);
2338
+ if (state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json'))) {
2339
+ await syncRuntimeState(state.projectDir);
2340
+ return state.projectDir;
2341
+ }
2342
+ const envProjectDir = process.env.OPENCLAW_PROJECT_DIR || (process.env.OPENCLAW_HOME ? dirname(process.env.OPENCLAW_HOME) : '');
2343
+ if (envProjectDir && existsSync(join(resolve(String(envProjectDir)), '.openclaw', 'openclaw.json'))) {
2344
+ state.projectDir = resolve(String(envProjectDir));
2345
+ await syncRuntimeState(state.projectDir);
2346
+ return state.projectDir;
2347
+ }
2348
+ const found = await findLatestProject(rootProjectDir);
2349
+ if (found) {
2350
+ await syncRuntimeState(found);
2351
+ await saveState(rootProjectDir);
2352
+ }
2347
2353
  return state.projectDir;
2348
2354
  }
2349
2355
 
@@ -2485,41 +2491,7 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
2485
2491
 
2486
2492
  const k = `${kind}:${id}`;
2487
2493
 
2488
- if (kind === 'skill' && id === 'browser') {
2489
- delete cfg.browser;
2490
- cfg.plugins = cfg.plugins || { entries: {} };
2491
- cfg.plugins.entries = cfg.plugins.entries || {};
2492
- const aliases = ['browser-automation', 'openclaw-browser-automation'];
2493
- const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2494
- cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2495
- cfg.plugins.entries[existingKey].enabled = !!enabled;
2496
- cfg.plugins.allow = cfg.plugins.allow || [];
2497
- if (enabled) {
2498
- if (!cfg.plugins.allow.includes(existingKey)) cfg.plugins.allow.push(existingKey);
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
- }
2510
-
2511
- // Write cfgPath early so syncDockerInfra reads the updated openclaw.json
2512
- await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
2513
2494
 
2514
- // Force Docker Infrastructure sync and container recreation
2515
- const hasDocker = existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'));
2516
- if (hasDocker) {
2517
- sendLog(`[docker] Browser skill toggled to ${enabled}. Regenerating Dockerfiles...`);
2518
- await syncDockerInfra(projectDir, true).catch((err) => sendLog(`[docker] Warning: Failed to sync docker infra: ${err.message}`));
2519
- sendLog(`[docker] Rebuilding and recreating containers...`);
2520
- await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] Warning: Failed to recreate container: ${err.message}`));
2521
- }
2522
- }
2523
2495
 
2524
2496
  if (kind === 'skill' && id === 'cron') {
2525
2497
  cfg.skills = cfg.skills || { entries: {} };
@@ -2583,8 +2555,18 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
2583
2555
  cfg.plugins.allow = cfg.plugins.allow || [];
2584
2556
  if (enabled) {
2585
2557
  if (!cfg.plugins.allow.includes('duckduckgo')) cfg.plugins.allow.push('duckduckgo');
2558
+ cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
2559
+ cfg.tools.alsoAllow = Array.from(new Set([...(cfg.tools.alsoAllow || []), 'group:web']));
2586
2560
  } else {
2587
2561
  cfg.plugins.allow = cfg.plugins.allow.filter((x) => x !== 'duckduckgo');
2562
+ const aliases = ['browser-automation', 'openclaw-browser-automation'];
2563
+ const isBrowserEnabled = aliases.some((a) => cfg.plugins?.entries?.[a]?.enabled);
2564
+ if (!isBrowserEnabled) {
2565
+ if (cfg.tools?.alsoAllow) {
2566
+ cfg.tools.alsoAllow = cfg.tools.alsoAllow.filter((x) => x !== 'group:web');
2567
+ if (cfg.tools.alsoAllow.length === 0) delete cfg.tools.alsoAllow;
2568
+ }
2569
+ }
2588
2570
  }
2589
2571
 
2590
2572
  // Write cfgPath early so recreation reads updated openclaw.json
@@ -2653,6 +2635,23 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
2653
2635
  }
2654
2636
  }
2655
2637
 
2638
+ if (kind === 'skill' && id === 'learning-memory') {
2639
+ cfg.skills = cfg.skills || { entries: {} };
2640
+ cfg.skills.entries = cfg.skills.entries || {};
2641
+ cfg.skills.entries['learning-memory'] = cfg.skills.entries['learning-memory'] || {};
2642
+ cfg.skills.entries['learning-memory'].enabled = !!enabled;
2643
+
2644
+ // Write cfgPath early so recreation reads updated openclaw.json
2645
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
2646
+
2647
+ // Recreate container to apply updated openclaw.json
2648
+ const hasDocker = existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'));
2649
+ if (hasDocker) {
2650
+ sendLog(`[docker] Learning Memory skill toggled to ${enabled}. Recreating containers...`);
2651
+ await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] Warning: Failed to recreate container: ${err.message}`));
2652
+ }
2653
+ }
2654
+
2656
2655
  if (kind === 'plugin') {
2657
2656
  cfg.plugins = cfg.plugins || { entries: {} };
2658
2657
  cfg.plugins.entries = cfg.plugins.entries || {};
@@ -2666,29 +2665,77 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
2666
2665
  const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2667
2666
  cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2668
2667
  cfg.plugins.entries[existingKey].enabled = !!enabled;
2669
- if (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod') {
2670
- ensureZaloModPluginConfig(cfg.plugins.entries[existingKey], cfg);
2671
- }
2672
- // Only add the canonical config key to allow list (not all aliases)
2673
- cfg.plugins.allow = cfg.plugins.allow || [];
2674
- if (!cfg.plugins.allow.includes(existingKey)) cfg.plugins.allow.push(existingKey);
2675
- // Auto-expose zalo-mod dashboard port in docker-compose.yml when enabling
2676
- if (enabled && (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod')) {
2677
- const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
2678
- if (existsSync(composeFile)) {
2679
- try {
2680
- let composeContent = await fsp.readFile(composeFile, 'utf8');
2681
- const dashPort = cfg.plugins.entries[existingKey].config?.dashboardPort;
2682
- if (dashPort && !composeContent.includes(`:${dashPort}`)) {
2683
- const gwPortStr = String(Number(cfg.gateway?.port) || state.gatewayPort || 18789);
2684
- composeContent = composeContent.replace(
2685
- new RegExp(`^(\\s*-\\s*"(?:\\d+:)?${gwPortStr}(?::${gwPortStr})?"\\s*)$`, 'm'),
2686
- `$1\n - "127.0.0.1:${dashPort}:${dashPort}" # zalo-mod dashboard`
2687
- );
2688
- await fsp.writeFile(composeFile, composeContent, 'utf8');
2689
- sendLog(`[plugin] Added dashboard port ${dashPort} to docker-compose.yml`);
2668
+
2669
+ if (enabled) {
2670
+ if (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod') {
2671
+ ensureZaloModPluginConfig(cfg.plugins.entries[existingKey], cfg);
2672
+ }
2673
+ // Only add the canonical config key to allow list (not all aliases)
2674
+ cfg.plugins.allow = cfg.plugins.allow || [];
2675
+ if (!cfg.plugins.allow.includes(existingKey)) cfg.plugins.allow.push(existingKey);
2676
+
2677
+ // Auto-expose zalo-mod dashboard port in docker-compose.yml when enabling
2678
+ if (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod') {
2679
+ const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
2680
+ if (existsSync(composeFile)) {
2681
+ try {
2682
+ let composeContent = await fsp.readFile(composeFile, 'utf8');
2683
+ const dashPort = cfg.plugins.entries[existingKey].config?.dashboardPort;
2684
+ if (dashPort && !composeContent.includes(`:${dashPort}`)) {
2685
+ const gwPortStr = String(Number(cfg.gateway?.port) || state.gatewayPort || 18789);
2686
+ composeContent = composeContent.replace(
2687
+ new RegExp(`^(\\s*-\\s*"(?:\\d+:)?${gwPortStr}(?::${gwPortStr})?"\\s*)$`, 'm'),
2688
+ `$1\n - "127.0.0.1:${dashPort}:${dashPort}" # zalo-mod dashboard`
2689
+ );
2690
+ await fsp.writeFile(composeFile, composeContent, 'utf8');
2691
+ sendLog(`[plugin] Added dashboard port ${dashPort} to docker-compose.yml`);
2692
+ }
2693
+ } catch (e) { sendLog(`[plugin] Warning: could not add dashboard port: ${e.message}`); }
2694
+ }
2695
+ }
2696
+
2697
+ if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
2698
+ cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
2699
+ cfg.tools.alsoAllow = Array.from(new Set([...(cfg.tools.alsoAllow || []), 'group:web']));
2700
+
2701
+ // Force Docker sync and recreate container to include chrome/playwright dependencies
2702
+ const hasDocker = existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'));
2703
+ if (hasDocker) {
2704
+ sendLog(`[docker] Browser plugin enabled. Regenerating Dockerfiles...`);
2705
+ await syncDockerInfra(projectDir, true).catch((err) => sendLog(`[docker] Warning: Failed to sync docker infra: ${err.message}`));
2706
+ sendLog(`[docker] Rebuilding and recreating containers...`);
2707
+ await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] Warning: Failed to recreate container: ${err.message}`));
2708
+ }
2709
+ }
2710
+ } else {
2711
+ if (Array.isArray(cfg.plugins.allow)) {
2712
+ cfg.plugins.allow = cfg.plugins.allow.filter((x) => x !== existingKey);
2713
+ }
2714
+ if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
2715
+ const isWebSearchEnabled = !!cfg.plugins?.entries?.['duckduckgo']?.enabled;
2716
+ if (!isWebSearchEnabled) {
2717
+ if (cfg.tools?.alsoAllow) {
2718
+ cfg.tools.alsoAllow = cfg.tools.alsoAllow.filter((x) => x !== 'group:web');
2719
+ if (cfg.tools.alsoAllow.length === 0) delete cfg.tools.alsoAllow;
2690
2720
  }
2691
- } catch (e) { sendLog(`[plugin] Warning: could not add dashboard port: ${e.message}`); }
2721
+ }
2722
+ for (const a of cfg.agents.list) {
2723
+ const bf = await readWorkspaceText(projectDir, a, 'BROWSER.md');
2724
+ if (existsSync(bf.file)) await fsp.rm(bf.file, { force: true });
2725
+ const bt = await readWorkspaceText(projectDir, a, 'browser-tool.js');
2726
+ if (existsSync(bt.file)) await fsp.rm(bt.file, { force: true });
2727
+ const rel = workspaceRelForAgent(a, cfg, projectDir);
2728
+ await fsp.rm(join(projectDir, '.openclaw', rel, 'plugin-skills', 'browser-automation'), { recursive: true, force: true }).catch(() => {});
2729
+ }
2730
+
2731
+ // Force Docker sync and recreate container to clean up browser dependencies/ports
2732
+ const hasDocker = existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'));
2733
+ if (hasDocker) {
2734
+ sendLog(`[docker] Browser plugin disabled. Regenerating Dockerfiles...`);
2735
+ await syncDockerInfra(projectDir, true).catch((err) => sendLog(`[docker] Warning: Failed to sync docker infra: ${err.message}`));
2736
+ sendLog(`[docker] Rebuilding and recreating containers...`);
2737
+ await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] Warning: Failed to recreate container: ${err.message}`));
2738
+ }
2692
2739
  }
2693
2740
  }
2694
2741
  }
@@ -2702,6 +2749,7 @@ async function installFeature(projectDir, agentId, kind, id) {
2702
2749
  const skillSlugMap = {
2703
2750
  'image-gen': 'infographic-generator',
2704
2751
  'sticker-mention': 'zalo-sticker-mention',
2752
+ 'learning-memory': 'learning-memory',
2705
2753
  };
2706
2754
  const slug = skillSlugMap[id] || id;
2707
2755
 
@@ -2797,17 +2845,19 @@ async function installFeature(projectDir, agentId, kind, id) {
2797
2845
  const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
2798
2846
  cfg.plugins = cfg.plugins || { entries: {} };
2799
2847
  cfg.plugins.entries = cfg.plugins.entries || {};
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
- }
2848
+ const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2849
+ cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2850
+ cfg.plugins.entries[existingKey].enabled = true;
2851
+ if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
2852
+ cfg.plugins.entries[existingKey].config = Object.assign({}, cfg.plugins.entries[existingKey].config, {
2853
+ hostOs: await resolveProjectHostOs(projectDir),
2854
+ });
2855
+ cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
2856
+ cfg.tools.alsoAllow = Array.from(new Set([...(cfg.tools.alsoAllow || []), 'group:web']));
2857
+ }
2858
+ if (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod') {
2859
+ ensureZaloModPluginConfig(cfg.plugins.entries[existingKey], cfg);
2860
+ }
2811
2861
  // Only add the canonical config key to allow list (not all aliases)
2812
2862
  if (!cfg.plugins.allow.includes(existingKey)) cfg.plugins.allow.push(existingKey);
2813
2863
  await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
@@ -2835,15 +2885,15 @@ async function installFeature(projectDir, agentId, kind, id) {
2835
2885
  }
2836
2886
  }
2837
2887
 
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 });
2888
+ // Browser-automation plugin needs Docker rebuild for Playwright/Chromium deps
2889
+ const isBrowserPlugin = id === 'openclaw-browser-automation' || id === 'browser-automation';
2890
+ if (isBrowserPlugin && composeDir) {
2891
+ await patchBrowserAutomationHostPreference(projectDir, aliases, sendLog);
2892
+ sendLog(`[plugin] Browser plugin requires Docker rebuild for Playwright/Chromium...`);
2893
+ const svcName = getBotServiceName(projectDir);
2894
+ await run('docker', ['compose', '-f', join(composeDir, 'docker-compose.yml'), 'up', '-d', '--build', '--force-recreate', svcName], { shell: false }).catch((err) => {
2895
+ sendLog(`[plugin] Docker rebuild failed: ${err.message}. Falling back to restart...`);
2896
+ return run('docker', ['restart', botContainer], { shell: false });
2847
2897
  });
2848
2898
  } else if (isZaloMod && composeDir) {
2849
2899
  // Use docker compose up to apply new port mappings from docker-compose.yml
@@ -2879,37 +2929,39 @@ async function installFeature(projectDir, agentId, kind, id) {
2879
2929
 
2880
2930
  // Automatically enable it in config after install
2881
2931
  const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
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
- }
2932
+ if (existsSync(cfgPath)) {
2933
+ const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
2934
+ cfg.plugins = cfg.plugins || { entries: {} };
2935
+ cfg.plugins.entries = cfg.plugins.entries || {};
2936
+ const pluginAliasMap = {
2937
+ 'openclaw-browser-automation': ['browser-automation', 'openclaw-browser-automation'],
2938
+ 'openclaw-zalo-mod': ['zalo-mod', 'openclaw-zalo-mod'],
2939
+ 'openclaw-facebook-crawler': ['openclaw-facebook-crawler', 'openclaw-n8n-facebook-crawler', 'n8n-facebook-crawler'],
2940
+ 'openclaw-n8n-facebook-poster': ['openclaw-n8n-facebook-poster', 'openclaw-facebook-poster', 'facebook-poster'],
2941
+ };
2942
+ const aliases = pluginAliasMap[id] || [id];
2943
+ const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2944
+ cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2945
+ cfg.plugins.entries[existingKey].enabled = true;
2946
+ if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
2947
+ cfg.plugins.entries[existingKey].config = Object.assign({}, cfg.plugins.entries[existingKey].config, {
2948
+ hostOs: await resolveProjectHostOs(projectDir),
2949
+ });
2950
+ cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
2951
+ cfg.tools.alsoAllow = Array.from(new Set([...(cfg.tools.alsoAllow || []), 'group:web']));
2952
+ }
2953
+ if (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod') {
2954
+ ensureZaloModPluginConfig(cfg.plugins.entries[existingKey], cfg);
2955
+ }
2956
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
2957
+ }
2958
+
2959
+ if (id === 'openclaw-browser-automation' || id === 'browser-automation') {
2960
+ const aliases = ['browser-automation', 'openclaw-browser-automation'];
2961
+ await patchBrowserAutomationHostPreference(projectDir, aliases, sendLog);
2962
+ }
2963
+ }
2964
+ }
2913
2965
  return { ok: true };
2914
2966
  }
2915
2967
 
@@ -2980,14 +3032,14 @@ async function getInstalledSkillVersion(projectDir, agentId, skillFolder, cfg =
2980
3032
 
2981
3033
  async function getFeatureFlags(projectDir, agentId = '') {
2982
3034
  const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
2983
- const cfg = existsSync(cfgPath) ? ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}'))) : {};
3035
+ const cfg = existsSync(cfgPath) ? ensureConfigShape(parseJsonText(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}'), {})) : {};
2984
3036
  const aid = agentId || cfg.agents?.list?.[0]?.id || 'bot';
2985
3037
  const browserOn = !!cfg.browser?.enabled;
2986
3038
  const cronOn = !!cfg.skills?.entries?.['cronjob']?.enabled || !!cfg.skills?.entries?.['cron']?.enabled || !!(cfg.tools?.alsoAllow || []).includes('group:automation');
2987
3039
  const fresh = cfg;
2988
3040
  const freshSaved = {};
2989
3041
  const installsPath = join(projectDir || '', '.openclaw', 'plugins', 'installs.json');
2990
- const installs = existsSync(installsPath) ? JSON.parse(await fsp.readFile(installsPath, 'utf8').catch(() => '{}')) : {};
3042
+ const installs = existsSync(installsPath) ? parseJsonText(await fsp.readFile(installsPath, 'utf8').catch(() => '{}'), {}) : {};
2991
3043
  const installRecords = installs.installRecords || {};
2992
3044
  const installedKeys = new Set(Object.keys(installRecords).map((k) => String(k || '').toLowerCase()));
2993
3045
  const installedSpecs = new Set(Object.values(installRecords).flatMap((r) => {
@@ -3013,6 +3065,7 @@ async function getFeatureFlags(projectDir, agentId = '') {
3013
3065
  const imageGenOn = !!cfg.skills?.entries?.['image-gen']?.enabled;
3014
3066
  const webSearchOn = isEnabled(['duckduckgo']);
3015
3067
  const stickerMentionOn = !!cfg.skills?.entries?.['sticker-mention']?.enabled;
3068
+ const learningMemoryOn = !!cfg.skills?.entries?.['learning-memory']?.enabled;
3016
3069
  const aliases = {
3017
3070
  browser: ['openclaw-browser-automation', 'browser-automation'],
3018
3071
  zalo: ['openclaw-zalo-mod', 'zalo-mod'],
@@ -3025,6 +3078,7 @@ async function getFeatureFlags(projectDir, agentId = '') {
3025
3078
  'skill:image-gen': imageGenOn,
3026
3079
  'skill:web-search': webSearchOn,
3027
3080
  'skill:sticker-mention': stickerMentionOn,
3081
+ 'skill:learning-memory': learningMemoryOn,
3028
3082
  'plugin:openclaw-browser-automation': isEnabled(aliases.browser),
3029
3083
  'plugin:openclaw-zalo-mod': isEnabled(aliases.zalo),
3030
3084
  'plugin:openclaw-facebook-crawler': isEnabled(aliases.crawler),
@@ -3038,6 +3092,7 @@ async function getFeatureFlags(projectDir, agentId = '') {
3038
3092
  const installed = {
3039
3093
  'skill:image-gen': isSkillFolderExists(projectDir, aid, 'infographic-generator', cfg),
3040
3094
  'skill:sticker-mention': isSkillFolderExists(projectDir, aid, 'zalo-sticker-mention', cfg),
3095
+ 'skill:learning-memory': isSkillFolderExists(projectDir, aid, 'learning-memory', cfg),
3041
3096
  'plugin:openclaw-browser-automation': isActuallyInstalled(aliases.browser),
3042
3097
  'plugin:openclaw-zalo-mod': isActuallyInstalled(aliases.zalo),
3043
3098
  'plugin:openclaw-facebook-crawler': isActuallyInstalled(aliases.crawler),
@@ -3046,6 +3101,7 @@ async function getFeatureFlags(projectDir, agentId = '') {
3046
3101
  const versions = {
3047
3102
  'skill:image-gen': await getInstalledSkillVersion(projectDir, aid, 'infographic-generator', cfg),
3048
3103
  'skill:sticker-mention': await getInstalledSkillVersion(projectDir, aid, 'zalo-sticker-mention', cfg),
3104
+ 'skill:learning-memory': await getInstalledSkillVersion(projectDir, aid, 'learning-memory', cfg),
3049
3105
  'plugin:openclaw-browser-automation': await getInstalledPluginVersion(projectDir, aliases.browser),
3050
3106
  'plugin:openclaw-zalo-mod': await getInstalledPluginVersion(projectDir, aliases.zalo),
3051
3107
  'plugin:openclaw-facebook-crawler': await getInstalledPluginVersion(projectDir, aliases.crawler),
@@ -3183,10 +3239,10 @@ async function handler(req, res, rootProjectDir) {
3183
3239
  const body = await readJson(req);
3184
3240
  return json(res, await connectPickedProject(body.projectName, rootProjectDir));
3185
3241
  }
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
- }
3242
+ if (url.pathname === '/api/bot/status' && req.method === 'GET') {
3243
+ await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3244
+ return json(res, await buildBotStatus());
3245
+ }
3190
3246
  if (url.pathname === '/api/bot/credentials' && req.method === 'PUT') {
3191
3247
  const body = await readJson(req);
3192
3248
  const projectDir = await resolveProjectDir(rootProjectDir, body);
@@ -3313,36 +3369,36 @@ async function handler(req, res, rootProjectDir) {
3313
3369
  });
3314
3370
  return json(res, { ok: true, message: 'Zalo login initiated. QR will appear in UI.' });
3315
3371
  }
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
- const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3326
- if (!projectDir) return json(res, { files: [] });
3327
- const agentId = url.searchParams.get('agentId') || '';
3328
- return json(res, { files: await listMarkdownFiles(projectDir, agentId).catch(() => []) });
3329
- }
3330
- if (url.pathname.startsWith('/api/bot/files/')) {
3331
- const name = decodeURIComponent(url.pathname.replace('/api/bot/files/', ''));
3332
- if (req.method === 'GET') {
3333
- const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3334
- const file = safeJoin(projectDir, name);
3335
- return json(res, { name, content: await fsp.readFile(file, 'utf8') });
3336
- }
3337
- if (req.method === 'PUT') {
3338
- if (!name.endsWith('.md')) throw httpError(400, 'Only markdown files (.md) can be modified');
3339
- const body = await readJson(req);
3340
- const projectDir = await resolveProjectDir(rootProjectDir, body);
3341
- const file = safeJoin(projectDir, name);
3342
- await fsp.writeFile(file, String(body.content || ''), 'utf8');
3343
- return json(res, { ok: true });
3344
- }
3345
- }
3372
+ if (url.pathname.startsWith('/api/bot/') && req.method === 'DELETE' && !url.pathname.startsWith('/api/bot/files/')) {
3373
+ const agentId = decodeURIComponent(url.pathname.replace('/api/bot/', ''));
3374
+ const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3375
+ const result = await deleteBotInProject(projectDir, agentId);
3376
+ sendLog(`? Bot deleted: ${agentId}`);
3377
+ await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] recreate skipped/failed: ${err.message}`));
3378
+ return json(res, result);
3379
+ }
3380
+ if (url.pathname === '/api/bot/files' && req.method === 'GET') {
3381
+ const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3382
+ if (!projectDir) return json(res, { files: [] });
3383
+ const agentId = url.searchParams.get('agentId') || '';
3384
+ return json(res, { files: await listMarkdownFiles(projectDir, agentId).catch(() => []) });
3385
+ }
3386
+ if (url.pathname.startsWith('/api/bot/files/')) {
3387
+ const name = decodeURIComponent(url.pathname.replace('/api/bot/files/', ''));
3388
+ if (req.method === 'GET') {
3389
+ const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3390
+ const file = safeJoin(projectDir, name);
3391
+ return json(res, { name, content: await fsp.readFile(file, 'utf8') });
3392
+ }
3393
+ if (req.method === 'PUT') {
3394
+ if (!name.endsWith('.md')) throw httpError(400, 'Only markdown files (.md) can be modified');
3395
+ const body = await readJson(req);
3396
+ const projectDir = await resolveProjectDir(rootProjectDir, body);
3397
+ const file = safeJoin(projectDir, name);
3398
+ await fsp.writeFile(file, String(body.content || ''), 'utf8');
3399
+ return json(res, { ok: true });
3400
+ }
3401
+ }
3346
3402
  if (url.pathname === '/api/catalog' && req.method === 'GET') return json(res, {
3347
3403
  skills: [
3348
3404
  { name: 'Browser', slug: 'browser' },
@@ -3357,21 +3413,21 @@ async function handler(req, res, rootProjectDir) {
3357
3413
  { name: 'openclaw-n8n-facebook-poster', package: 'openclaw-n8n-facebook-poster' },
3358
3414
  ]
3359
3415
  });
3360
- if (url.pathname === '/api/features' && req.method === 'GET') {
3361
- const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3362
- if (!projectDir) return json(res, { flags: {}, installed: {}, versions: {} });
3363
- return json(res, await getFeatureFlags(projectDir, url.searchParams.get('agentId') || ''));
3364
- }
3365
- if (url.pathname === '/api/features/toggle' && req.method === 'POST') {
3366
- const body = await readJson(req);
3367
- const projectDir = await resolveProjectDir(rootProjectDir, body);
3368
- return json(res, await applyFeatureToggle(projectDir, body.agentId || '', body.kind, body.id, !!body.enabled));
3369
- }
3370
- if (url.pathname === '/api/features/install' && req.method === 'POST') {
3371
- const body = await readJson(req);
3372
- const projectDir = await resolveProjectDir(rootProjectDir, body);
3373
- return json(res, await installFeature(projectDir, body.agentId || '', body.kind, body.id));
3374
- }
3416
+ if (url.pathname === '/api/features' && req.method === 'GET') {
3417
+ const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3418
+ if (!projectDir) return json(res, { flags: {}, installed: {}, versions: {} });
3419
+ return json(res, await getFeatureFlags(projectDir, url.searchParams.get('agentId') || ''));
3420
+ }
3421
+ if (url.pathname === '/api/features/toggle' && req.method === 'POST') {
3422
+ const body = await readJson(req);
3423
+ const projectDir = await resolveProjectDir(rootProjectDir, body);
3424
+ return json(res, await applyFeatureToggle(projectDir, body.agentId || '', body.kind, body.id, !!body.enabled));
3425
+ }
3426
+ if (url.pathname === '/api/features/install' && req.method === 'POST') {
3427
+ const body = await readJson(req);
3428
+ const projectDir = await resolveProjectDir(rootProjectDir, body);
3429
+ return json(res, await installFeature(projectDir, body.agentId || '', body.kind, body.id));
3430
+ }
3375
3431
  if (await serveStatic(req, res)) return;
3376
3432
  json(res, { error: 'Not found' }, 404);
3377
3433
  } catch (err) {