create-openclaw-bot 5.8.21 → 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();
@@ -609,21 +609,21 @@ async function readEnvFile(file) {
609
609
  return existsSync(file) ? readEnvText(await fsp.readFile(file, 'utf8').catch(() => '')) : {};
610
610
  }
611
611
 
612
- function openclawProjectEnv(projectDir) {
613
- return {
614
- ...process.env,
615
- OPENCLAW_HOME: join(projectDir, '.openclaw'),
616
- OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
617
- };
618
- }
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
-
626
- async function runOpenclawJson(projectDir, args = [], timeout = 12000) {
612
+ function openclawProjectEnv(projectDir) {
613
+ return {
614
+ ...process.env,
615
+ OPENCLAW_HOME: join(projectDir, '.openclaw'),
616
+ OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
617
+ };
618
+ }
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
+
626
+ async function runOpenclawJson(projectDir, args = [], timeout = 12000) {
627
627
  const out = await runCapture('openclaw', args, {
628
628
  cwd: projectDir,
629
629
  env: openclawProjectEnv(projectDir),
@@ -632,8 +632,8 @@ async function runOpenclawJson(projectDir, args = [], timeout = 12000) {
632
632
  });
633
633
  if (out.code !== 0) throw new Error((out.stderr || out.stdout || `openclaw ${args.join(' ')} failed`).trim());
634
634
  const text = String(out.stdout || '').trim();
635
- return text ? parseJsonText(text) : null;
636
- }
635
+ return text ? parseJsonText(text) : null;
636
+ }
637
637
 
638
638
  async function readComposeText(projectDir) {
639
639
  const p = join(projectDir || '', 'docker', 'openclaw', 'docker-compose.yml');
@@ -678,9 +678,9 @@ function parseBaseUrlPort(baseUrl = '') {
678
678
  }
679
679
  }
680
680
 
681
- async function detectRuntime(projectDir) {
682
- const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
683
- const cfg = existsSync(cfgPath) ? parseJsonText(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}'), {}) : {};
681
+ async function detectRuntime(projectDir) {
682
+ const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
683
+ const cfg = existsSync(cfgPath) ? parseJsonText(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}'), {}) : {};
684
684
  let cliGatewayStatus = null;
685
685
  let cliGatewayPort = 0;
686
686
  let cliRouterPort = 0;
@@ -1045,11 +1045,11 @@ async function resolveProject9RouterApiKey(projectDir, cfg = null) {
1045
1045
  return '';
1046
1046
  }
1047
1047
 
1048
- async function applyResolved9RouterApiKey(projectDir, cfg = null) {
1049
- if (!projectDir) return '';
1050
- const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
1051
- if (!existsSync(cfgPath)) return '';
1052
- const current = cfg || ensureConfigShape(parseJsonText(await fsp.readFile(cfgPath, 'utf8')));
1048
+ async function applyResolved9RouterApiKey(projectDir, cfg = null) {
1049
+ if (!projectDir) return '';
1050
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
1051
+ if (!existsSync(cfgPath)) return '';
1052
+ const current = cfg || ensureConfigShape(parseJsonText(await fsp.readFile(cfgPath, 'utf8')));
1053
1053
  const apiKey = await resolveProject9RouterApiKey(projectDir, current);
1054
1054
  if (!apiKey) return '';
1055
1055
  current.models = current.models || { mode: 'merge', providers: {} };
@@ -1062,21 +1062,21 @@ async function applyResolved9RouterApiKey(projectDir, cfg = null) {
1062
1062
  return apiKey;
1063
1063
  }
1064
1064
 
1065
- async function readBotCredentials(projectDir) {
1066
- const found = readProjectConfig(projectDir);
1067
- if (!found) return { openclawToken: '', nineRouterApiKey: '' };
1068
- const cfg = ensureConfigShape(parseJsonText(await fsp.readFile(found.cfgPath, 'utf8')));
1065
+ async function readBotCredentials(projectDir) {
1066
+ const found = readProjectConfig(projectDir);
1067
+ if (!found) return { openclawToken: '', nineRouterApiKey: '' };
1068
+ const cfg = ensureConfigShape(parseJsonText(await fsp.readFile(found.cfgPath, 'utf8')));
1069
1069
  return {
1070
1070
  openclawToken: cfg.gateway?.auth?.token || '',
1071
1071
  nineRouterApiKey: await resolveProject9RouterApiKey(projectDir, cfg),
1072
1072
  };
1073
1073
  }
1074
1074
 
1075
- async function updateBotCredentials(projectDir, body = {}) {
1076
- const found = readProjectConfig(projectDir);
1077
- if (!found) throw httpError(400, 'Install project not found');
1078
- const raw = await fsp.readFile(found.cfgPath, 'utf8');
1079
- const cfg = ensureConfigShape(parseJsonText(raw));
1075
+ async function updateBotCredentials(projectDir, body = {}) {
1076
+ const found = readProjectConfig(projectDir);
1077
+ if (!found) throw httpError(400, 'Install project not found');
1078
+ const raw = await fsp.readFile(found.cfgPath, 'utf8');
1079
+ const cfg = ensureConfigShape(parseJsonText(raw));
1080
1080
  const nineRouterApiKey = String(body.nineRouterApiKey || '').trim();
1081
1081
  if (Object.prototype.hasOwnProperty.call(body, 'nineRouterApiKey')) {
1082
1082
  cfg.models = cfg.models || { mode: 'merge', providers: {} };
@@ -1144,11 +1144,11 @@ function mapAgentChannels(agent, cfg) {
1144
1144
  return enabled.length === 1 ? enabled : [mapAgentChannel(agent, cfg)];
1145
1145
  }
1146
1146
 
1147
- async function listConfiguredBots(projectDir) {
1148
- const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
1149
- if (!projectDir || !existsSync(cfgPath)) return [];
1150
- const raw = await fsp.readFile(cfgPath, 'utf8');
1151
- const cfg = ensureConfigShape(parseJsonText(raw));
1147
+ async function listConfiguredBots(projectDir) {
1148
+ const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
1149
+ if (!projectDir || !existsSync(cfgPath)) return [];
1150
+ const raw = await fsp.readFile(cfgPath, 'utf8');
1151
+ const cfg = ensureConfigShape(parseJsonText(raw));
1152
1152
  const normalized = JSON.stringify(cfg, null, 2) + '\n';
1153
1153
  if (normalized !== raw) await fsp.writeFile(cfgPath, normalized, 'utf8');
1154
1154
  const rows = await Promise.all(cfg.agents.list.map(async (agent) => {
@@ -1176,14 +1176,14 @@ async function rmInside(root, target) {
1176
1176
  await fsp.rm(targetFull, { recursive: true, force: true }).catch(() => {});
1177
1177
  }
1178
1178
 
1179
- async function deleteBotInProject(projectDir, agentId) {
1179
+ async function deleteBotInProject(projectDir, agentId) {
1180
1180
  if (!projectDir) throw httpError(400, 'Install project not found');
1181
1181
  const cleanId = slugify(agentId, '');
1182
1182
  if (!cleanId || cleanId !== agentId) throw httpError(400, 'Invalid bot id');
1183
1183
  const openclawHome = join(projectDir, '.openclaw');
1184
1184
  const cfgPath = join(openclawHome, 'openclaw.json');
1185
1185
  if (!existsSync(cfgPath)) throw httpError(404, 'openclaw.json not found');
1186
- const cfg = ensureConfigShape(parseJsonText(await fsp.readFile(cfgPath, 'utf8')));
1186
+ const cfg = ensureConfigShape(parseJsonText(await fsp.readFile(cfgPath, 'utf8')));
1187
1187
  const agent = cfg.agents.list.find((a) => a.id === agentId);
1188
1188
  if (!agent) throw httpError(404, 'Bot not found');
1189
1189
 
@@ -1821,9 +1821,9 @@ async function syncDockerInfra(projectDir, force = false) {
1821
1821
  const botContainer = parseComposeServiceContainerName(compose, 'ai-bot') || `openclaw-${slugify(basename(projectDir))}`;
1822
1822
  const routerContainer = parseComposeServiceContainerName(compose, '9router') || `9router-${slugify(basename(projectDir))}`;
1823
1823
  const composeName = (compose.match(/^name:\s*(\S+)/m) || [])[1] || `oc-${slugify(basename(projectDir))}`;
1824
- const gatewayPort = state.gatewayPort || 18789;
1825
- const routerPort = state.routerPort || 20128;
1826
- const osChoice = await resolveProjectHostOs(projectDir);
1824
+ const gatewayPort = state.gatewayPort || 18789;
1825
+ const routerPort = state.routerPort || 20128;
1826
+ const osChoice = await resolveProjectHostOs(projectDir);
1827
1827
 
1828
1828
  // Detect features from openclaw.json
1829
1829
  const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
@@ -1838,10 +1838,10 @@ async function syncDockerInfra(projectDir, force = false) {
1838
1838
  is9Router: true,
1839
1839
  openClawNpmSpec: OPENCLAW_NPM_SPEC,
1840
1840
  openClawRuntimePackages: '',
1841
- allSkills: [],
1842
- dockerfilePlugins: [],
1843
- osChoice,
1844
- gatewayPort,
1841
+ allSkills: [],
1842
+ dockerfilePlugins: [],
1843
+ osChoice,
1844
+ gatewayPort,
1845
1845
  routerPort,
1846
1846
  singleComposeName: composeName,
1847
1847
  singleAppContainerName: botContainer,
@@ -1973,12 +1973,12 @@ async function restartDockerBotContainer(projectDir = state.projectDir) {
1973
1973
  return true;
1974
1974
  }
1975
1975
 
1976
- async function readJson(req) {
1977
- const chunks = [];
1978
- for await (const chunk of req) chunks.push(chunk);
1979
- if (!chunks.length) return {};
1980
- return parseJsonText(Buffer.concat(chunks).toString('utf8'));
1981
- }
1976
+ async function readJson(req) {
1977
+ const chunks = [];
1978
+ for await (const chunk of req) chunks.push(chunk);
1979
+ if (!chunks.length) return {};
1980
+ return parseJsonText(Buffer.concat(chunks).toString('utf8'));
1981
+ }
1982
1982
 
1983
1983
  function json(res, data, status = 200) {
1984
1984
  const body = JSON.stringify(data, null, 2);
@@ -2210,7 +2210,7 @@ function isRestrictedSystemDir(dirPath) {
2210
2210
  ]);
2211
2211
  if (unixBlacklist.has(basename(lower))) return true;
2212
2212
 
2213
- 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') {
2214
2214
  const realHome = resolve(getRealHomedir()).toLowerCase();
2215
2215
  if (lower !== realHome && !lower.startsWith(realHome + '/')) {
2216
2216
  const cwd = resolve(process.cwd()).toLowerCase();
@@ -2324,32 +2324,32 @@ async function discoverProjects(rootProjectDir) {
2324
2324
  return state.projects.slice(0, 20);
2325
2325
  }
2326
2326
 
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
- }
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
+ }
2353
2353
  return state.projectDir;
2354
2354
  }
2355
2355
 
@@ -2491,41 +2491,7 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
2491
2491
 
2492
2492
  const k = `${kind}:${id}`;
2493
2493
 
2494
- if (kind === 'skill' && id === 'browser') {
2495
- delete cfg.browser;
2496
- cfg.plugins = cfg.plugins || { entries: {} };
2497
- cfg.plugins.entries = cfg.plugins.entries || {};
2498
- const aliases = ['browser-automation', 'openclaw-browser-automation'];
2499
- const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2500
- cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2501
- cfg.plugins.entries[existingKey].enabled = !!enabled;
2502
- cfg.plugins.allow = cfg.plugins.allow || [];
2503
- if (enabled) {
2504
- if (!cfg.plugins.allow.includes(existingKey)) cfg.plugins.allow.push(existingKey);
2505
- } else {
2506
- cfg.plugins.allow = cfg.plugins.allow.filter((x) => x !== existingKey);
2507
- for (const a of cfg.agents.list) {
2508
- const bf = await readWorkspaceText(projectDir, a, 'BROWSER.md');
2509
- if (existsSync(bf.file)) await fsp.rm(bf.file, { force: true });
2510
- const bt = await readWorkspaceText(projectDir, a, 'browser-tool.js');
2511
- if (existsSync(bt.file)) await fsp.rm(bt.file, { force: true });
2512
- const rel = workspaceRelForAgent(a, cfg, projectDir);
2513
- await fsp.rm(join(projectDir, '.openclaw', rel, 'plugin-skills', 'browser-automation'), { recursive: true, force: true }).catch(() => {});
2514
- }
2515
- }
2516
-
2517
- // Write cfgPath early so syncDockerInfra reads the updated openclaw.json
2518
- await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
2519
2494
 
2520
- // Force Docker Infrastructure sync and container recreation
2521
- const hasDocker = existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'));
2522
- if (hasDocker) {
2523
- sendLog(`[docker] Browser skill toggled to ${enabled}. Regenerating Dockerfiles...`);
2524
- await syncDockerInfra(projectDir, true).catch((err) => sendLog(`[docker] Warning: Failed to sync docker infra: ${err.message}`));
2525
- sendLog(`[docker] Rebuilding and recreating containers...`);
2526
- await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] Warning: Failed to recreate container: ${err.message}`));
2527
- }
2528
- }
2529
2495
 
2530
2496
  if (kind === 'skill' && id === 'cron') {
2531
2497
  cfg.skills = cfg.skills || { entries: {} };
@@ -2589,8 +2555,18 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
2589
2555
  cfg.plugins.allow = cfg.plugins.allow || [];
2590
2556
  if (enabled) {
2591
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']));
2592
2560
  } else {
2593
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
+ }
2594
2570
  }
2595
2571
 
2596
2572
  // Write cfgPath early so recreation reads updated openclaw.json
@@ -2659,6 +2635,23 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
2659
2635
  }
2660
2636
  }
2661
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
+
2662
2655
  if (kind === 'plugin') {
2663
2656
  cfg.plugins = cfg.plugins || { entries: {} };
2664
2657
  cfg.plugins.entries = cfg.plugins.entries || {};
@@ -2672,29 +2665,77 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
2672
2665
  const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2673
2666
  cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2674
2667
  cfg.plugins.entries[existingKey].enabled = !!enabled;
2675
- if (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod') {
2676
- ensureZaloModPluginConfig(cfg.plugins.entries[existingKey], cfg);
2677
- }
2678
- // Only add the canonical config key to allow list (not all aliases)
2679
- cfg.plugins.allow = cfg.plugins.allow || [];
2680
- if (!cfg.plugins.allow.includes(existingKey)) cfg.plugins.allow.push(existingKey);
2681
- // Auto-expose zalo-mod dashboard port in docker-compose.yml when enabling
2682
- if (enabled && (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod')) {
2683
- const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
2684
- if (existsSync(composeFile)) {
2685
- try {
2686
- let composeContent = await fsp.readFile(composeFile, 'utf8');
2687
- const dashPort = cfg.plugins.entries[existingKey].config?.dashboardPort;
2688
- if (dashPort && !composeContent.includes(`:${dashPort}`)) {
2689
- const gwPortStr = String(Number(cfg.gateway?.port) || state.gatewayPort || 18789);
2690
- composeContent = composeContent.replace(
2691
- new RegExp(`^(\\s*-\\s*"(?:\\d+:)?${gwPortStr}(?::${gwPortStr})?"\\s*)$`, 'm'),
2692
- `$1\n - "127.0.0.1:${dashPort}:${dashPort}" # zalo-mod dashboard`
2693
- );
2694
- await fsp.writeFile(composeFile, composeContent, 'utf8');
2695
- 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;
2696
2720
  }
2697
- } 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
+ }
2698
2739
  }
2699
2740
  }
2700
2741
  }
@@ -2708,6 +2749,7 @@ async function installFeature(projectDir, agentId, kind, id) {
2708
2749
  const skillSlugMap = {
2709
2750
  'image-gen': 'infographic-generator',
2710
2751
  'sticker-mention': 'zalo-sticker-mention',
2752
+ 'learning-memory': 'learning-memory',
2711
2753
  };
2712
2754
  const slug = skillSlugMap[id] || id;
2713
2755
 
@@ -2803,17 +2845,19 @@ async function installFeature(projectDir, agentId, kind, id) {
2803
2845
  const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
2804
2846
  cfg.plugins = cfg.plugins || { entries: {} };
2805
2847
  cfg.plugins.entries = cfg.plugins.entries || {};
2806
- const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2807
- cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2808
- cfg.plugins.entries[existingKey].enabled = true;
2809
- if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
2810
- cfg.plugins.entries[existingKey].config = Object.assign({}, cfg.plugins.entries[existingKey].config, {
2811
- hostOs: await resolveProjectHostOs(projectDir),
2812
- });
2813
- }
2814
- if (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod') {
2815
- ensureZaloModPluginConfig(cfg.plugins.entries[existingKey], cfg);
2816
- }
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
+ }
2817
2861
  // Only add the canonical config key to allow list (not all aliases)
2818
2862
  if (!cfg.plugins.allow.includes(existingKey)) cfg.plugins.allow.push(existingKey);
2819
2863
  await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
@@ -2841,15 +2885,15 @@ async function installFeature(projectDir, agentId, kind, id) {
2841
2885
  }
2842
2886
  }
2843
2887
 
2844
- // Browser-automation plugin needs Docker rebuild for Playwright/Chromium deps
2845
- const isBrowserPlugin = id === 'openclaw-browser-automation' || id === 'browser-automation';
2846
- if (isBrowserPlugin && composeDir) {
2847
- await patchBrowserAutomationHostPreference(projectDir, aliases, sendLog);
2848
- sendLog(`[plugin] Browser plugin requires Docker rebuild for Playwright/Chromium...`);
2849
- const svcName = getBotServiceName(projectDir);
2850
- await run('docker', ['compose', '-f', join(composeDir, 'docker-compose.yml'), 'up', '-d', '--build', '--force-recreate', svcName], { shell: false }).catch((err) => {
2851
- sendLog(`[plugin] Docker rebuild failed: ${err.message}. Falling back to restart...`);
2852
- 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 });
2853
2897
  });
2854
2898
  } else if (isZaloMod && composeDir) {
2855
2899
  // Use docker compose up to apply new port mappings from docker-compose.yml
@@ -2885,37 +2929,39 @@ async function installFeature(projectDir, agentId, kind, id) {
2885
2929
 
2886
2930
  // Automatically enable it in config after install
2887
2931
  const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
2888
- if (existsSync(cfgPath)) {
2889
- const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
2890
- cfg.plugins = cfg.plugins || { entries: {} };
2891
- cfg.plugins.entries = cfg.plugins.entries || {};
2892
- const pluginAliasMap = {
2893
- 'openclaw-browser-automation': ['browser-automation', 'openclaw-browser-automation'],
2894
- 'openclaw-zalo-mod': ['zalo-mod', 'openclaw-zalo-mod'],
2895
- 'openclaw-facebook-crawler': ['openclaw-facebook-crawler', 'openclaw-n8n-facebook-crawler', 'n8n-facebook-crawler'],
2896
- 'openclaw-n8n-facebook-poster': ['openclaw-n8n-facebook-poster', 'openclaw-facebook-poster', 'facebook-poster'],
2897
- };
2898
- const aliases = pluginAliasMap[id] || [id];
2899
- const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2900
- cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2901
- cfg.plugins.entries[existingKey].enabled = true;
2902
- if (existingKey === 'browser-automation' || existingKey === 'openclaw-browser-automation') {
2903
- cfg.plugins.entries[existingKey].config = Object.assign({}, cfg.plugins.entries[existingKey].config, {
2904
- hostOs: await resolveProjectHostOs(projectDir),
2905
- });
2906
- }
2907
- if (existingKey === 'zalo-mod' || existingKey === 'openclaw-zalo-mod') {
2908
- ensureZaloModPluginConfig(cfg.plugins.entries[existingKey], cfg);
2909
- }
2910
- await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
2911
- }
2912
-
2913
- if (id === 'openclaw-browser-automation' || id === 'browser-automation') {
2914
- const aliases = ['browser-automation', 'openclaw-browser-automation'];
2915
- await patchBrowserAutomationHostPreference(projectDir, aliases, sendLog);
2916
- }
2917
- }
2918
- }
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
+ }
2919
2965
  return { ok: true };
2920
2966
  }
2921
2967
 
@@ -2984,16 +3030,16 @@ async function getInstalledSkillVersion(projectDir, agentId, skillFolder, cfg =
2984
3030
  return '';
2985
3031
  }
2986
3032
 
2987
- async function getFeatureFlags(projectDir, agentId = '') {
2988
- const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
2989
- const cfg = existsSync(cfgPath) ? ensureConfigShape(parseJsonText(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}'), {})) : {};
3033
+ async function getFeatureFlags(projectDir, agentId = '') {
3034
+ const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
3035
+ const cfg = existsSync(cfgPath) ? ensureConfigShape(parseJsonText(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}'), {})) : {};
2990
3036
  const aid = agentId || cfg.agents?.list?.[0]?.id || 'bot';
2991
3037
  const browserOn = !!cfg.browser?.enabled;
2992
3038
  const cronOn = !!cfg.skills?.entries?.['cronjob']?.enabled || !!cfg.skills?.entries?.['cron']?.enabled || !!(cfg.tools?.alsoAllow || []).includes('group:automation');
2993
- const fresh = cfg;
2994
- const freshSaved = {};
2995
- const installsPath = join(projectDir || '', '.openclaw', 'plugins', 'installs.json');
2996
- const installs = existsSync(installsPath) ? parseJsonText(await fsp.readFile(installsPath, 'utf8').catch(() => '{}'), {}) : {};
3039
+ const fresh = cfg;
3040
+ const freshSaved = {};
3041
+ const installsPath = join(projectDir || '', '.openclaw', 'plugins', 'installs.json');
3042
+ const installs = existsSync(installsPath) ? parseJsonText(await fsp.readFile(installsPath, 'utf8').catch(() => '{}'), {}) : {};
2997
3043
  const installRecords = installs.installRecords || {};
2998
3044
  const installedKeys = new Set(Object.keys(installRecords).map((k) => String(k || '').toLowerCase()));
2999
3045
  const installedSpecs = new Set(Object.values(installRecords).flatMap((r) => {
@@ -3019,6 +3065,7 @@ async function getFeatureFlags(projectDir, agentId = '') {
3019
3065
  const imageGenOn = !!cfg.skills?.entries?.['image-gen']?.enabled;
3020
3066
  const webSearchOn = isEnabled(['duckduckgo']);
3021
3067
  const stickerMentionOn = !!cfg.skills?.entries?.['sticker-mention']?.enabled;
3068
+ const learningMemoryOn = !!cfg.skills?.entries?.['learning-memory']?.enabled;
3022
3069
  const aliases = {
3023
3070
  browser: ['openclaw-browser-automation', 'browser-automation'],
3024
3071
  zalo: ['openclaw-zalo-mod', 'zalo-mod'],
@@ -3031,6 +3078,7 @@ async function getFeatureFlags(projectDir, agentId = '') {
3031
3078
  'skill:image-gen': imageGenOn,
3032
3079
  'skill:web-search': webSearchOn,
3033
3080
  'skill:sticker-mention': stickerMentionOn,
3081
+ 'skill:learning-memory': learningMemoryOn,
3034
3082
  'plugin:openclaw-browser-automation': isEnabled(aliases.browser),
3035
3083
  'plugin:openclaw-zalo-mod': isEnabled(aliases.zalo),
3036
3084
  'plugin:openclaw-facebook-crawler': isEnabled(aliases.crawler),
@@ -3044,6 +3092,7 @@ async function getFeatureFlags(projectDir, agentId = '') {
3044
3092
  const installed = {
3045
3093
  'skill:image-gen': isSkillFolderExists(projectDir, aid, 'infographic-generator', cfg),
3046
3094
  'skill:sticker-mention': isSkillFolderExists(projectDir, aid, 'zalo-sticker-mention', cfg),
3095
+ 'skill:learning-memory': isSkillFolderExists(projectDir, aid, 'learning-memory', cfg),
3047
3096
  'plugin:openclaw-browser-automation': isActuallyInstalled(aliases.browser),
3048
3097
  'plugin:openclaw-zalo-mod': isActuallyInstalled(aliases.zalo),
3049
3098
  'plugin:openclaw-facebook-crawler': isActuallyInstalled(aliases.crawler),
@@ -3052,6 +3101,7 @@ async function getFeatureFlags(projectDir, agentId = '') {
3052
3101
  const versions = {
3053
3102
  'skill:image-gen': await getInstalledSkillVersion(projectDir, aid, 'infographic-generator', cfg),
3054
3103
  'skill:sticker-mention': await getInstalledSkillVersion(projectDir, aid, 'zalo-sticker-mention', cfg),
3104
+ 'skill:learning-memory': await getInstalledSkillVersion(projectDir, aid, 'learning-memory', cfg),
3055
3105
  'plugin:openclaw-browser-automation': await getInstalledPluginVersion(projectDir, aliases.browser),
3056
3106
  'plugin:openclaw-zalo-mod': await getInstalledPluginVersion(projectDir, aliases.zalo),
3057
3107
  'plugin:openclaw-facebook-crawler': await getInstalledPluginVersion(projectDir, aliases.crawler),
@@ -3189,10 +3239,10 @@ async function handler(req, res, rootProjectDir) {
3189
3239
  const body = await readJson(req);
3190
3240
  return json(res, await connectPickedProject(body.projectName, rootProjectDir));
3191
3241
  }
3192
- if (url.pathname === '/api/bot/status' && req.method === 'GET') {
3193
- await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3194
- return json(res, await buildBotStatus());
3195
- }
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
+ }
3196
3246
  if (url.pathname === '/api/bot/credentials' && req.method === 'PUT') {
3197
3247
  const body = await readJson(req);
3198
3248
  const projectDir = await resolveProjectDir(rootProjectDir, body);
@@ -3319,36 +3369,36 @@ async function handler(req, res, rootProjectDir) {
3319
3369
  });
3320
3370
  return json(res, { ok: true, message: 'Zalo login initiated. QR will appear in UI.' });
3321
3371
  }
3322
- if (url.pathname.startsWith('/api/bot/') && req.method === 'DELETE' && !url.pathname.startsWith('/api/bot/files/')) {
3323
- const agentId = decodeURIComponent(url.pathname.replace('/api/bot/', ''));
3324
- const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3325
- const result = await deleteBotInProject(projectDir, agentId);
3326
- sendLog(`? Bot deleted: ${agentId}`);
3327
- await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] recreate skipped/failed: ${err.message}`));
3328
- return json(res, result);
3329
- }
3330
- if (url.pathname === '/api/bot/files' && req.method === 'GET') {
3331
- const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3332
- if (!projectDir) return json(res, { files: [] });
3333
- const agentId = url.searchParams.get('agentId') || '';
3334
- return json(res, { files: await listMarkdownFiles(projectDir, agentId).catch(() => []) });
3335
- }
3336
- if (url.pathname.startsWith('/api/bot/files/')) {
3337
- const name = decodeURIComponent(url.pathname.replace('/api/bot/files/', ''));
3338
- if (req.method === 'GET') {
3339
- const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3340
- const file = safeJoin(projectDir, name);
3341
- return json(res, { name, content: await fsp.readFile(file, 'utf8') });
3342
- }
3343
- if (req.method === 'PUT') {
3344
- if (!name.endsWith('.md')) throw httpError(400, 'Only markdown files (.md) can be modified');
3345
- const body = await readJson(req);
3346
- const projectDir = await resolveProjectDir(rootProjectDir, body);
3347
- const file = safeJoin(projectDir, name);
3348
- await fsp.writeFile(file, String(body.content || ''), 'utf8');
3349
- return json(res, { ok: true });
3350
- }
3351
- }
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
+ }
3352
3402
  if (url.pathname === '/api/catalog' && req.method === 'GET') return json(res, {
3353
3403
  skills: [
3354
3404
  { name: 'Browser', slug: 'browser' },
@@ -3363,21 +3413,21 @@ async function handler(req, res, rootProjectDir) {
3363
3413
  { name: 'openclaw-n8n-facebook-poster', package: 'openclaw-n8n-facebook-poster' },
3364
3414
  ]
3365
3415
  });
3366
- if (url.pathname === '/api/features' && req.method === 'GET') {
3367
- const projectDir = await resolveProjectDir(rootProjectDir, Object.fromEntries(url.searchParams));
3368
- if (!projectDir) return json(res, { flags: {}, installed: {}, versions: {} });
3369
- return json(res, await getFeatureFlags(projectDir, url.searchParams.get('agentId') || ''));
3370
- }
3371
- if (url.pathname === '/api/features/toggle' && req.method === 'POST') {
3372
- const body = await readJson(req);
3373
- const projectDir = await resolveProjectDir(rootProjectDir, body);
3374
- return json(res, await applyFeatureToggle(projectDir, body.agentId || '', body.kind, body.id, !!body.enabled));
3375
- }
3376
- if (url.pathname === '/api/features/install' && req.method === 'POST') {
3377
- const body = await readJson(req);
3378
- const projectDir = await resolveProjectDir(rootProjectDir, body);
3379
- return json(res, await installFeature(projectDir, body.agentId || '', body.kind, body.id));
3380
- }
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
+ }
3381
3431
  if (await serveStatic(req, res)) return;
3382
3432
  json(res, { error: 'Not found' }, 404);
3383
3433
  } catch (err) {