create-openclaw-bot 5.7.10 → 5.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2568 @@
1
+ import http from 'http';
2
+ import fs, { createReadStream, existsSync, promises as fsp } from 'fs';
3
+ import { createRequire } from 'module';
4
+ import { basename, dirname, extname, join, normalize, resolve } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { spawn, execFile } from 'child_process';
7
+ import os from 'os';
8
+ import net from 'net';
9
+ import { DatabaseSync } from 'node:sqlite';
10
+ const _require = createRequire(import.meta.url);
11
+ function loadSharedModule(modulePath, globalName) {
12
+ const loaded = _require(modulePath);
13
+ if (loaded && Object.keys(loaded).length > 0) return loaded;
14
+ return globalThis[globalName] || loaded || {};
15
+ }
16
+ const { buildWorkspaceFileMap } = loadSharedModule('../setup/shared/workspace-gen.js', '__openclawWorkspace');
17
+ const { buildOpenclawJson, buildEnvFileContent, buildExecApprovalsJson } = loadSharedModule('../setup/shared/bot-config-gen.js', '__openclawBotConfig');
18
+ const { buildDockerArtifacts } = loadSharedModule('../setup/shared/docker-gen.js', '__openclawDockerGen');
19
+ const { OPENCLAW_NPM_SPEC, NINE_ROUTER_NPM_SPEC, build9RouterProviderConfig, get9RouterBaseUrl } = loadSharedModule('../setup/shared/common-gen.js', '__openclawCommon');
20
+ const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const WEB_DIR = resolve(__dirname, '../web');
24
+ const SETUP_VERSION = (() => { try { return JSON.parse(fs.readFileSync(resolve(__dirname, '../../package.json'), 'utf8')).version || '0.0.0'; } catch { return '0.0.0'; } })();
25
+ const DEFAULT_PROJECT_NAME = 'openclaw-bot';
26
+ const STATE_FILE = '.openclaw-setup-state.json';
27
+ const DEFAULT_MODEL = 'smart-route';
28
+ const logClients = new Set();
29
+ let zaloLoginInFlight = false;
30
+ const state = {
31
+ installing: false,
32
+ installed: false,
33
+ lastError: null,
34
+ projectDir: null,
35
+ gatewayUrl: 'http://127.0.0.1:18789',
36
+ gatewayPort: 18789,
37
+ routerUrl: 'http://127.0.0.1:20128',
38
+ routerPort: 20128,
39
+ syncSource: 'config',
40
+ botPid: null,
41
+ mode: null,
42
+ os: null,
43
+ startedAt: null,
44
+ };
45
+
46
+ function sendLog(line) {
47
+ const payload = `data: ${JSON.stringify({ line, ts: new Date().toISOString() })}\n\n`;
48
+ for (const res of logClients) res.write(payload);
49
+ process.stdout.write(`${line}\n`);
50
+ }
51
+
52
+ function extractCompletePngBase64(stdout) {
53
+ const b64 = String(stdout || '').trim();
54
+ if (b64.length < 100) return '';
55
+ let buf;
56
+ try {
57
+ buf = Buffer.from(b64, 'base64');
58
+ } catch {
59
+ return '';
60
+ }
61
+ if (!buf || buf.length < 32) return '';
62
+ const pngSig = '89504e470d0a1a0a';
63
+ const hasSig = buf.subarray(0, 8).toString('hex') === pngSig;
64
+ const hasIend = buf.includes(Buffer.from('49454e44ae426082', 'hex'));
65
+ if (!hasSig || !hasIend) return '';
66
+ return b64;
67
+ }
68
+
69
+ function detectOs() {
70
+ const platform = process.platform;
71
+ if (platform === 'win32') return 'win';
72
+ if (platform === 'darwin') return 'macos';
73
+ if (platform === 'linux') return os.release().toLowerCase().includes('microsoft') ? 'linux-desktop' : 'linux-desktop';
74
+ return 'linux-desktop';
75
+ }
76
+
77
+ function recommendedMode(osChoice) {
78
+ if (osChoice === 'win' || osChoice === 'macos') return 'docker';
79
+ return 'native';
80
+ }
81
+
82
+ function commandExists(cmd, args = ['--version']) {
83
+ return new Promise((resolve) => {
84
+ const shell = process.platform === 'win32';
85
+ execFile(cmd, args, { windowsHide: true, timeout: 5000, shell }, (err, stdout, stderr) => {
86
+ resolve({ ok: !err, output: String(stdout || stderr || '').trim() });
87
+ });
88
+ });
89
+ }
90
+
91
+ function run(cmd, args, opts = {}) {
92
+ return new Promise((resolve, reject) => {
93
+ sendLog(`$ ${cmd} ${args.join(' ')}`);
94
+ const child = spawn(cmd, args, { cwd: opts.cwd, shell: process.platform === 'win32', env: { ...process.env, ...(opts.env || {}) } });
95
+ let stdout = '';
96
+ let resolved = false;
97
+ child.stdout.on('data', (d) => {
98
+ const chunk = String(d);
99
+ stdout += chunk;
100
+ sendLog(chunk.trimEnd());
101
+ if (opts.resolveOnPattern && opts.resolveOnPattern.test(stdout) && !resolved) {
102
+ resolved = true;
103
+ resolve();
104
+ setTimeout(() => {
105
+ try { child.kill('SIGTERM'); } catch {}
106
+ }, 1000);
107
+ }
108
+ });
109
+ child.stderr.on('data', (d) => sendLog(String(d).trimEnd()));
110
+ child.on('error', (err) => {
111
+ if (!resolved) reject(err);
112
+ });
113
+ child.on('close', (code) => {
114
+ if (!resolved) {
115
+ if (code === 0) resolve();
116
+ else reject(new Error(`${cmd} exited ${code}`));
117
+ }
118
+ });
119
+ });
120
+ }
121
+
122
+ function startDetached(cmd, args, opts = {}) {
123
+ sendLog(`$ ${cmd} ${args.join(' ')} &`);
124
+ const child = spawn(cmd, args, {
125
+ cwd: opts.cwd,
126
+ shell: process.platform === 'win32',
127
+ detached: true,
128
+ stdio: 'ignore',
129
+ windowsHide: opts.windowsHide ?? true,
130
+ env: { ...process.env, ...(opts.env || {}) },
131
+ });
132
+ child.unref();
133
+ return child.pid;
134
+ }
135
+
136
+ async function getCurrentRuntimeVersions() {
137
+ const [openclaw, nineRouter, node] = await Promise.all([
138
+ commandExists('openclaw', ['--version']),
139
+ commandExists('9router', ['--version']),
140
+ commandExists('node', ['--version']),
141
+ ]);
142
+ return {
143
+ openclaw: openclaw.ok ? (openclaw.output.split(/\r?\n/)[0] || '').trim() : '',
144
+ nineRouter: nineRouter.ok ? (nineRouter.output.split(/\r?\n/)[0] || '').trim() : '',
145
+ node: node.ok ? (node.output.split(/\r?\n/)[0] || '').trim() : process.version,
146
+ };
147
+ }
148
+
149
+ async function resolveProjectRuntimeVersions(projectDir, mode = state.mode || 'docker') {
150
+ const fallback = {
151
+ openclaw: '',
152
+ nineRouter: '',
153
+ node: process.version || '',
154
+ };
155
+ if (!projectDir) return fallback;
156
+ if (mode === 'docker') {
157
+ const compose = await readComposeText(projectDir);
158
+ const botContainer = getBotContainerName(projectDir);
159
+ const routerContainer = parseComposeServiceContainerName(compose, '9router') || '9router';
160
+ const [openclawOut, routerOut, nodeOut] = await Promise.all([
161
+ runCapture('docker', ['exec', botContainer, 'node', '-e', "const fs=require('fs');const p='/usr/local/lib/node_modules/openclaw/package.json';process.stdout.write(fs.existsSync(p)?String(JSON.parse(fs.readFileSync(p,'utf8')).version||''):'')"], { shell: false }),
162
+ runCapture('docker', ['exec', routerContainer, 'node', '-e', "fetch('http://localhost:20128/api/version').then(async r=>{const d=await r.json().catch(()=>({}));process.stdout.write(String(d.currentVersion||''));}).catch(()=>process.stdout.write(''))"], { shell: false }),
163
+ runCapture('docker', ['exec', botContainer, 'node', '--version'], { shell: false }),
164
+ ]);
165
+ return {
166
+ openclaw: String(openclawOut.stdout || '').trim(),
167
+ nineRouter: String(routerOut.stdout || '').trim(),
168
+ node: String(nodeOut.stdout || '').trim(),
169
+ };
170
+ }
171
+ const current = await getCurrentRuntimeVersions();
172
+ return {
173
+ openclaw: current.openclaw || '',
174
+ nineRouter: current.nineRouter || '',
175
+ node: current.node || process.version || '',
176
+ };
177
+ }
178
+
179
+ function runStreamed(cmd, args, opts = {}) {
180
+ sendLog(`$ ${cmd} ${args.join(' ')}`);
181
+ const child = spawn(cmd, args, {
182
+ cwd: opts.cwd,
183
+ shell: opts.shell ?? process.platform === 'win32',
184
+ windowsHide: opts.windowsHide ?? true,
185
+ env: { ...process.env, ...(opts.env || {}) },
186
+ });
187
+ child.stdout.on('data', (d) => sendLog(String(d).trimEnd()));
188
+ child.stderr.on('data', (d) => sendLog(String(d).trimEnd()));
189
+ child.on('error', (err) => sendLog(`ERROR: ${err.message}`));
190
+ child.on('close', (code) => sendLog(`${cmd} exited ${code}`));
191
+ return child.pid;
192
+ }
193
+
194
+ function runStreamedToLogFile(cmd, args, logFile, opts = {}) {
195
+ sendLog(`$ ${cmd} ${args.join(' ')}`);
196
+ const child = spawn(cmd, args, {
197
+ cwd: opts.cwd,
198
+ shell: opts.shell ?? process.platform === 'win32',
199
+ windowsHide: opts.windowsHide ?? true,
200
+ env: { ...process.env, ...(opts.env || {}) },
201
+ });
202
+ let offset = 0;
203
+ const poll = setInterval(async () => {
204
+ try {
205
+ const data = opts.readLogFile ? await opts.readLogFile(logFile) : (existsSync(logFile) ? await fsp.readFile(logFile, 'utf8') : '');
206
+ if (data.length <= offset) return;
207
+ const chunk = data.slice(offset);
208
+ offset = data.length;
209
+ for (const line of chunk.split(/\r?\n/).filter(Boolean)) sendLog(line);
210
+ } catch {}
211
+ }, 700);
212
+ child.on('error', (err) => sendLog(`ERROR: ${err.message}`));
213
+ child.on('close', (code) => { clearInterval(poll); sendLog(`${cmd} exited ${code}`); });
214
+ return child.pid;
215
+ }
216
+
217
+ function runCapture(cmd, args, opts = {}) {
218
+ return new Promise((resolve) => {
219
+ let stdout = '';
220
+ let stderr = '';
221
+ const child = spawn(cmd, args, {
222
+ cwd: opts.cwd,
223
+ shell: opts.shell ?? process.platform === 'win32',
224
+ windowsHide: opts.windowsHide ?? true,
225
+ env: { ...process.env, ...(opts.env || {}) },
226
+ });
227
+ let timedOut = false;
228
+ const timer = Number.isFinite(opts.timeout) && opts.timeout > 0
229
+ ? setTimeout(() => {
230
+ timedOut = true;
231
+ try { child.kill(); } catch {}
232
+ }, opts.timeout)
233
+ : null;
234
+ child.stdout.on('data', (d) => { stdout += String(d); });
235
+ child.stderr.on('data', (d) => { stderr += String(d); });
236
+ child.on('error', (err) => {
237
+ if (timer) clearTimeout(timer);
238
+ resolve({ code: 1, stdout, stderr: stderr + err.message });
239
+ });
240
+ child.on('close', (code) => {
241
+ if (timer) clearTimeout(timer);
242
+ resolve({ code: timedOut ? 124 : code, stdout, stderr: timedOut ? `${stderr}
243
+ Timed out after ${opts.timeout}ms`.trim() : stderr });
244
+ });
245
+ });
246
+ }
247
+
248
+ function safeJoin(root, name) {
249
+ const clean = normalize(String(name || '')).replace(/^([/\\])+/, '');
250
+ if (!clean || clean.includes('..')) throw httpError(400, 'Invalid file path');
251
+ const full = resolve(root, clean);
252
+ if (!full.startsWith(resolve(root))) throw httpError(403, 'Path escapes project');
253
+ return full;
254
+ }
255
+
256
+ function httpError(status, message) {
257
+ const err = new Error(message);
258
+ err.status = status;
259
+ return err;
260
+ }
261
+
262
+ function slugify(name, fallback = 'bot') {
263
+ return String(name || fallback).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || fallback;
264
+ }
265
+
266
+ function readEnvText(text = '') {
267
+ const out = {};
268
+ for (const line of String(text || '').split(/\r?\n/)) {
269
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
270
+ if (m) out[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
271
+ }
272
+ return out;
273
+ }
274
+
275
+ function numberFrom(...values) {
276
+ for (const value of values) {
277
+ const n = Number(String(value ?? '').match(/\d{2,5}/)?.[0] || '');
278
+ if (Number.isFinite(n) && n > 0) return n;
279
+ }
280
+ return 0;
281
+ }
282
+
283
+ async function readEnvFile(file) {
284
+ return existsSync(file) ? readEnvText(await fsp.readFile(file, 'utf8').catch(() => '')) : {};
285
+ }
286
+
287
+ function openclawProjectEnv(projectDir) {
288
+ return {
289
+ ...process.env,
290
+ OPENCLAW_HOME: join(projectDir, '.openclaw'),
291
+ OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
292
+ };
293
+ }
294
+
295
+ async function runOpenclawJson(projectDir, args = [], timeout = 12000) {
296
+ const out = await runCapture('openclaw', args, {
297
+ cwd: projectDir,
298
+ env: openclawProjectEnv(projectDir),
299
+ shell: false,
300
+ timeout,
301
+ });
302
+ if (out.code !== 0) throw new Error((out.stderr || out.stdout || `openclaw ${args.join(' ')} failed`).trim());
303
+ const text = String(out.stdout || '').trim();
304
+ return text ? JSON.parse(text) : null;
305
+ }
306
+
307
+ async function readComposeText(projectDir) {
308
+ const p = join(projectDir || '', 'docker', 'openclaw', 'docker-compose.yml');
309
+ return existsSync(p) ? await fsp.readFile(p, 'utf8').catch(() => '') : '';
310
+ }
311
+
312
+ function getComposeServiceBlock(compose = '', serviceName = '') {
313
+ const lines = String(compose || '').split(/\r?\n/);
314
+ const start = lines.findIndex((l) => new RegExp(`^\\s{2}${serviceName}:\\s*$`).test(l));
315
+ if (start < 0) return '';
316
+ let end = lines.length;
317
+ for (let i = start + 1; i < lines.length; i++) if (/^\s{2}\S/.test(lines[i])) { end = i; break; }
318
+ return lines.slice(start, end).join('\n');
319
+ }
320
+
321
+ function parseComposeServiceContainerName(compose = '', serviceName = '') {
322
+ const block = getComposeServiceBlock(compose, serviceName);
323
+ return block.match(/^\s{4}container_name:\s*["']?([^"'\r\n]+)["']?\s*$/m)?.[1]?.trim() || '';
324
+ }
325
+
326
+ function parseComposeHostPort(compose = '', containerPort = 0, serviceHint = '') {
327
+ const lines = String(compose || '').split(/\r?\n/);
328
+ let text = lines.join('\n');
329
+ if (serviceHint) {
330
+ const start = lines.findIndex((l) => new RegExp(`^\\s{2}${serviceHint}:\\s*$`).test(l));
331
+ if (start >= 0) {
332
+ let end = lines.length;
333
+ for (let i = start + 1; i < lines.length; i++) if (/^\s{2}\S/.test(lines[i])) { end = i; break; }
334
+ text = lines.slice(start, end).join('\n');
335
+ }
336
+ }
337
+ const re = new RegExp(`["']?(?:127\\.0\\.0\\.1:)?(\\d{2,5}):${containerPort || '\\d{2,5}'}["']?`);
338
+ return numberFrom(text.match(re)?.[1]);
339
+ }
340
+
341
+ function parseBaseUrlPort(baseUrl = '') {
342
+ try {
343
+ const u = new URL(baseUrl);
344
+ return Number(u.port || (u.protocol === 'https:' ? 443 : 80)) || 0;
345
+ } catch {
346
+ return numberFrom(baseUrl);
347
+ }
348
+ }
349
+
350
+ async function detectRuntime(projectDir) {
351
+ const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
352
+ const cfg = existsSync(cfgPath) ? JSON.parse(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}')) : {};
353
+ let cliGatewayStatus = null;
354
+ let cliGatewayPort = 0;
355
+ let cliRouterPort = 0;
356
+ let syncSource = 'config';
357
+ try {
358
+ cliGatewayStatus = await runOpenclawJson(projectDir, ['gateway', 'status', '--json', '--no-probe'], 15000);
359
+ cliGatewayPort = numberFrom(cliGatewayStatus?.gateway?.port);
360
+ if (cliGatewayPort) syncSource = 'cli';
361
+ } catch {}
362
+ try {
363
+ cliRouterPort = parseBaseUrlPort(await runOpenclawJson(projectDir, ['config', 'get', "models.providers['9router'].baseUrl", '--json'], 8000));
364
+ if (cliRouterPort) syncSource = 'cli';
365
+ } catch {}
366
+ const rootEnv = await readEnvFile(join(projectDir || '', '.env'));
367
+ const dockerEnv = await readEnvFile(join(projectDir || '', 'docker', 'openclaw', '.env'));
368
+ const compose = await readComposeText(projectDir);
369
+ const gatewayPort = numberFrom(
370
+ cliGatewayPort,
371
+ rootEnv.OPENCLAW_GATEWAY_PORT,
372
+ rootEnv.OPENCLAW_PORT,
373
+ dockerEnv.OPENCLAW_GATEWAY_PORT,
374
+ dockerEnv.OPENCLAW_PORT,
375
+ compose.match(/OPENCLAW_GATEWAY_PORT=(\d+)/)?.[1],
376
+ compose.match(/OPENCLAW_PORT=(\d+)/)?.[1],
377
+ parseComposeHostPort(compose, numberFrom(cfg.gateway?.port) || 18789, 'ai-bot'),
378
+ cfg.gateway?.port,
379
+ 18789,
380
+ ) || 18789;
381
+ const providerBase = cfg.models?.providers?.['9router']?.baseUrl || '';
382
+ const providerPort = cliRouterPort || parseBaseUrlPort(providerBase);
383
+ const routerContainerPort = numberFrom(compose.match(/(?:PORT=|-p\s+)(\d{2,5})/)?.[1], providerPort, 20128) || 20128;
384
+ const routerPort = numberFrom(
385
+ parseComposeHostPort(compose, routerContainerPort, '9router'),
386
+ /^(https?:\/\/)?(localhost|127\.0\.0\.1|host\.docker\.internal|9router)(:|\/)/i.test(providerBase) ? providerPort : 0,
387
+ 20128,
388
+ ) || 20128;
389
+ if (syncSource !== 'cli' && compose) syncSource = 'compose';
390
+ return {
391
+ gatewayPort,
392
+ routerPort,
393
+ gatewayUrl: `http://127.0.0.1:${gatewayPort}`,
394
+ routerUrl: `http://127.0.0.1:${routerPort}`,
395
+ mode: existsSync(join(projectDir || '', 'docker', 'openclaw', 'docker-compose.yml')) ? 'docker' : 'native',
396
+ cliGatewayStatus,
397
+ syncSource,
398
+ };
399
+ }
400
+
401
+ async function syncRuntimeState(projectDir) {
402
+ if (!projectDir || !existsSync(join(projectDir, '.openclaw', 'openclaw.json'))) return;
403
+ await applyResolved9RouterApiKey(projectDir).catch(() => {});
404
+ const rt = await detectRuntime(projectDir).catch(() => null);
405
+ if (!rt) return;
406
+ state.projectDir = projectDir;
407
+ state.gatewayPort = rt.gatewayPort;
408
+ state.routerPort = rt.routerPort;
409
+ state.gatewayUrl = rt.gatewayUrl;
410
+ state.routerUrl = rt.routerUrl;
411
+ state.mode = state.mode || rt.mode;
412
+ state.syncSource = rt.syncSource || 'config';
413
+ state.installed = true;
414
+ // Auto-sync Docker files if outdated
415
+ if (rt.mode === 'docker') {
416
+ await syncDockerInfra(projectDir).catch((err) =>
417
+ sendLog(`[sync] Docker infra sync skipped: ${err.message}`)
418
+ );
419
+ }
420
+ }
421
+
422
+ function uniqueSlug(base, used) {
423
+ let out = base;
424
+ let i = 2;
425
+ while (used.has(out)) out = `${base}-${i++}`;
426
+ return out;
427
+ }
428
+
429
+ function uniqueDisplayName(base, used) {
430
+ const clean = String(base || 'OpenClaw Bot').trim() || 'OpenClaw Bot';
431
+ const taken = new Set(Array.from(used || []).map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
432
+ if (!taken.has(clean.toLowerCase())) return clean;
433
+ let i = 2;
434
+ let out = `${clean} ${i}`;
435
+ while (taken.has(out.toLowerCase())) out = `${clean} ${++i}`;
436
+ return out;
437
+ }
438
+
439
+ function parseIdentityFields(content = '') {
440
+ const out = {};
441
+ const lines = String(content || '').split(/\r?\n/);
442
+ for (const line of lines) {
443
+ const m = line.match(/^\s*-\s*\*\*(?:Tên|Name)\s*:\*\*\s*(.+?)\s*$/i);
444
+ if (m) out.name = m[1].trim();
445
+ const r = line.match(/^\s*-\s*\*\*(?:Vai trò|Role)\s*:\*\*\s*(.+?)\s*$/i);
446
+ if (r) out.role = r[1].trim();
447
+ }
448
+ return out;
449
+ }
450
+
451
+ function usableIdentityName(name = '') {
452
+ const clean = String(name || '').trim();
453
+ if (clean && clean.length <= 60 && !/[*_"()]/.test(clean)) return clean;
454
+ const bold = clean.match(/\*\*([^*]{1,40})\*\*/)?.[1]?.trim();
455
+ return bold && !/[*_"()]/.test(bold) ? bold : '';
456
+ }
457
+
458
+ function workspaceRelForAgent(agent, cfg = {}, projectDir = '') {
459
+ const hasOwnWorkspace = !!agent?.workspace;
460
+ const raw = agent?.workspace || cfg.agents?.defaults?.workspace || '';
461
+ const s = String(raw || '').replace(/\\/g, '/');
462
+ let resolved = '';
463
+ const m = s.match(/(?:^|\/)\.openclaw\/(.+)$/);
464
+ if (m) resolved = m[1].replace(/^\/+/, '');
465
+ else if (s.startsWith('.openclaw/')) resolved = s.replace(/^\.openclaw\//, '');
466
+ else if (s.startsWith('workspace')) resolved = s;
467
+ else resolved = `workspace-${agent?.id || 'workspace'}`;
468
+ // When workspace came from defaults (shared), prefer per-agent dir if it exists
469
+ if (!hasOwnWorkspace && agent?.id && resolved !== `workspace-${agent.id}`) {
470
+ const perAgent = `workspace-${agent.id}`;
471
+ if (projectDir) {
472
+ if (existsSync(join(projectDir, '.openclaw', perAgent))) return perAgent;
473
+ }
474
+ if (projectDir && !existsSync(join(projectDir, '.openclaw', resolved))) return perAgent;
475
+ }
476
+ return resolved;
477
+ }
478
+
479
+ async function readAgentIdentity(projectDir, agent) {
480
+ const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
481
+ const cfg = existsSync(cfgPath) ? JSON.parse(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}')) : {};
482
+ const rel = workspaceRelForAgent(agent, cfg, projectDir);
483
+ if (!rel) return {};
484
+ const file = join(projectDir, '.openclaw', rel, 'IDENTITY.md');
485
+ if (!existsSync(file)) return {};
486
+ return parseIdentityFields(await fsp.readFile(file, 'utf8').catch(() => ''));
487
+ }
488
+
489
+ function ensureConfigShape(cfg) {
490
+ if (!cfg || typeof cfg !== 'object') throw httpError(400, 'Invalid openclaw.json');
491
+ cfg.agents = cfg.agents || {};
492
+ cfg.agents.defaults = cfg.agents.defaults || { model: { primary: DEFAULT_MODEL, fallbacks: [] } };
493
+ cfg.agents.defaults.model = cfg.agents.defaults.model || { primary: DEFAULT_MODEL, fallbacks: [] };
494
+ if (!cfg.agents.defaults.model.primary || cfg.agents.defaults.model.primary === '9router/smart-route' || cfg.agents.defaults.model.primary === 'openai/smart-route') cfg.agents.defaults.model.primary = DEFAULT_MODEL;
495
+ cfg.agents.list = Array.isArray(cfg.agents.list) ? cfg.agents.list : [];
496
+ for (const agent of cfg.agents.list) {
497
+ if (agent && typeof agent === 'object') {
498
+ delete agent.channel;
499
+ delete agent.role;
500
+ delete agent.desc;
501
+ delete agent.description;
502
+ delete agent.persona;
503
+ }
504
+ agent.model = agent.model || { primary: cfg.agents.defaults.model.primary, fallbacks: [] };
505
+ if (!agent.model.primary || agent.model.primary === '9router/smart-route' || agent.model.primary === 'openai/smart-route') agent.model.primary = DEFAULT_MODEL;
506
+ }
507
+ cfg.models = cfg.models || { mode: 'merge', providers: {} };
508
+ cfg.models.providers = cfg.models.providers || {};
509
+ if (!cfg.models.providers['9router']) cfg.models.providers['9router'] = cfg.models.providers.openai || (build9RouterProviderConfig ? build9RouterProviderConfig(get9RouterBaseUrl ? get9RouterBaseUrl(state.mode || 'docker', state.routerPort) : `http://9router:${state.routerPort || 20128}/v1`) : undefined);
510
+ if (cfg.models.providers.openai?.baseUrl?.includes('9router')) delete cfg.models.providers.openai;
511
+ cfg.channels = cfg.channels || {};
512
+ cfg.bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
513
+ cfg.plugins = cfg.plugins || { entries: { 'memory-core': { config: { dreaming: { enabled: false } } } } };
514
+ // Preserve plugins.allow — needed for non-bundled plugins like zalouser, zalo-mod
515
+ if (!cfg.plugins.allow) cfg.plugins.allow = [];
516
+ cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
517
+ return cfg;
518
+ }
519
+
520
+ function ensureTelegramChannel(cfg) {
521
+ cfg.channels.telegram = cfg.channels.telegram || {};
522
+ Object.assign(cfg.channels.telegram, {
523
+ enabled: true,
524
+ defaultAccount: cfg.channels.telegram.defaultAccount || 'default',
525
+ dmPolicy: cfg.channels.telegram.dmPolicy || 'open',
526
+ allowFrom: cfg.channels.telegram.allowFrom || ['*'],
527
+ replyToMode: cfg.channels.telegram.replyToMode || 'first',
528
+ reactionLevel: cfg.channels.telegram.reactionLevel || 'minimal',
529
+ actions: cfg.channels.telegram.actions || { sendMessage: true, reactions: true },
530
+ accounts: cfg.channels.telegram.accounts || {},
531
+ });
532
+ }
533
+
534
+ function ensureZaloUserChannel(cfg) {
535
+ cfg.channels.zalouser = cfg.channels.zalouser || {
536
+ enabled: true,
537
+ defaultAccount: 'default',
538
+ accounts: {
539
+ default: {
540
+ enabled: true,
541
+ profile: 'default',
542
+ },
543
+ },
544
+ dmPolicy: 'open',
545
+ groupPolicy: 'open',
546
+ historyLimit: 50,
547
+ groups: {
548
+ '*': { enabled: true, requireMention: false },
549
+ },
550
+ allowFrom: ['*'],
551
+ groupAllowFrom: ['*'],
552
+ };
553
+ // Ensure zalouser is in plugins.entries (plugins.allow is deprecated)
554
+ cfg.plugins.entries = cfg.plugins.entries || {};
555
+ cfg.plugins.entries.zalouser = cfg.plugins.entries.zalouser || { enabled: true };
556
+ cfg.plugins.allow = cfg.plugins.allow || [];
557
+ if (!cfg.plugins.allow.includes('zalouser')) cfg.plugins.allow.push('zalouser');
558
+ }
559
+
560
+ function ensureZaloApiChannel(cfg, token) {
561
+ cfg.channels.zalo = cfg.channels.zalo || {};
562
+ Object.assign(cfg.channels.zalo, {
563
+ enabled: true,
564
+ provider: cfg.channels.zalo.provider || 'official_account',
565
+ botToken: token || cfg.channels.zalo.botToken || '<your_zalo_bot_token>',
566
+ });
567
+ }
568
+
569
+ function readProjectConfig(projectDir) {
570
+ const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
571
+ if (!projectDir || !existsSync(cfgPath)) return null;
572
+ return { cfgPath, cfg: null };
573
+ }
574
+
575
+ // Read 9Router endpoint API key from the apiKeys table (NOT providerConnections which stores Gemini/OpenAI keys)
576
+ function read9RouterEndpointApiKey(sqlitePath) {
577
+ if (!sqlitePath || !existsSync(sqlitePath)) return '';
578
+ let db;
579
+ try {
580
+ db = new DatabaseSync(sqlitePath, { readOnly: true });
581
+ const rows = db.prepare(`
582
+ SELECT key FROM apiKeys
583
+ WHERE isActive = 1
584
+ ORDER BY createdAt DESC
585
+ LIMIT 1
586
+ `).all();
587
+ return String(rows[0]?.key || '').trim();
588
+ } catch {
589
+ return '';
590
+ } finally {
591
+ try { db?.close(); } catch {}
592
+ }
593
+ }
594
+
595
+ // Keep legacy alias for backward compat
596
+ function read9RouterApiKeyFromSqlite(sqlitePath) {
597
+ return read9RouterEndpointApiKey(sqlitePath);
598
+ }
599
+
600
+ async function read9RouterApiKeyFromDocker(containerName) {
601
+ if (!containerName) return '';
602
+ const script = `
603
+ const { DatabaseSync } = require('node:sqlite');
604
+ let db;
605
+ try {
606
+ db = new DatabaseSync('/root/.9router/db/data.sqlite', { readOnly: true });
607
+ const rows = db.prepare("SELECT key FROM apiKeys WHERE isActive = 1 ORDER BY createdAt DESC LIMIT 1").all();
608
+ process.stdout.write(rows[0] && rows[0].key ? rows[0].key : '');
609
+ } catch (err) {
610
+ process.stderr.write(String(err && err.message || err));
611
+ process.exit(1);
612
+ } finally {
613
+ try { db && db.close(); } catch {}
614
+ }`;
615
+ const out = await runCapture('docker', ['exec', containerName, 'node', '-e', script], { shell: false });
616
+ if (out.code !== 0) return '';
617
+ return String(out.stdout || '').trim();
618
+ }
619
+
620
+ async function create9RouterApiKeyFromDocker(containerName, keyName = 'openclaw-bot') {
621
+ if (!containerName) return '';
622
+ const script = `
623
+ const api = require('/usr/local/lib/node_modules/9router/src/cli/api/client.js');
624
+ api.createApiKey(${JSON.stringify(keyName)}).then((r) => {
625
+ process.stdout.write(JSON.stringify(r || {}));
626
+ }).catch((err) => {
627
+ process.stderr.write(String(err && err.message || err));
628
+ process.exit(1);
629
+ });
630
+ `;
631
+ const out = await runCapture('docker', ['exec', containerName, 'node', '-e', script], { shell: false });
632
+ if (out.code !== 0) return '';
633
+ try {
634
+ const data = JSON.parse(String(out.stdout || '{}'));
635
+ return String(data?.data?.key || '').trim();
636
+ } catch {
637
+ return '';
638
+ }
639
+ }
640
+
641
+ async function resolveProject9RouterApiKey(projectDir, cfg = null) {
642
+ const configApiKey = String(cfg?.models?.providers?.['9router']?.apiKey || '').trim();
643
+ if (configApiKey && configApiKey !== 'sk-no-key') return configApiKey;
644
+ const compose = await readComposeText(projectDir);
645
+ if (compose) {
646
+ const containerName = parseComposeServiceContainerName(compose, '9router') || '9router';
647
+ const dockerApiKey = await read9RouterApiKeyFromDocker(containerName);
648
+ if (dockerApiKey) return dockerApiKey;
649
+ const createdApiKey = await create9RouterApiKeyFromDocker(containerName, `openclaw-${slugify(basename(projectDir || 'openclaw')) || 'bot'}`).catch(() => '');
650
+ if (createdApiKey) return createdApiKey;
651
+ }
652
+ const nativeApiKey = read9RouterApiKeyFromSqlite(join(projectDir || '', '.9router', 'db', 'data.sqlite'));
653
+ if (nativeApiKey) return nativeApiKey;
654
+ const homeApiKey = read9RouterApiKeyFromSqlite(join(os.homedir(), '.9router', 'db', 'data.sqlite'));
655
+ if (homeApiKey) return homeApiKey;
656
+ return '';
657
+ }
658
+
659
+ async function applyResolved9RouterApiKey(projectDir, cfg = null) {
660
+ if (!projectDir) return '';
661
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
662
+ if (!existsSync(cfgPath)) return '';
663
+ const current = cfg || ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
664
+ const apiKey = await resolveProject9RouterApiKey(projectDir, current);
665
+ if (!apiKey) return '';
666
+ current.models = current.models || { mode: 'merge', providers: {} };
667
+ current.models.providers = current.models.providers || {};
668
+ current.models.providers['9router'] = current.models.providers['9router'] || (build9RouterProviderConfig ? build9RouterProviderConfig(get9RouterBaseUrl ? get9RouterBaseUrl(state.mode || 'docker', state.routerPort) : `http://9router:${state.routerPort || 20128}/v1`) : {});
669
+ if (current.models.providers['9router'].apiKey !== apiKey) {
670
+ current.models.providers['9router'].apiKey = apiKey;
671
+ await fsp.writeFile(cfgPath, JSON.stringify(current, null, 2), 'utf8');
672
+ }
673
+ return apiKey;
674
+ }
675
+
676
+ async function readBotCredentials(projectDir) {
677
+ const found = readProjectConfig(projectDir);
678
+ if (!found) return { openclawToken: '', nineRouterApiKey: '' };
679
+ const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(found.cfgPath, 'utf8')));
680
+ return {
681
+ openclawToken: cfg.gateway?.auth?.token || '',
682
+ nineRouterApiKey: await resolveProject9RouterApiKey(projectDir, cfg),
683
+ };
684
+ }
685
+
686
+ async function updateBotCredentials(projectDir, body = {}) {
687
+ const found = readProjectConfig(projectDir);
688
+ if (!found) throw httpError(400, 'Install project not found');
689
+ const raw = await fsp.readFile(found.cfgPath, 'utf8');
690
+ const cfg = ensureConfigShape(JSON.parse(raw));
691
+ const nineRouterApiKey = String(body.nineRouterApiKey || '').trim();
692
+ if (Object.prototype.hasOwnProperty.call(body, 'nineRouterApiKey')) {
693
+ cfg.models = cfg.models || { mode: 'merge', providers: {} };
694
+ cfg.models.providers = cfg.models.providers || {};
695
+ cfg.models.providers['9router'] = cfg.models.providers['9router'] || (build9RouterProviderConfig ? build9RouterProviderConfig(get9RouterBaseUrl ? get9RouterBaseUrl(state.mode || 'docker', state.routerPort) : `http://9router:${state.routerPort || 20128}/v1`) : {});
696
+ cfg.models.providers['9router'].apiKey = nineRouterApiKey;
697
+ await appendEnvValue(projectDir, 'NINE_ROUTER_API_KEY', nineRouterApiKey);
698
+ }
699
+ await fsp.writeFile(found.cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
700
+ return readBotCredentials(projectDir);
701
+ }
702
+
703
+ async function appendEnvValue(projectDir, key, value) {
704
+ const envPath = join(projectDir, '.env');
705
+ const line = `${key}=${value || ''}`;
706
+ let env = existsSync(envPath) ? await fsp.readFile(envPath, 'utf8') : '';
707
+ const re = new RegExp(`^${key}=.*$`, 'm');
708
+ env = re.test(env) ? env.replace(re, line) : `${env.replace(/\s*$/, '\n')}${line}\n`;
709
+ await fsp.writeFile(envPath, env, 'utf8');
710
+ }
711
+
712
+ function validateOpenclawConfig(cfg) {
713
+ if (!Array.isArray(cfg.agents?.list)) throw httpError(500, 'openclaw.json missing agents.list');
714
+ for (const a of cfg.agents.list) {
715
+ if (!a.id) throw httpError(500, `Invalid agent entry: ${a.id || '(missing id)'}`);
716
+ }
717
+ if (!cfg.channels || typeof cfg.channels !== 'object') throw httpError(500, 'openclaw.json missing channels');
718
+
719
+ // Self-healing: Garbage collect any orphaned telegram accounts that are no longer bound to any active agent
720
+ if (cfg.channels?.telegram?.accounts) {
721
+ const boundAccountIds = new Set(
722
+ (cfg.bindings || []).map((b) => b.match?.accountId).filter(Boolean)
723
+ );
724
+ for (const accId of Object.keys(cfg.channels.telegram.accounts)) {
725
+ if (!boundAccountIds.has(accId)) {
726
+ delete cfg.channels.telegram.accounts[accId];
727
+ }
728
+ }
729
+ }
730
+ }
731
+
732
+ function mapAgentChannel(agent, cfg) {
733
+ const agentId = typeof agent === 'string' ? agent : agent?.id;
734
+ if (agent && typeof agent === 'object' && ['telegram', 'zalo-personal', 'zalo-bot'].includes(agent.channel)) return agent.channel;
735
+ const binding = (cfg.bindings || []).find((b) => b.agentId === agentId);
736
+ const ch = binding?.match?.channel;
737
+ if (ch === 'zalouser') return 'zalo-personal';
738
+ if (ch === 'zalo') return 'zalo-bot';
739
+ if (ch === 'telegram') return 'telegram';
740
+ if (cfg.channels?.telegram && agentId) return 'telegram';
741
+ return 'unknown';
742
+ }
743
+
744
+ function mapAgentChannels(agent, cfg) {
745
+ if (agent?.channel && ['telegram', 'zalo-personal', 'zalo-bot'].includes(agent.channel)) return [agent.channel];
746
+ const channels = (cfg.bindings || [])
747
+ .filter((b) => b.agentId === agent?.id)
748
+ .map((b) => b.match?.channel === 'zalouser' ? 'zalo-personal' : b.match?.channel === 'zalo' ? 'zalo-bot' : b.match?.channel)
749
+ .filter((ch) => ['telegram', 'zalo-personal', 'zalo-bot'].includes(ch));
750
+ if (channels.length) return Array.from(new Set(channels));
751
+ const enabled = [];
752
+ if (cfg.channels?.telegram?.enabled) enabled.push('telegram');
753
+ if (cfg.channels?.zalouser?.enabled) enabled.push('zalo-personal');
754
+ if (cfg.channels?.zalo?.enabled) enabled.push('zalo-bot');
755
+ return enabled.length === 1 ? enabled : [mapAgentChannel(agent, cfg)];
756
+ }
757
+
758
+ async function listConfiguredBots(projectDir) {
759
+ const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
760
+ if (!projectDir || !existsSync(cfgPath)) return [];
761
+ const raw = await fsp.readFile(cfgPath, 'utf8');
762
+ const cfg = ensureConfigShape(JSON.parse(raw));
763
+ const normalized = JSON.stringify(cfg, null, 2) + '\n';
764
+ if (normalized !== raw) await fsp.writeFile(cfgPath, normalized, 'utf8');
765
+ const rows = await Promise.all(cfg.agents.list.map(async (agent) => {
766
+ const identity = await readAgentIdentity(projectDir, agent);
767
+ const hasOwnWorkspace = !!agent.workspace;
768
+ const identityName = usableIdentityName(identity.name);
769
+ return mapAgentChannels(agent, cfg).map((channel) => ({
770
+ id: agent.id,
771
+ name: (hasOwnWorkspace ? identityName : agent.name) || agent.name || identityName || agent.id,
772
+ role: identity.role || agent.role || agent.desc || agent.description || '',
773
+ channel,
774
+ workspace: agent.workspace || `.openclaw/${workspaceRelForAgent(agent, cfg, projectDir)}`,
775
+ agentDir: agent.agentDir,
776
+ }));
777
+ }));
778
+ return rows.flat();
779
+ }
780
+
781
+ async function rmInside(root, target) {
782
+ const rootFull = resolve(root);
783
+ const targetFull = resolve(root, target);
784
+ if (targetFull === rootFull || !targetFull.startsWith(rootFull + '\\') && !targetFull.startsWith(rootFull + '/')) {
785
+ throw httpError(403, 'Delete path escapes project');
786
+ }
787
+ await fsp.rm(targetFull, { recursive: true, force: true }).catch(() => {});
788
+ }
789
+
790
+ async function deleteBotInProject(projectDir, agentId) {
791
+ if (!projectDir) throw httpError(400, 'Install project not found');
792
+ const cleanId = slugify(agentId, '');
793
+ if (!cleanId || cleanId !== agentId) throw httpError(400, 'Invalid bot id');
794
+ const openclawHome = join(projectDir, '.openclaw');
795
+ const cfgPath = join(openclawHome, 'openclaw.json');
796
+ if (!existsSync(cfgPath)) throw httpError(404, 'openclaw.json not found');
797
+ const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
798
+ const agent = cfg.agents.list.find((a) => a.id === agentId);
799
+ if (!agent) throw httpError(404, 'Bot not found');
800
+
801
+ const removedBindings = (cfg.bindings || []).filter((b) => b.agentId === agentId);
802
+ cfg.agents.list = cfg.agents.list.filter((a) => a.id !== agentId);
803
+ cfg.bindings = (cfg.bindings || []).filter((b) => b.agentId !== agentId);
804
+ if (cfg.tools?.agentToAgent?.allow) {
805
+ cfg.tools.agentToAgent.allow = cfg.tools.agentToAgent.allow.filter((id) => id !== agentId);
806
+ if (cfg.tools.agentToAgent.allow.length < 2) delete cfg.tools.agentToAgent;
807
+ }
808
+
809
+ for (const binding of removedBindings) {
810
+ const accountId = binding.match?.accountId;
811
+ if (binding.match?.channel === 'telegram' && accountId && cfg.channels?.telegram?.accounts) delete cfg.channels.telegram.accounts[accountId];
812
+ }
813
+ if (cfg.channels?.telegram?.accounts?.[agentId]) delete cfg.channels.telegram.accounts[agentId];
814
+
815
+ if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
816
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
817
+
818
+ // Also clear bot tokens in .env files if deleting the primary bot
819
+ if (agentId === 'bot') {
820
+ const envPath = join(projectDir, '.env');
821
+ if (existsSync(envPath)) {
822
+ let envContent = await fsp.readFile(envPath, 'utf8');
823
+ envContent = envContent
824
+ .replace(/TELEGRAM_BOT_TOKEN=.*/g, 'TELEGRAM_BOT_TOKEN=')
825
+ await fsp.writeFile(envPath, envContent, 'utf8');
826
+
827
+ const dockerEnv = join(projectDir, 'docker', 'openclaw', '.env');
828
+ if (existsSync(dockerEnv)) {
829
+ await fsp.writeFile(dockerEnv, envContent, 'utf8');
830
+ }
831
+ }
832
+ }
833
+
834
+ const workspace = workspaceRelForAgent(agent, cfg, projectDir);
835
+ if (workspace) await rmInside(openclawHome, workspace);
836
+ await rmInside(projectDir, join('agents', agentId));
837
+ await rmInside(openclawHome, join('agents', agentId));
838
+
839
+ return { ok: true, agentId };
840
+ }
841
+
842
+ function portStatus(port) {
843
+ return new Promise((resolve) => {
844
+ const sock = net.createConnection({ host: '127.0.0.1', port, timeout: 650 });
845
+ sock.on('connect', () => { sock.destroy(); resolve('online'); });
846
+ sock.on('timeout', () => { sock.destroy(); resolve('offline'); });
847
+ sock.on('error', () => resolve('offline'));
848
+ });
849
+ }
850
+
851
+ async function buildBotStatus() {
852
+ if (state.projectDir) await syncRuntimeState(state.projectDir).catch(() => {});
853
+ const [gatewayStatus, routerStatus, bots, runtimeVersions] = await Promise.all([
854
+ portStatus(state.gatewayPort || 18789).catch(() => state.installed ? 'unknown' : 'offline'),
855
+ portStatus(state.routerPort || 20128).catch(() => state.installed ? 'unknown' : 'offline'),
856
+ listConfiguredBots(state.projectDir).catch(() => []),
857
+ resolveProjectRuntimeVersions(state.projectDir, state.mode).catch(() => ({ openclaw: '', nineRouter: '', node: process.version || '' })),
858
+ ]);
859
+ const credentials = await readBotCredentials(state.projectDir).catch(() => ({ openclawToken: '', nineRouterApiKey: '' }));
860
+ return { ...state, gatewayStatus, routerStatus, bots, credentials, runtimeVersions };
861
+ }
862
+
863
+ async function createBotInProject(projectDir, body = {}, runtime = {}) {
864
+ if (!projectDir) throw httpError(400, 'Install project not found');
865
+ const channel = body.channel || 'telegram';
866
+ if (!['telegram', 'zalo-personal', 'zalo-bot'].includes(channel)) throw httpError(400, 'Unsupported channel');
867
+ const token = String(body.token || '').trim();
868
+ if ((channel === 'telegram' || channel === 'zalo-bot') && !token) throw httpError(400, 'Token is required for this channel');
869
+
870
+ const requestedBotName = String(body.botName || '').trim() || 'OpenClaw Bot';
871
+ const botDesc = String(body.role || body.botDesc || '').trim() || 'Personal OpenClaw assistant';
872
+ const persona = String(body.personality || body.persona || '').trim();
873
+ const emoji = String(body.emoji || '').trim();
874
+ const userName = String(body.userName || '').trim();
875
+ const userDesc = String(body.userDescription || body.userDesc || '').trim();
876
+ const userInfo = [userName ? `- **Tên:** ${userName}` : '', userDesc ? `- **Mô tả:** ${userDesc}` : ''].filter(Boolean).join('\n');
877
+
878
+ const openclawHome = join(projectDir, '.openclaw');
879
+ await fsp.mkdir(openclawHome, { recursive: true });
880
+ const cfgPath = join(openclawHome, 'openclaw.json');
881
+ const cfg = ensureConfigShape(existsSync(cfgPath) ? JSON.parse(await fsp.readFile(cfgPath, 'utf8')) : buildOpenclawJson({
882
+ botName: requestedBotName,
883
+ channelKey: channel,
884
+ providerKey: '9router',
885
+ deployMode: runtime.mode || state.mode || 'docker',
886
+ osChoice: runtime.os || state.os || detectOs(),
887
+ selectedSkills: [],
888
+ skills: dataExport.SKILLS || [],
889
+ agentMetas: [],
890
+ }));
891
+
892
+ const existingAgentCount = cfg.agents.list.length;
893
+ const used = new Set(cfg.agents.list.map((a) => a.id));
894
+ const botName = uniqueDisplayName(requestedBotName, new Set(cfg.agents.list.map((a) => a.name || a.id)));
895
+ const agentId = uniqueSlug(slugify(botName), used);
896
+ const workspaceDir = `workspace-${agentId}`;
897
+ const model = cfg.agents.defaults?.model?.primary || cfg.agents.list[0]?.model?.primary || DEFAULT_MODEL;
898
+ cfg.agents.list.push({
899
+ id: agentId,
900
+ name: botName,
901
+ workspace: `/root/project/.openclaw/${workspaceDir}`,
902
+ agentDir: `agents/${agentId}/agent`,
903
+ model: { primary: model === '9router/smart-route' || model === 'openai/smart-route' ? DEFAULT_MODEL : model, fallbacks: [] },
904
+ });
905
+
906
+ let accountId = 'default';
907
+ let warning = '';
908
+ if (channel === 'telegram') {
909
+ ensureTelegramChannel(cfg);
910
+ const accounts = cfg.channels.telegram.accounts || {};
911
+ // Use 'default' for the first telegram account, agentId for subsequent ones
912
+ const existingTelegramAccounts = Object.keys(accounts).filter((k) => accounts[k]?.botToken && accounts[k].botToken !== '<your_bot_token>');
913
+ accountId = existingTelegramAccounts.length === 0 ? 'default' : agentId;
914
+ accounts[accountId] = { botToken: token };
915
+ cfg.channels.telegram.accounts = accounts;
916
+ cfg.channels.telegram.defaultAccount = cfg.channels.telegram.defaultAccount || 'default';
917
+ cfg.bindings.push({ agentId, match: { channel: 'telegram', accountId } });
918
+ await appendEnvValue(projectDir, accountId === 'default' ? 'TELEGRAM_BOT_TOKEN' : `TELEGRAM_BOT_TOKEN_${agentId.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`, token);
919
+ } else if (channel === 'zalo-personal') {
920
+ ensureZaloUserChannel(cfg);
921
+ const hasZaloBinding = cfg.bindings.some((b) => b.match?.channel === 'zalouser');
922
+ if (!hasZaloBinding) cfg.bindings.push({ agentId, match: { channel: 'zalouser' } });
923
+ else warning = 'Zalo User already has a channel binding; new agent created, route manually if needed.';
924
+ } else {
925
+ ensureZaloApiChannel(cfg, token);
926
+ const hasZaloApiBinding = cfg.bindings.some((b) => b.match?.channel === 'zalo');
927
+ if (!hasZaloApiBinding) cfg.bindings.push({ agentId, match: { channel: 'zalo' } });
928
+ await appendEnvValue(projectDir, 'ZALO_BOT_TOKEN', token);
929
+ }
930
+
931
+ if (cfg.agents.list.length > 1) {
932
+ cfg.tools.agentToAgent = { enabled: true, allow: cfg.agents.list.map((a) => a.id) };
933
+ }
934
+ validateOpenclawConfig(cfg);
935
+ if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
936
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
937
+
938
+ const files = buildWorkspaceFileMap({
939
+ isVi: true,
940
+ botName,
941
+ botDesc,
942
+ persona,
943
+ emoji,
944
+ userInfo,
945
+ agentWorkspaceDir: workspaceDir,
946
+ workspacePath: `.openclaw/${workspaceDir}`,
947
+ hasZaloMod: channel === 'zalo-personal',
948
+ });
949
+ const wsRoot = join(openclawHome, workspaceDir);
950
+ for (const [name, content] of Object.entries(files)) {
951
+ await fsp.mkdir(dirname(join(wsRoot, name)), { recursive: true });
952
+ await fsp.writeFile(join(wsRoot, name), content || '', 'utf8');
953
+ }
954
+
955
+ return { ok: true, agentId, accountId, channel, workspace: `.openclaw/${workspaceDir}`, warning };
956
+ }
957
+
958
+ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {}) {
959
+ if (!projectDir) throw httpError(400, 'Install project not found');
960
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
961
+ if (!existsSync(cfgPath)) throw httpError(404, 'openclaw.json not found');
962
+ const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
963
+ const agent = cfg.agents.list.find((a) => a.id === agentId);
964
+ if (!agent) throw httpError(404, 'Bot not found');
965
+
966
+ const channel = body.channel || (cfg.bindings || []).find((b) => b.agentId === agentId)?.match?.channel || 'telegram';
967
+ const token = String(body.token || '').trim();
968
+ const botName = uniqueDisplayName(String(body.botName || agent.name || agentId).trim() || agent.name || agentId, new Set(cfg.agents.list.filter((a) => a.id !== agentId).map((a) => a.name || a.id)));
969
+ const botDesc = String(body.role || body.botDesc || agent.role || agent.desc || agent.description || '').trim();
970
+ const persona = String(body.personality || body.persona || '').trim();
971
+ const emoji = String(body.emoji || '').trim();
972
+ const userName = String(body.userName || '').trim();
973
+ const userDesc = String(body.userDescription || body.userDesc || '').trim();
974
+ const userInfo = [userName ? `- **Tên:** ${userName}` : '', userDesc ? `- **Mô tả:** ${userDesc}` : ''].filter(Boolean).join('\n');
975
+ const workspaceDir = workspaceRelForAgent(agent, cfg, projectDir) || `workspace-${agentId}`;
976
+ agent.workspace = `/root/project/.openclaw/${workspaceDir}`;
977
+ agent.agentDir = `agents/${agentId}/agent`;
978
+
979
+ // Find the existing accountId from bindings BEFORE removing them
980
+ const existingBinding = (cfg.bindings || []).find((b) => b.agentId === agentId && b.match?.channel === 'telegram');
981
+ const existingAccountId = existingBinding?.match?.accountId || null;
982
+ cfg.bindings = (cfg.bindings || []).filter((b) => b.agentId !== agentId);
983
+ if (channel === 'telegram') {
984
+ ensureTelegramChannel(cfg);
985
+ const accounts = cfg.channels.telegram.accounts || {};
986
+ // Preserve existing accountId; for first/only bot use 'default', otherwise agentId
987
+ const existingTelegramAccounts = Object.keys(accounts).filter((k) => accounts[k]?.botToken);
988
+ const accountId = existingAccountId || (existingTelegramAccounts.length === 0 ? 'default' : agentId);
989
+ // If accountId changed (e.g. was agentId, now should be 'default'), clean up old key
990
+ if (existingAccountId && existingAccountId !== accountId) delete accounts[existingAccountId];
991
+ accounts[accountId] = { botToken: token || accounts[accountId]?.botToken || accounts[existingAccountId]?.botToken || '' };
992
+ cfg.channels.telegram.accounts = accounts;
993
+ cfg.channels.telegram.defaultAccount = cfg.channels.telegram.defaultAccount || 'default';
994
+ cfg.bindings.push({ agentId, match: { channel: 'telegram', accountId } });
995
+ } else if (channel === 'zalo-personal') {
996
+ ensureZaloUserChannel(cfg);
997
+ cfg.bindings.push({ agentId, match: { channel: 'zalouser' } });
998
+ } else {
999
+ ensureZaloApiChannel(cfg, token || cfg.channels?.zalo?.botToken || '');
1000
+ cfg.bindings.push({ agentId, match: { channel: 'zalo' } });
1001
+ }
1002
+
1003
+ agent.name = botName;
1004
+ agent.role = botDesc;
1005
+ validateOpenclawConfig(cfg);
1006
+ if (existsSync(cfgPath)) await fsp.copyFile(cfgPath, `${cfgPath}.bak`);
1007
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
1008
+
1009
+ // Synchronize the token to .env files for the primary bot to ensure Docker picks it up
1010
+ if (agentId === 'bot') {
1011
+ const envPath = join(projectDir, '.env');
1012
+ if (existsSync(envPath)) {
1013
+ let envContent = await fsp.readFile(envPath, 'utf8');
1014
+ if (channel === 'telegram') {
1015
+ if (envContent.includes('TELEGRAM_BOT_TOKEN=')) {
1016
+ envContent = envContent.replace(/TELEGRAM_BOT_TOKEN=.*/g, `TELEGRAM_BOT_TOKEN=${token}`);
1017
+ } else {
1018
+ envContent += `\nTELEGRAM_BOT_TOKEN=${token}\n`;
1019
+ }
1020
+ } else if (channel === 'zalo') {
1021
+ if (envContent.includes('ZALO_BOT_TOKEN=')) {
1022
+ envContent = envContent.replace(/ZALO_BOT_TOKEN=.*/g, `ZALO_BOT_TOKEN=${token}`);
1023
+ } else {
1024
+ envContent += `\nZALO_BOT_TOKEN=${token}\n`;
1025
+ }
1026
+ }
1027
+ await fsp.writeFile(envPath, envContent, 'utf8');
1028
+
1029
+ const dockerEnv = join(projectDir, 'docker', 'openclaw', '.env');
1030
+ if (existsSync(dockerEnv)) {
1031
+ await fsp.writeFile(dockerEnv, envContent, 'utf8');
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+ const files = buildWorkspaceFileMap({
1037
+ isVi: true,
1038
+ botName,
1039
+ botDesc,
1040
+ persona,
1041
+ emoji,
1042
+ userInfo,
1043
+ agentWorkspaceDir: workspaceDir,
1044
+ workspacePath: `.openclaw/${workspaceDir}`,
1045
+ hasZaloMod: channel === 'zalo-personal',
1046
+ });
1047
+ const wsRoot = join(projectDir, '.openclaw', workspaceDir);
1048
+ for (const [name, content] of Object.entries(files)) {
1049
+ await fsp.mkdir(dirname(join(wsRoot, name)), { recursive: true });
1050
+ await fsp.writeFile(join(wsRoot, name), content || '', 'utf8');
1051
+ }
1052
+ return { ok: true, agentId, channel, workspace: `.openclaw/${workspaceDir}` };
1053
+ }
1054
+
1055
+ function openclawRuntimeEnv(projectDir) {
1056
+ return {
1057
+ OPENCLAW_HOME: join(projectDir, '.openclaw'),
1058
+ OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
1059
+ DATA_DIR: join(projectDir, '.9router'),
1060
+ OPENCLAW_GATEWAY_PORT: String(state.gatewayPort || 18789),
1061
+ OPENCLAW_PORT: String(state.gatewayPort || 18789),
1062
+ };
1063
+ }
1064
+
1065
+ async function waitForDockerContainer(name, timeoutMs = 30000) {
1066
+ const started = Date.now();
1067
+ while (Date.now() - started < timeoutMs) {
1068
+ try {
1069
+ let status = '';
1070
+ await new Promise((resolve) => {
1071
+ const child = spawn('docker', ['inspect', '-f', '{{.State.Running}}', name], { shell: false, windowsHide: true });
1072
+ child.stdout.on('data', (d) => { status += String(d); });
1073
+ child.on('close', () => resolve());
1074
+ child.on('error', () => resolve());
1075
+ });
1076
+ if (status.trim() === 'true') return true;
1077
+ } catch {}
1078
+ await new Promise((r) => setTimeout(r, 1000));
1079
+ }
1080
+ return false;
1081
+ }
1082
+
1083
+ async function waitForGatewayZaloReady(botContainer, projectDir, timeoutMs = 90000) {
1084
+ const started = Date.now();
1085
+ // Use dynamic port from env: OPENCLAW_GATEWAY_PORT → OPENCLAW_PORT → fallback 18789
1086
+ const healthScript = 'const http=require("http");const port=process.env.OPENCLAW_GATEWAY_PORT||process.env.OPENCLAW_PORT||18789;const r=http.get("http://127.0.0.1:"+port+"/health",{timeout:2000},(res)=>{let d="";res.on("data",c=>d+=c);res.on("end",()=>{try{const j=JSON.parse(d);process.stdout.write(j.ok?"READY":"WAIT")}catch{process.stdout.write("WAIT")}})});r.on("error",()=>process.stdout.write("WAIT"));r.on("timeout",()=>{r.destroy();process.stdout.write("WAIT")})';
1087
+ await waitForDockerContainer(botContainer, 15000);
1088
+ let ready = false;
1089
+ let attempts = 0;
1090
+ while (Date.now() - started < timeoutMs) {
1091
+ attempts++;
1092
+ try {
1093
+ const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', healthScript], { cwd: projectDir, shell: false });
1094
+ const status = String(out.stdout || '').trim();
1095
+ if (status === 'READY') {
1096
+ const pluginCheck = await runCapture('docker', ['exec', botContainer, 'sh', '-c', 'openclaw channels status 2>&1 || true'], { cwd: projectDir, shell: false });
1097
+ const output = (pluginCheck.stdout || '') + ' ' + (pluginCheck.stderr || '');
1098
+ if (output.toLowerCase().includes('zalouser') || output.toLowerCase().includes('zalo personal')) {
1099
+ ready = true;
1100
+ break;
1101
+ }
1102
+ if (attempts > 2) sendLog('[zalouser] Gateway healthy but zalouser not yet loaded (' + Math.round((Date.now() - started) / 1000) + 's)...');
1103
+ } else {
1104
+ if (attempts > 2 && attempts % 3 === 0) sendLog('[zalouser] Waiting for gateway... (' + Math.round((Date.now() - started) / 1000) + 's)');
1105
+ }
1106
+ } catch {}
1107
+ await new Promise((r) => setTimeout(r, 5000));
1108
+ }
1109
+ if (!ready) {
1110
+ sendLog('[zalouser] Gateway readiness timeout after ' + Math.round(timeoutMs / 1000) + 's — proceeding anyway.');
1111
+ }
1112
+ return ready;
1113
+ }
1114
+
1115
+ async function startZaloUserLogin(projectDir, mode = state.mode) {
1116
+ const qrPaths = ['/tmp/openclaw/openclaw-zalouser-qr.png', '/tmp/openclaw/openclaw-zalouser-qr-default.png'];
1117
+ if (zaloLoginInFlight) {
1118
+ setImmediate(async () => {
1119
+ try {
1120
+ const botContainer = getBotContainerName(projectDir);
1121
+ const js = `const fs=require('fs');const ps=${JSON.stringify(qrPaths)};for(const p of ps){try{if(fs.existsSync(p)&&fs.statSync(p).size>100){process.stdout.write(fs.readFileSync(p).toString('base64'));break;}}catch{}}`;
1122
+ const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', js], { cwd: projectDir, shell: false });
1123
+ const b64 = extractCompletePngBase64(out.stdout);
1124
+ if (b64.length > 100) {
1125
+ sendLog(`[zalouser:qr] data:image/png;base64,${b64}`);
1126
+ sendLog('[zalouser] Found running login session. Displaying current QR code.');
1127
+ }
1128
+ } catch {}
1129
+ });
1130
+ return { message: 'Zalo login is already running. Keep this modal open...' };
1131
+ }
1132
+ zaloLoginInFlight = true;
1133
+ sendLog('[zalouser] Preparing login. QR will be generated for the UI modal.');
1134
+ const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
1135
+ if ((mode === 'docker' || existsSync(composeFile)) && existsSync(composeFile)) {
1136
+ const botContainer = getBotContainerName(projectDir);
1137
+ // Verify if zalouser is properly registered in installs.json with channels array.
1138
+ // npm install --prefix misses this, which causes error:not configured.
1139
+ const checkRegistryScript = `
1140
+ const fs = require('fs');
1141
+ try {
1142
+ const dist = '/root/project/.openclaw/npm/node_modules/@openclaw/zalouser/dist/index.js';
1143
+ const inst = '/root/project/.openclaw/plugins/installs.json';
1144
+ if (!fs.existsSync(dist)) { console.log('MISSING'); process.exit(0); }
1145
+ if (!fs.existsSync(inst)) { console.log('MISSING_CHANNELS'); process.exit(0); }
1146
+ const j = JSON.parse(fs.readFileSync(inst, 'utf8'));
1147
+ const z = j.plugins.find(x => x.pluginId === 'zalouser');
1148
+ if (z && z.channels && z.channels.includes('zalouser')) {
1149
+ console.log('OK');
1150
+ } else {
1151
+ console.log('MISSING_CHANNELS');
1152
+ }
1153
+ } catch(e) { console.log('MISSING'); }
1154
+ `;
1155
+ const checkInstall = await runCapture('docker', ['exec', botContainer, 'node', '-e', checkRegistryScript], { cwd: projectDir, shell: false }).catch(() => ({ stdout: 'MISSING' }));
1156
+ const status = String(checkInstall.stdout || '').trim();
1157
+ if (status !== 'OK') {
1158
+ sendLog(status === 'MISSING' ? '[zalouser] Plugin not found — installing @openclaw/zalouser...' : '[zalouser] Plugin registry missing channels array — fixing install via CLI...');
1159
+
1160
+ const fixScript = `
1161
+ const fs=require('fs');
1162
+ const cp=require('child_process');
1163
+ const cfg='/root/project/.openclaw/openclaw.json';
1164
+ const bk='/root/project/.openclaw/openclaw.json.zalo-backup';
1165
+ try{if(fs.existsSync(cfg))fs.copyFileSync(cfg,bk);}catch(e){}
1166
+ // Detect gateway version and pin zalouser plugin to match, preventing createSetupTranslator mismatch
1167
+ let gatewayVer='';
1168
+ try{gatewayVer=cp.execSync('openclaw --version 2>/dev/null',{encoding:'utf8'}).trim().replace(/[^0-9.]/g,'');}catch(e){}
1169
+ const pluginSpec=gatewayVer ? '@openclaw/zalouser@'+gatewayVer : '@openclaw/zalouser';
1170
+ console.log('Installing plugin via CLI: '+pluginSpec+'...');
1171
+ try{cp.execSync('cd /root/project && openclaw plugins install '+pluginSpec+' --force',{stdio:'inherit'});}catch(e){
1172
+ // Fallback: try without version pin if exact version not found on registry
1173
+ if(gatewayVer){console.log('Pinned version failed, trying latest...');try{cp.execSync('cd /root/project && openclaw plugins install @openclaw/zalouser --force',{stdio:'inherit'});}catch(e2){console.error('Install failed');}}
1174
+ else{console.error('Install failed');}
1175
+ }
1176
+ try{
1177
+ if(fs.existsSync(bk)){
1178
+ const b=JSON.parse(fs.readFileSync(bk,'utf8'));
1179
+ const c=JSON.parse(fs.readFileSync(cfg,'utf8'));
1180
+ const keys=['agents','channels','bindings','gateway','models'];
1181
+ for(const k of keys){if(b[k])c[k]=b[k];}
1182
+ if(b.plugins){
1183
+ if(b.plugins.allow)c.plugins={...c.plugins,allow:b.plugins.allow};
1184
+ if(b.plugins.deny)c.plugins={...c.plugins,deny:b.plugins.deny};
1185
+ if(b.plugins.entries)c.plugins={...c.plugins,entries:b.plugins.entries};
1186
+ }
1187
+ if(!c.plugins)c.plugins={};if(!c.plugins.entries)c.plugins.entries={};
1188
+ if(!c.plugins.entries.zalouser)c.plugins.entries.zalouser={};
1189
+ c.plugins.entries.zalouser.enabled=true;
1190
+ fs.writeFileSync(cfg,JSON.stringify(c,null,2)+'\\n','utf8');
1191
+ fs.unlinkSync(bk);
1192
+ console.log('Config protected and restored.');
1193
+ }
1194
+ }catch(e){}
1195
+ try{
1196
+ console.log('Patching zalouser stability settings...');
1197
+ cp.execSync('ZALO_JS=$(find "/root/project/.openclaw" -path "*/zalouser/dist/zalo-js*.js" -type f 2>/dev/null | head -1); if [ -n "$ZALO_JS" ]; then sed -i "s/LISTENER_WATCHDOG_MAX_GAP_MS = 35e3/LISTENER_WATCHDOG_MAX_GAP_MS = 120e3/g" "$ZALO_JS"; echo "Patched watchdog gap to 120s"; fi', {shell:true,stdio:'inherit'});
1198
+ }catch(e){}
1199
+ try{
1200
+ const ep = '/root/project/docker/openclaw/entrypoint.sh';
1201
+ if (fs.existsSync(ep)) {
1202
+ let content = fs.readFileSync(ep, 'utf8');
1203
+ if (!content.includes('zalo-monitor')) {
1204
+ const monitor = "\\n# Zalo channel auto-restart monitor (background)\\n(\\n sleep 180\\n while true; do\\n sleep 60\\n STATUS=$(openclaw channels status 2>/dev/null | grep -i \\"Zalo Personal\\" || true)\\n if echo \\"$STATUS\\" | grep -qi \\"stopped\\"; then\\n echo \\"[zalo-monitor] Zalo channel stopped - restarting container in 5s\\"\\n sleep 5\\n kill 1 2>/dev/null || true\\n fi\\n done\\n) &\\n";
1205
+ // Insert before 'openclaw gateway run'
1206
+ const target = 'openclaw gateway run';
1207
+ if (content.includes(target)) {
1208
+ content = content.replace(target, monitor + target);
1209
+ fs.writeFileSync(ep, content, 'utf8');
1210
+ console.log('Added auto-restart monitor to entrypoint.sh');
1211
+ }
1212
+ }
1213
+ }
1214
+ }catch(e){}
1215
+ `;
1216
+ const install = await runCapture('docker', ['exec', botContainer, 'node', '-e', fixScript], { cwd: projectDir, shell: false });
1217
+ for (const line of `${install.stdout}\n${install.stderr}`.split(/\r?\n/).filter(Boolean)) sendLog(line);
1218
+ // Restart the gateway to load the new installs.json channels array so `openclaw channels login` works
1219
+ await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[docker] restart skipped/failed: ${err.message}`));
1220
+ // Wait for gateway to fully reload zalouser plugin after restart (~20s for plugin load + buffer)
1221
+ sendLog('[zalouser] Waiting for gateway to load zalouser plugin...');
1222
+ await waitForGatewayZaloReady(botContainer, projectDir);
1223
+ sendLog('[zalouser] Gateway ready with zalouser plugin.');
1224
+ } else {
1225
+ sendLog('[zalouser] Plugin already properly installed with channels array — skipping install.');
1226
+ // Even when plugin is installed, gateway may still be booting (e.g. after recreateDockerBot)
1227
+ await waitForGatewayZaloReady(botContainer, projectDir);
1228
+ }
1229
+
1230
+ // Clean old credentials & QR files inside container
1231
+ const credPath = '/root/project/.openclaw/credentials/zalouser/credentials.json';
1232
+ await runCapture('docker', ['exec', botContainer, 'sh', '-lc', `rm -f ${credPath} ${qrPaths.join(' ')}`], { cwd: projectDir, shell: false });
1233
+
1234
+ sendLog('[zalouser] Generating Zalo QR. The image will appear automatically.');
1235
+ const loginCmd = 'cd /root/project && openclaw channels login --channel zalouser --verbose';
1236
+
1237
+ // Retry-based login: the zalouser plugin may need time to connect to Zalo servers.
1238
+ // The CLI often exits with "Still preparing QR" on the first attempt.
1239
+ const MAX_LOGIN_ATTEMPTS = 4;
1240
+ const RETRY_DELAYS = [0, 8000, 15000, 20000];
1241
+ let restartAfterLogin = false;
1242
+ let loginAttempt = 0;
1243
+ let sent = false;
1244
+
1245
+ // Start QR file polling in parallel (runs across all retry attempts)
1246
+ let tries = 0;
1247
+ const poll = setInterval(async () => {
1248
+ if (sent || tries++ > 120) {
1249
+ clearInterval(poll);
1250
+ if (!sent) sendLog('[zalouser] QR not found yet. Try closing/reopening login or recreate Zalo User bot.');
1251
+ return;
1252
+ }
1253
+ const js = `const fs=require('fs');const ps=${JSON.stringify(qrPaths)};for(const p of ps){try{if(fs.existsSync(p)&&fs.statSync(p).size>100){process.stdout.write(fs.readFileSync(p).toString('base64'));break;}}catch{}}`;
1254
+ const out = await runCapture('docker', ['exec', botContainer, 'node', '-e', js], { cwd: projectDir, shell: false });
1255
+ const b64 = extractCompletePngBase64(out.stdout);
1256
+ if (b64.length > 100) {
1257
+ sent = true;
1258
+ clearInterval(poll);
1259
+ sendLog(`[zalouser:qr] data:image/png;base64,${b64}`);
1260
+ sendLog('[zalouser] Scan this QR with the Zalo app.');
1261
+ }
1262
+ }, 1000);
1263
+
1264
+ const runLoginAttempt = () => {
1265
+ loginAttempt++;
1266
+ if (loginAttempt > 1) sendLog(`[zalouser] Retry attempt ${loginAttempt}/${MAX_LOGIN_ATTEMPTS}...`);
1267
+ const child = spawn('docker', ['exec', botContainer, 'sh', '-lc', loginCmd], { cwd: projectDir, shell: false, windowsHide: true });
1268
+ const handleLoginLine = (line) => {
1269
+ sendLog(line);
1270
+ if (/login successful|saved auth/i.test(line)) restartAfterLogin = true;
1271
+ };
1272
+ child.stdout.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLoginLine));
1273
+ child.stderr.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLoginLine));
1274
+ child.on('error', (err) => sendLog(`[zalouser] Login process failed: ${err.message}`));
1275
+ child.on('close', async (code) => {
1276
+ sendLog(`[zalouser] Login process exited ${code}`);
1277
+ if (code === 0 || restartAfterLogin || sent) {
1278
+ // Success or QR already found by poll
1279
+ if (restartAfterLogin) {
1280
+ sendLog(`[zalouser] Login saved. Restarting ${botContainer} container so Zalo User can receive messages...`);
1281
+ await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[zalouser] Container restart failed: ${err.message}`));
1282
+ sendLog(`[zalouser] ${botContainer} restarted. Try sending a Zalo message now.`);
1283
+ }
1284
+ zaloLoginInFlight = false;
1285
+ } else if (loginAttempt < MAX_LOGIN_ATTEMPTS && !sent) {
1286
+ // Failed with "Still preparing QR" — retry after delay
1287
+ const delay = RETRY_DELAYS[loginAttempt] || 10000;
1288
+ sendLog(`[zalouser] QR not ready yet. Waiting ${delay / 1000}s before retry...`);
1289
+ setTimeout(runLoginAttempt, delay);
1290
+ } else {
1291
+ sendLog('[zalouser] All login attempts exhausted. Try clicking "Đăng nhập Zalo" again.');
1292
+ zaloLoginInFlight = false;
1293
+ }
1294
+ });
1295
+ };
1296
+
1297
+ runLoginAttempt();
1298
+ return { message: 'Generating Zalo QR. The image will appear automatically.' };
1299
+ }
1300
+ zaloLoginInFlight = false;
1301
+ return { message: 'Native Zalo login UI not implemented yet in local setup.' };
1302
+ }
1303
+
1304
+ function getBotServiceName(projectDir) {
1305
+ const composeFile = join(projectDir || state.projectDir || '', 'docker', 'openclaw', 'docker-compose.yml');
1306
+ if (!existsSync(composeFile)) return 'ai-bot';
1307
+ try {
1308
+ const content = fs.readFileSync(composeFile, 'utf8');
1309
+ const servicesMatch = content.match(/services:\s*\n([\s\S]+?)(?=\n\S|\n$)/);
1310
+ if (servicesMatch) {
1311
+ const servicesText = servicesMatch[1];
1312
+ const keys = Array.from(servicesText.matchAll(/^\s{2}([a-zA-Z0-9_-]+):/gm)).map(m => m[1]);
1313
+ const botService = keys.find(k => k !== '9router');
1314
+ if (botService) return botService;
1315
+ }
1316
+ } catch (e) {}
1317
+ return 'ai-bot';
1318
+ }
1319
+
1320
+ function getBotContainerName(projectDir) {
1321
+ const composeFile = join(projectDir || state.projectDir || '', 'docker', 'openclaw', 'docker-compose.yml');
1322
+ if (!existsSync(composeFile)) return 'openclaw-bot';
1323
+ try {
1324
+ const content = fs.readFileSync(composeFile, 'utf8');
1325
+ const containerMatch = content.match(/container_name:\s*([a-zA-Z0-9_-]+)/);
1326
+ if (containerMatch) return containerMatch[1];
1327
+ } catch (e) {}
1328
+ return 'openclaw-bot';
1329
+ }
1330
+
1331
+ async function syncDockerInfra(projectDir, force = false) {
1332
+ const dockerDir = join(projectDir, 'docker', 'openclaw');
1333
+ if (!existsSync(join(dockerDir, 'docker-compose.yml'))) return false;
1334
+
1335
+ // Check existing entrypoint version stamp
1336
+ const entrypointPath = join(dockerDir, 'entrypoint.sh');
1337
+ const existingEntrypoint = existsSync(entrypointPath)
1338
+ ? await fsp.readFile(entrypointPath, 'utf8').catch(() => '') : '';
1339
+ const existingVersion = (existingEntrypoint.match(/# openclaw-setup v([\d.]+)/) || [])[1] || '0.0.0';
1340
+
1341
+ // Only regenerate if version differs OR force is true
1342
+ if (existingVersion === SETUP_VERSION && !force) return false;
1343
+
1344
+ // Read existing compose to preserve customizations
1345
+ const compose = await readComposeText(projectDir);
1346
+ const botContainer = parseComposeServiceContainerName(compose, 'ai-bot') || `openclaw-${slugify(basename(projectDir))}`;
1347
+ const routerContainer = parseComposeServiceContainerName(compose, '9router') || `9router-${slugify(basename(projectDir))}`;
1348
+ const composeName = (compose.match(/^name:\s*(\S+)/m) || [])[1] || `oc-${slugify(basename(projectDir))}`;
1349
+ const gatewayPort = state.gatewayPort || 18789;
1350
+ const routerPort = state.routerPort || 20128;
1351
+
1352
+ // Detect features from openclaw.json
1353
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
1354
+ let hasZalo = false;
1355
+ try {
1356
+ const cfg = JSON.parse(await fsp.readFile(cfgPath, 'utf8'));
1357
+ hasZalo = !!cfg.channels?.zalouser?.enabled;
1358
+ } catch {}
1359
+
1360
+ // Regenerate with detected settings
1361
+ const docker = buildDockerArtifacts({
1362
+ is9Router: true,
1363
+ openClawNpmSpec: OPENCLAW_NPM_SPEC,
1364
+ openClawRuntimePackages: '',
1365
+ allSkills: [],
1366
+ dockerfilePlugins: [],
1367
+ gatewayPort,
1368
+ routerPort,
1369
+ singleComposeName: composeName,
1370
+ singleAppContainerName: botContainer,
1371
+ singleRouterContainerName: routerContainer,
1372
+ runtimeCommandParts: [
1373
+ hasZalo ? 'ensure_zalouser' : '',
1374
+ 'while true; do sleep 5; openclaw devices approve --latest 2>/dev/null || true; done >/dev/null 2>&1 &',
1375
+ ].filter(Boolean),
1376
+ plainSingleExtraHosts: true,
1377
+ });
1378
+
1379
+ // Inject version stamp into entrypoint
1380
+ let entryScript = docker.entrypointScript || '';
1381
+ entryScript = entryScript.replace('#!/bin/sh', `#!/bin/sh\n# openclaw-setup v${SETUP_VERSION}`);
1382
+
1383
+ // Write updated files preserving env_file path convention
1384
+ const newCompose = String(docker.compose || '')
1385
+ .replace(/env_file:\s*\n\s*-\s*\.env/g, 'env_file:\n - ../../.env')
1386
+ .replace(/env_file:\s*\.env/g, 'env_file: ../../.env');
1387
+
1388
+ sendLog(`[sync] Updating Docker infrastructure files (v${existingVersion} \u2192 v${SETUP_VERSION})`);
1389
+ await fsp.writeFile(join(dockerDir, 'Dockerfile'), docker.dockerfile, 'utf8');
1390
+ await fsp.writeFile(join(dockerDir, 'docker-compose.yml'), newCompose, 'utf8');
1391
+ await fsp.writeFile(entrypointPath, entryScript, 'utf8');
1392
+ if (docker.syncScript) await fsp.writeFile(join(dockerDir, 'sync.js'), docker.syncScript, 'utf8');
1393
+ if (docker.patchScript) await fsp.writeFile(join(dockerDir, 'patch-9router.js'), docker.patchScript, 'utf8');
1394
+
1395
+ sendLog(`[sync] Docker files updated to v${SETUP_VERSION}. Next rebuild will use new infrastructure.`);
1396
+ return true;
1397
+ }
1398
+
1399
+ async function recreateDockerBot(projectDir) {
1400
+ const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
1401
+ if (!existsSync(composeFile)) return false;
1402
+ const depDir = join(projectDir, '.openclaw', 'plugin-runtime-deps');
1403
+ await fsp.mkdir(depDir, { recursive: true }).catch(() => {});
1404
+ const serviceName = getBotServiceName(projectDir);
1405
+ const containerName = getBotContainerName(projectDir);
1406
+ sendLog(`[docker] Recreating ${serviceName} to reload openclaw.json/.env...`);
1407
+ await run('docker', ['compose', '-f', composeFile, 'up', '-d', '--build', '--force-recreate', serviceName], { cwd: projectDir });
1408
+ await waitForDockerContainer(containerName);
1409
+ return true;
1410
+ }
1411
+
1412
+ async function updateRuntime(target, projectDir) {
1413
+ const isRouter = target === '9router';
1414
+ const spec = isRouter ? NINE_ROUTER_NPM_SPEC : OPENCLAW_NPM_SPEC;
1415
+ if (state.mode === 'docker' && projectDir) {
1416
+ const dockerDir = join(projectDir, 'docker', 'openclaw');
1417
+ if (isRouter) {
1418
+ await run('docker', ['compose', 'pull', '9router'], { cwd: dockerDir }).catch(() => {});
1419
+ await run('docker', ['compose', 'up', '-d', '--force-recreate', '9router'], { cwd: dockerDir });
1420
+ } else {
1421
+ // Ensure Docker files are current before rebuilding
1422
+ await syncDockerInfra(projectDir).catch(() => {});
1423
+ const serviceName = getBotServiceName(projectDir);
1424
+ const containerName = getBotContainerName(projectDir);
1425
+ await run('docker', ['compose', 'build', '--no-cache', serviceName], { cwd: dockerDir });
1426
+ await run('docker', ['compose', 'up', '-d', '--force-recreate', serviceName], { cwd: dockerDir });
1427
+ }
1428
+ await syncRuntimeState(projectDir).catch(() => {});
1429
+ return { ok: true, target, spec, mode: 'docker' };
1430
+ }
1431
+ await run('npm', ['install', '-g', spec]);
1432
+ if (isRouter) {
1433
+ await run('openclaw', ['gateway', 'stop'], { cwd: projectDir }).catch(() => {});
1434
+ await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
1435
+ } else {
1436
+ await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
1437
+ }
1438
+ await syncRuntimeState(projectDir).catch(() => {});
1439
+ return { ok: true, target, spec, mode: 'native' };
1440
+ }
1441
+
1442
+ async function restartDockerBotContainer(projectDir = state.projectDir) {
1443
+ const containerName = getBotContainerName(projectDir);
1444
+ sendLog(`[docker] Restarting ${containerName} container...`);
1445
+ await run('docker', ['restart', containerName], { shell: false });
1446
+ await waitForDockerContainer(containerName);
1447
+ return true;
1448
+ }
1449
+
1450
+ async function readJson(req) {
1451
+ const chunks = [];
1452
+ for await (const chunk of req) chunks.push(chunk);
1453
+ if (!chunks.length) return {};
1454
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
1455
+ }
1456
+
1457
+ function json(res, data, status = 200) {
1458
+ const body = JSON.stringify(data, null, 2);
1459
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
1460
+ res.end(body);
1461
+ }
1462
+
1463
+ async function writeCoreProject({ projectDir, osChoice, mode, gatewayPort = 18789, routerPort = 20128 }) {
1464
+ await fsp.mkdir(projectDir, { recursive: true });
1465
+ const openclawHome = join(projectDir, '.openclaw');
1466
+ await fsp.mkdir(openclawHome, { recursive: true });
1467
+ await fsp.mkdir(join(openclawHome, 'plugin-runtime-deps'), { recursive: true });
1468
+
1469
+ const selectedSkills = [];
1470
+ const botName = 'OpenClaw Bot';
1471
+ const agentMetas = [{ agentId: 'bot', name: botName, description: 'Personal OpenClaw assistant' }];
1472
+ const common = { botName, channelKey: 'telegram', providerKey: '9router', model: DEFAULT_MODEL, deployMode: mode, osChoice, selectedSkills, skills: dataExport.SKILLS || [], agentMetas, gatewayPort, routerPort };
1473
+ const cfg = buildOpenclawJson(common);
1474
+ const env = buildEnvFileContent({ ...common, apiKey: '', botToken: '' });
1475
+ const approvals = buildExecApprovalsJson({ agentMetas });
1476
+
1477
+ await fsp.writeFile(join(openclawHome, 'openclaw.json'), JSON.stringify(cfg, null, 2), 'utf8');
1478
+ await fsp.writeFile(join(projectDir, '.env'), env, 'utf8');
1479
+ await fsp.writeFile(join(openclawHome, 'exec-approvals.json'), JSON.stringify(approvals, null, 2), 'utf8');
1480
+
1481
+ const workspaceDir = 'workspace-bot';
1482
+ const workspace = buildWorkspaceFileMap({
1483
+ isVi: true,
1484
+ botName,
1485
+ channelKey: 'telegram',
1486
+ providerKey: '9router',
1487
+ selectedSkills,
1488
+ skillsCatalog: dataExport.SKILLS || [],
1489
+ agentMetas,
1490
+ deployMode: mode,
1491
+ osChoice,
1492
+ agentWorkspaceDir: workspaceDir,
1493
+ workspacePath: `.openclaw/${workspaceDir}`,
1494
+ });
1495
+ const wsRoot = join(openclawHome, workspaceDir);
1496
+ for (const [name, content] of Object.entries(workspace)) {
1497
+ await fsp.mkdir(dirname(join(wsRoot, name)), { recursive: true });
1498
+ await fsp.writeFile(join(wsRoot, name), content || '', 'utf8');
1499
+ }
1500
+
1501
+ if (mode === 'docker') {
1502
+ const projectName = slugify(basename(projectDir)) || 'bot';
1503
+ const docker = buildDockerArtifacts({
1504
+ is9Router: true,
1505
+ osChoice,
1506
+ openClawNpmSpec: OPENCLAW_NPM_SPEC,
1507
+ allSkills: [],
1508
+ dockerfilePlugins: [],
1509
+ gatewayPort,
1510
+ routerPort,
1511
+ singleComposeName: `oc-${projectName}`,
1512
+ singleAppContainerName: `openclaw-${projectName}`,
1513
+ singleRouterContainerName: `9router-${projectName}`,
1514
+ });
1515
+ const dockerDir = join(projectDir, 'docker', 'openclaw');
1516
+ await fsp.mkdir(dockerDir, { recursive: true });
1517
+ const compose = String(docker.compose || '')
1518
+ .replace(/env_file:\s*\n\s*-\s*\.env/g, 'env_file:\n - ../../.env')
1519
+ .replace(/env_file:\s*\.env/g, 'env_file: ../../.env');
1520
+ sendLog(`[writeCoreProject] Writing docker files to ${dockerDir} (compose ${compose.length} bytes, routerPort=${routerPort})`);
1521
+ await fsp.writeFile(join(dockerDir, 'Dockerfile'), docker.dockerfile, 'utf8');
1522
+ await fsp.writeFile(join(dockerDir, 'docker-compose.yml'), compose, 'utf8');
1523
+ const entryScript = (docker.entrypointScript || docker.entrypoint || '').replace('#!/bin/sh', `#!/bin/sh\n# openclaw-setup v${SETUP_VERSION}`);
1524
+ await fsp.writeFile(join(dockerDir, 'entrypoint.sh'), entryScript, 'utf8');
1525
+ // Write 9router helper scripts as separate files (mounted as volumes)
1526
+ if (docker.syncScript) await fsp.writeFile(join(dockerDir, 'sync.js'), docker.syncScript, 'utf8');
1527
+ if (docker.patchScript) await fsp.writeFile(join(dockerDir, 'patch-9router.js'), docker.patchScript, 'utf8');
1528
+ // docker-compose.yml uses env_file: .env relative to docker/openclaw.
1529
+ await fsp.writeFile(join(dockerDir, '.env'), env, 'utf8');
1530
+ }
1531
+ }
1532
+
1533
+ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, routerPort = 20128 }) {
1534
+ state.installing = true;
1535
+ state.installed = false;
1536
+ state.lastError = null;
1537
+ state.projectDir = projectDir;
1538
+ state.mode = mode;
1539
+ state.os = osChoice;
1540
+ state.startedAt = new Date().toISOString();
1541
+ try {
1542
+ sendLog('OpenClaw local installer started');
1543
+ sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
1544
+ await writeCoreProject({ projectDir, osChoice, mode, gatewayPort, routerPort });
1545
+ await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
1546
+ await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
1547
+ if (mode === 'docker') {
1548
+ const dockerDir = join(projectDir, 'docker', 'openclaw');
1549
+ const rootEnvPath = join(projectDir, '.env');
1550
+ const dockerEnvPath = join(dockerDir, '.env');
1551
+ await fsp.mkdir(dockerDir, { recursive: true });
1552
+ const envContent = existsSync(rootEnvPath)
1553
+ ? await fsp.readFile(rootEnvPath, 'utf8')
1554
+ : buildEnvFileContent({ botName: 'OpenClaw Bot', channelKey: 'telegram', providerKey: '9router', deployMode: mode, osChoice, selectedSkills: [], skills: dataExport.SKILLS || [], agentMetas: [{ agentId: 'bot', name: 'OpenClaw Bot', description: 'Personal OpenClaw assistant' }], apiKey: '', botToken: '' });
1555
+ await fsp.writeFile(dockerEnvPath, envContent, 'utf8');
1556
+ sendLog(`Docker env ready: ${dockerEnvPath}`);
1557
+ await run('docker', ['compose', 'up', '-d', '--build'], { cwd: dockerDir });
1558
+ await applyResolved9RouterApiKey(projectDir).catch(() => {});
1559
+ await recreateDockerBot(projectDir).catch(() => {});
1560
+ } else {
1561
+ const runtimeEnv = {
1562
+ OPENCLAW_HOME: join(projectDir, '.openclaw'),
1563
+ OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
1564
+ DATA_DIR: join(projectDir, '.9router'),
1565
+ OPENCLAW_GATEWAY_PORT: String(gatewayPort),
1566
+ OPENCLAW_PORT: String(gatewayPort),
1567
+ };
1568
+ await run('openclaw', ['gateway', 'stop'], { cwd: projectDir, env: runtimeEnv }).catch(() => {});
1569
+ startDetached('9router', ['-n', '-l', '-H', '127.0.0.1', '-p', String(routerPort), '--skip-update'], { cwd: projectDir, env: runtimeEnv });
1570
+ state.botPid = startDetached('openclaw', ['gateway', 'run'], { cwd: projectDir, env: runtimeEnv });
1571
+ sendLog(`Native gateway started in background (pid=${state.botPid || 'unknown'})`);
1572
+ }
1573
+ state.installed = true;
1574
+ sendLog('✅ Install completed');
1575
+ sendLog(`Gateway: ${state.gatewayUrl}`);
1576
+ sendLog(`9Router: http://127.0.0.1:${state.routerPort || routerPort}`);
1577
+ } catch (err) {
1578
+ state.lastError = err.message;
1579
+ sendLog(`ERROR: ${err.message}`);
1580
+ throw err;
1581
+ } finally {
1582
+ state.installing = false;
1583
+ }
1584
+ }
1585
+
1586
+ async function listMarkdownFiles(projectDir, agentId = '') {
1587
+ const out = [];
1588
+ const home = join(projectDir, '.openclaw');
1589
+ const cfgPath = join(home, 'openclaw.json');
1590
+ const cfg = existsSync(cfgPath) ? ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8'))) : null;
1591
+ const agent = agentId && cfg ? cfg.agents.list.find((a) => a.id === agentId) : null;
1592
+ const workspaceDirs = agent
1593
+ ? [workspaceRelForAgent(agent, cfg, projectDir)]
1594
+ : [];
1595
+ if (agentId && !agent) throw httpError(404, 'Bot not found');
1596
+ const textExt = new Set(['.md', '.txt', '.json', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.yml', '.yaml', '.env', '.sh', '.bat', '.ps1', '.html', '.css']);
1597
+ async function walk(absDir, relDir = '', depth = 0) {
1598
+ if (depth > 8) return;
1599
+ const entries = await fsp.readdir(absDir, { withFileTypes: true }).catch(() => []);
1600
+ for (const e of entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))) {
1601
+ if (e.name === 'node_modules' || e.name === '.git' || e.name === 'plugin-runtime-deps') continue;
1602
+ const abs = join(absDir, e.name);
1603
+ const rel = relDir ? `${relDir}/${e.name}` : e.name;
1604
+ const relProject = rel.replace(/\\/g, '/');
1605
+ if (e.isDirectory()) {
1606
+ out.push({ name: relProject, path: relProject, type: 'dir' });
1607
+ await walk(abs, relProject, depth + 1);
1608
+ continue;
1609
+ }
1610
+ const st = await fsp.stat(abs).catch(() => null);
1611
+ const ext = extname(e.name).toLowerCase() || (e.name === '.env' ? '.env' : '');
1612
+ const isText = !!st && st.size <= 1024 * 1024 && (textExt.has(ext) || !ext);
1613
+ out.push({ name: relProject, path: relProject, type: 'file', content: isText ? await fsp.readFile(abs, 'utf8').catch(() => '') : '', editable: isText });
1614
+ }
1615
+ }
1616
+ if (existsSync(home)) {
1617
+ const dirs = workspaceDirs.length
1618
+ ? workspaceDirs
1619
+ : (await fsp.readdir(home, { withFileTypes: true }).catch(() => [])).filter((d) => d.isDirectory() && (d.name === 'workspace' || d.name.startsWith('workspace-'))).map((d) => d.name);
1620
+ for (const dir of dirs) {
1621
+ const abs = join(home, dir);
1622
+ if (existsSync(abs)) await walk(abs, `.openclaw/${dir}`);
1623
+ }
1624
+ // Also include project-level config files from .openclaw root
1625
+ const rootEntries = await fsp.readdir(home, { withFileTypes: true }).catch(() => []);
1626
+ const extraDirs = new Set(['extensions', 'plugins', 'agents', 'credentials']);
1627
+ for (const e of rootEntries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))) {
1628
+ if (e.isDirectory()) {
1629
+ // Walk extensions/ and agents/ directories so plugins show up in the tree
1630
+ if (extraDirs.has(e.name)) {
1631
+ const abs = join(home, e.name);
1632
+ await walk(abs, `.openclaw/${e.name}`);
1633
+ }
1634
+ continue;
1635
+ }
1636
+ const abs = join(home, e.name);
1637
+ const rel = `.openclaw/${e.name}`;
1638
+ const ext = extname(e.name).toLowerCase();
1639
+ const isText = textExt.has(ext);
1640
+ if (!isText) continue;
1641
+ const st = await fsp.stat(abs).catch(() => null);
1642
+ if (!st || st.size > 1024 * 1024) continue;
1643
+ out.push({ name: rel, path: rel, type: 'file', content: await fsp.readFile(abs, 'utf8').catch(() => ''), editable: true });
1644
+ }
1645
+ }
1646
+ return out;
1647
+ }
1648
+
1649
+ async function saveState(rootProjectDir) {
1650
+ const file = join(rootProjectDir, STATE_FILE);
1651
+ await fsp.writeFile(file, JSON.stringify({
1652
+ projectDir: state.projectDir,
1653
+ mode: state.mode,
1654
+ os: state.os,
1655
+ installed: state.installed,
1656
+ gatewayUrl: state.gatewayUrl,
1657
+ gatewayPort: state.gatewayPort,
1658
+ routerUrl: state.routerUrl,
1659
+ routerPort: state.routerPort,
1660
+ }, null, 2), 'utf8').catch(() => {});
1661
+ }
1662
+
1663
+ async function loadSavedState(rootProjectDir) {
1664
+ const file = join(rootProjectDir, STATE_FILE);
1665
+ if (!existsSync(file)) return;
1666
+ const saved = JSON.parse(await fsp.readFile(file, 'utf8'));
1667
+ if (saved?.projectDir && existsSync(join(saved.projectDir, '.openclaw', 'openclaw.json'))) {
1668
+ Object.assign(state, saved, { installed: !!saved.installed });
1669
+ await syncRuntimeState(state.projectDir);
1670
+ }
1671
+ }
1672
+
1673
+ async function findLatestProject(rootProjectDir) {
1674
+ const roots = [
1675
+ process.env.OPENCLAW_PROJECT_DIR,
1676
+ process.env.OPENCLAW_HOME ? dirname(process.env.OPENCLAW_HOME) : '',
1677
+ rootProjectDir,
1678
+ join(rootProjectDir, DEFAULT_PROJECT_NAME),
1679
+ dirname(rootProjectDir),
1680
+ os.homedir(),
1681
+ 'D:\\tmp',
1682
+ ];
1683
+ for (const drive of ['D:\\', 'E:\\']) {
1684
+ const entries = await fsp.readdir(drive, { withFileTypes: true }).catch(() => []);
1685
+ for (const e of entries) if (e.isDirectory() && !e.name.startsWith('$')) roots.push(join(drive, e.name));
1686
+ }
1687
+ const candidates = [];
1688
+ async function walk(dir, depth = 0) {
1689
+ if (!dir || depth > 2 || !existsSync(dir)) return;
1690
+ if (existsSync(join(dir, '.openclaw', 'openclaw.json'))) {
1691
+ const st = await fsp.stat(join(dir, '.openclaw', 'openclaw.json')).catch(() => null);
1692
+ if (st) candidates.push({ dir, mtimeMs: st.mtimeMs });
1693
+ return;
1694
+ }
1695
+ const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
1696
+ for (const e of entries) if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') await walk(join(dir, e.name), depth + 1);
1697
+ }
1698
+ for (const r of roots) await walk(r);
1699
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
1700
+ return candidates[0]?.dir || null;
1701
+ }
1702
+
1703
+ async function discoverProjects(rootProjectDir) {
1704
+ const roots = [
1705
+ process.env.OPENCLAW_PROJECT_DIR,
1706
+ rootProjectDir,
1707
+ dirname(rootProjectDir),
1708
+ process.env.OPENCLAW_HOME ? dirname(process.env.OPENCLAW_HOME) : '',
1709
+ 'D:\\tmp',
1710
+ 'D:\\',
1711
+ 'E:\\',
1712
+ ];
1713
+ const seen = new Set();
1714
+ const hits = [];
1715
+ async function walk(dir, depth = 0) {
1716
+ if (!dir || depth > 2 || !existsSync(dir)) return;
1717
+ const full = resolve(dir);
1718
+ if (full === resolve(os.homedir())) return;
1719
+ if (seen.has(full)) return;
1720
+ seen.add(full);
1721
+ const cfgPath = join(full, '.openclaw', 'openclaw.json');
1722
+ if (existsSync(cfgPath)) {
1723
+ const st = await fsp.stat(cfgPath).catch(() => null);
1724
+ const runtime = await detectRuntime(full).catch(() => ({ mode: 'unknown', gatewayPort: 0, routerPort: 0, syncSource: 'config' }));
1725
+ const bots = await listConfiguredBots(full).catch(() => []);
1726
+ const uniqueBotCount = new Set(bots.map((b) => b.id)).size;
1727
+ const hasDocker = existsSync(join(full, 'docker', 'openclaw', 'docker-compose.yml'));
1728
+ const isLikelyProject = uniqueBotCount > 0 || hasDocker || existsSync(join(full, '.env')) || existsSync(join(full, 'package.json'));
1729
+ if (!isLikelyProject) return;
1730
+ hits.push({
1731
+ projectDir: full,
1732
+ os: process.platform === 'win32' ? 'Windows' : process.platform === 'darwin' ? 'macOS' : 'Linux',
1733
+ mode: runtime.mode || 'unknown',
1734
+ gatewayPort: runtime.gatewayPort || 0,
1735
+ routerPort: runtime.routerPort || 0,
1736
+ syncSource: runtime.syncSource || 'config',
1737
+ botCount: uniqueBotCount,
1738
+ hasDocker,
1739
+ updatedAt: st?.mtimeMs || 0,
1740
+ });
1741
+ return;
1742
+ }
1743
+ const entries = await fsp.readdir(full, { withFileTypes: true }).catch(() => []);
1744
+ for (const e of entries) {
1745
+ if (!e.isDirectory()) continue;
1746
+ if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;
1747
+ await walk(join(full, e.name), depth + 1);
1748
+ }
1749
+ }
1750
+ for (const root of roots) await walk(root);
1751
+ hits.sort((a, b) =>
1752
+ (b.botCount - a.botCount) ||
1753
+ (Number(b.hasDocker) - Number(a.hasDocker)) ||
1754
+ (b.updatedAt - a.updatedAt)
1755
+ );
1756
+ return hits.slice(0, 20);
1757
+ }
1758
+
1759
+ async function resolveProjectDir(rootProjectDir, body = {}) {
1760
+ if (body.projectDir && existsSync(join(resolve(String(body.projectDir)), '.openclaw', 'openclaw.json'))) {
1761
+ state.projectDir = resolve(String(body.projectDir));
1762
+ await syncRuntimeState(state.projectDir);
1763
+ return state.projectDir;
1764
+ }
1765
+ const envProjectDir = process.env.OPENCLAW_PROJECT_DIR || (process.env.OPENCLAW_HOME ? dirname(process.env.OPENCLAW_HOME) : '');
1766
+ if (envProjectDir && existsSync(join(resolve(String(envProjectDir)), '.openclaw', 'openclaw.json'))) {
1767
+ state.projectDir = resolve(String(envProjectDir));
1768
+ await syncRuntimeState(state.projectDir);
1769
+ return state.projectDir;
1770
+ }
1771
+ if (state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json'))) {
1772
+ await syncRuntimeState(state.projectDir);
1773
+ return state.projectDir;
1774
+ }
1775
+ await loadSavedState(rootProjectDir);
1776
+ if (state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json'))) {
1777
+ await syncRuntimeState(state.projectDir);
1778
+ return state.projectDir;
1779
+ }
1780
+ const found = await findLatestProject(rootProjectDir);
1781
+ if (found) {
1782
+ await syncRuntimeState(found);
1783
+ await saveState(rootProjectDir);
1784
+ }
1785
+ return state.projectDir;
1786
+ }
1787
+
1788
+ async function connectExistingProject(projectDir, rootProjectDir) {
1789
+ const resolved = resolve(String(projectDir || ''));
1790
+ if (!existsSync(join(resolved, '.openclaw', 'openclaw.json'))) throw httpError(404, 'openclaw.json not found in selected project');
1791
+ await syncRuntimeState(resolved);
1792
+ await saveState(rootProjectDir);
1793
+ const bots = await listConfiguredBots(resolved).catch(() => []);
1794
+ return {
1795
+ ok: true,
1796
+ projectDir: resolved,
1797
+ mode: state.mode,
1798
+ syncSource: state.syncSource,
1799
+ gatewayUrl: state.gatewayUrl,
1800
+ gatewayPort: state.gatewayPort,
1801
+ routerUrl: state.routerUrl,
1802
+ routerPort: state.routerPort,
1803
+ bots,
1804
+ };
1805
+ }
1806
+
1807
+ async function connectPickedProject(projectName, rootProjectDir) {
1808
+ const name = String(projectName || '').trim();
1809
+ if (!name) throw httpError(400, 'Missing project name');
1810
+ const projects = await discoverProjects(rootProjectDir).catch(() => []);
1811
+ const matches = projects.filter((p) => basename(resolve(p.projectDir)) === name);
1812
+ if (matches.length === 1) return connectExistingProject(matches[0].projectDir, rootProjectDir);
1813
+ if (matches.length > 1) {
1814
+ throw httpError(409, `Multiple projects named "${name}" found; use a detected project card or type the path manually`);
1815
+ }
1816
+ throw httpError(404, `No detected project named "${name}"`);
1817
+ }
1818
+
1819
+ async function deleteProjectFolder(projectDir, rootProjectDir) {
1820
+ const resolved = resolve(String(projectDir || ''));
1821
+ const home = resolve(os.homedir());
1822
+ if (!existsSync(join(resolved, '.openclaw', 'openclaw.json'))) throw httpError(404, 'openclaw.json not found in selected project');
1823
+ if (resolved === home || /^[A-Za-z]:\\?$/.test(resolved)) throw httpError(403, 'Refusing to delete home/root folder');
1824
+ const projects = await discoverProjects(rootProjectDir).catch(() => []);
1825
+ const meta = projects.find((p) => resolve(p.projectDir) === resolved);
1826
+ if (!meta || !meta.botCount) throw httpError(403, 'Refusing to delete a folder that is not a detected bot project');
1827
+ // Stop and remove Docker containers first to release host folder locks
1828
+ const dockerComposeDir = join(resolved, 'docker', 'openclaw');
1829
+ if (existsSync(join(dockerComposeDir, 'docker-compose.yml'))) {
1830
+ sendLog(`[docker] Stopping and removing containers and volumes for ${resolved} to release file locks...`);
1831
+ await run('docker', ['compose', 'down', '-v'], { cwd: dockerComposeDir }).catch((err) => {
1832
+ sendLog(`[docker] Warning: Failed to stop compose containers: ${err.message}`);
1833
+ });
1834
+ // Sleep 2.5 seconds to let Windows file system release overlays/locks
1835
+ await new Promise((resolve) => setTimeout(resolve, 2500));
1836
+ }
1837
+
1838
+ try {
1839
+ await fsp.rm(resolved, { recursive: true, force: true });
1840
+ } catch (err) {
1841
+ throw httpError(500, `Không thể xóa thư mục ${resolved}. Lý do: ${err.message}. (Gợi ý: Thư mục này có thể đang bị khóa bởi tiến trình khác, ví dụ như VS Code, Command Prompt/PowerShell đang cd vào thư mục, hoặc Docker chưa kịp tháo dỡ hoàn toàn. Vui lòng đóng tất cả các file/đóng terminal đang mở tại thư mục này, tắt Docker Desktop nếu cần, và bấm Xóa lại nhé!)`);
1842
+ }
1843
+ if (state.projectDir && resolve(state.projectDir) === resolved) {
1844
+ state.projectDir = null;
1845
+ state.installed = false;
1846
+ }
1847
+ await saveState(rootProjectDir);
1848
+ return { ok: true, projectDir: resolved };
1849
+ }
1850
+
1851
+ async function pickProjectFolder() {
1852
+ if (process.platform !== 'win32') throw httpError(501, 'Folder picker currently supported on Windows only');
1853
+ const script = `
1854
+ Add-Type -AssemblyName System.Windows.Forms
1855
+ $dlg = New-Object System.Windows.Forms.FolderBrowserDialog
1856
+ $dlg.Description = "Select an OpenClaw project folder"
1857
+ $dlg.ShowNewFolderButton = $true
1858
+ if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
1859
+ Write-Output $dlg.SelectedPath
1860
+ }
1861
+ `;
1862
+ const out = await runCapture('powershell', ['-NoProfile', '-STA', '-Command', script], { shell: false, windowsHide: false, timeout: 120000 });
1863
+ const projectDir = String(out.stdout || '').trim();
1864
+ if (!projectDir) throw httpError(400, 'No folder selected');
1865
+ return { ok: true, projectDir };
1866
+ }
1867
+
1868
+ function upsertManagedBlock(text = '', key = '', content = '') {
1869
+ const start = `<!-- OPENCLAW:${key}:START -->`;
1870
+ const end = `<!-- OPENCLAW:${key}:END -->`;
1871
+ const block = `${start}\n${content}\n${end}`;
1872
+ const re = new RegExp(`${start}[\\s\\S]*?${end}`, 'm');
1873
+ if (re.test(text)) return text.replace(re, block);
1874
+ return `${String(text || '').trimEnd()}\n\n${block}\n`;
1875
+ }
1876
+
1877
+ function removeManagedBlock(text = '', key = '') {
1878
+ const start = `<!-- OPENCLAW:${key}:START -->`;
1879
+ const end = `<!-- OPENCLAW:${key}:END -->`;
1880
+ const re = new RegExp(`\\n?${start}[\\s\\S]*?${end}\\n?`, 'm');
1881
+ return String(text || '').replace(re, '\n').trimEnd() + '\n';
1882
+ }
1883
+ async function readWorkspaceText(projectDir, agent, name) {
1884
+ const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
1885
+ const cfg = existsSync(cfgPath) ? JSON.parse(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}')) : {};
1886
+ const rel = workspaceRelForAgent(agent, cfg, projectDir);
1887
+ const file = join(projectDir, '.openclaw', rel, name);
1888
+ return { file, content: existsSync(file) ? await fsp.readFile(file, 'utf8').catch(() => '') : '' };
1889
+ }
1890
+
1891
+ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
1892
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
1893
+ const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
1894
+ const agent = cfg.agents.list.find((a) => a.id === agentId) || cfg.agents.list[0];
1895
+ if (!agent) throw httpError(404, 'Bot not found');
1896
+
1897
+ const k = `${kind}:${id}`;
1898
+
1899
+ if (kind === 'skill' && id === 'browser') {
1900
+ if (enabled) {
1901
+ cfg.browser = {
1902
+ enabled: true,
1903
+ defaultProfile: 'host-chrome',
1904
+ profiles: { 'host-chrome': { cdpUrl: 'http://127.0.0.1:9222', color: '#4285F4' } },
1905
+ };
1906
+ const isHeadlessServer = process.platform === 'linux';
1907
+ const docVariant = 'cli-server';
1908
+
1909
+ for (const a of cfg.agents.list) {
1910
+ const wm = buildWorkspaceFileMap({
1911
+ isVi: true,
1912
+ botName: a.name || a.id,
1913
+ botDesc: '',
1914
+ hasBrowser: false,
1915
+ hasScheduler: true,
1916
+ workspacePath: `.openclaw/${workspaceRelForAgent(a, cfg, projectDir)}/`,
1917
+ agentWorkspaceDir: workspaceRelForAgent(a, cfg, projectDir),
1918
+ variant: cfg.agents.list.length > 1 ? 'relay' : 'single',
1919
+ browserDocVariant: docVariant,
1920
+ });
1921
+ const browserDoc = wm['BROWSER.md'] || '# BROWSER';
1922
+ const browserTool = wm['browser-tool.js'] || '';
1923
+ const bf = await readWorkspaceText(projectDir, a, 'BROWSER.md');
1924
+ await fsp.writeFile(bf.file, browserDoc, 'utf8');
1925
+ const bt = await readWorkspaceText(projectDir, a, 'browser-tool.js');
1926
+ if (browserTool) await fsp.writeFile(bt.file, browserTool, 'utf8');
1927
+
1928
+ const af = await readWorkspaceText(projectDir, a, 'AGENTS.md');
1929
+ const agentsManaged = upsertManagedBlock(af.content, 'BROWSER_LINK', '- Browser docs: `BROWSER.md`');
1930
+ await fsp.writeFile(af.file, agentsManaged, 'utf8');
1931
+
1932
+ // Add to TOOLS.md
1933
+ const tf = await readWorkspaceText(projectDir, a, 'TOOLS.md');
1934
+ const browserGuide = isHeadlessServer
1935
+ ? `## 🌐 Browser Automation
1936
+ - Xem hướng dẫn chi tiết tại **BROWSER.md**
1937
+ - Script điều khiển: \`browser-tool.js\`
1938
+ - Chế độ hiện tại: Chạy ngầm độc lập qua Docker hoặc Xvfb trên VPS.
1939
+ - **Tìm kiếm Web:** Nếu không có công cụ Web Search (hoặc Web Search không khả dụng/bị lỗi), hãy **luôn sử dụng ngay công cụ terminal (exec/run_command) để chạy lệnh: \`node search-tool.js "<từ khóa>" 5\`**! Lệnh này sẽ tự động chạy ngầm qua DuckDuckGo/Google/Bing bằng trình duyệt ngầm tàng hình của bạn và trả về kết quả JSON sạch ngay lập tức. Tuyệt đối KHÔNG được mở trình duyệt thủ công, chụp ảnh màn hình hay click tìm kiếm bằng tay từng bước!
1940
+ - Nếu browser lỗi, thử lại 1 lần rồi mới báo user với lỗi cụ thể`
1941
+ : `## 🌐 Browser Automation
1942
+ - Xem hướng dẫn chi tiết tại **BROWSER.md**
1943
+ - Script điều khiển: \`browser-tool.js\`
1944
+ - Chế độ hiện tại:
1945
+ - **Mặc định:** Chạy ngầm độc lập qua Docker hoặc Server.
1946
+ - **Chế độ xem Chrome thật:** Chạy file \`start-chrome-debug.bat\` / \`start-chrome-debug.sh\` trên host trước để bot kết nối điều khiển trực quan.
1947
+ - Kết nối mặc định: \`http://127.0.0.1:9222\`
1948
+ - **Tìm kiếm Web:** Nếu không có công cụ Web Search (hoặc Web Search không khả dụng/bị lỗi), hãy **luôn sử dụng ngay công cụ terminal (exec/run_command) để chạy lệnh: \`node search-tool.js "<từ khóa>" 5\`**! Lệnh này sẽ tự động chạy ngầm qua DuckDuckGo/Google/Bing bằng trình duyệt ngầm tàng hình của bạn và trả về kết quả JSON sạch ngay lập tức. Tuyệt đối KHÔNG được mở trình duyệt thủ công, chụp ảnh màn hình hay click tìm kiếm bằng tay từng bước!
1949
+ - Nếu browser lỗi, thử lại 1 lần rồi mới báo user với lỗi cụ thể`;
1950
+ await fsp.writeFile(tf.file, upsertManagedBlock(tf.content, 'BROWSER_GUIDE', browserGuide), 'utf8');
1951
+ }
1952
+ } else {
1953
+ delete cfg.browser;
1954
+ for (const a of cfg.agents.list) {
1955
+ const bf = await readWorkspaceText(projectDir, a, 'BROWSER.md');
1956
+ if (existsSync(bf.file)) await fsp.rm(bf.file, { force: true });
1957
+ const bt = await readWorkspaceText(projectDir, a, 'browser-tool.js');
1958
+ if (existsSync(bt.file)) await fsp.rm(bt.file, { force: true });
1959
+
1960
+ const af = await readWorkspaceText(projectDir, a, 'AGENTS.md');
1961
+ await fsp.writeFile(af.file, removeManagedBlock(af.content, 'BROWSER_LINK'), 'utf8');
1962
+
1963
+ // Remove from TOOLS.md
1964
+ const tf = await readWorkspaceText(projectDir, a, 'TOOLS.md');
1965
+ await fsp.writeFile(tf.file, removeManagedBlock(tf.content, 'BROWSER_GUIDE'), 'utf8');
1966
+ }
1967
+ }
1968
+
1969
+ // Write cfgPath early so syncDockerInfra reads the updated openclaw.json
1970
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
1971
+
1972
+ // Force Docker Infrastructure sync and container recreation
1973
+ const hasDocker = existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'));
1974
+ if (hasDocker) {
1975
+ sendLog(`[docker] Browser skill toggled to ${enabled}. Regenerating Dockerfiles...`);
1976
+ await syncDockerInfra(projectDir, true).catch((err) => sendLog(`[docker] Warning: Failed to sync docker infra: ${err.message}`));
1977
+ sendLog(`[docker] Rebuilding and recreating containers...`);
1978
+ await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] Warning: Failed to recreate container: ${err.message}`));
1979
+ }
1980
+ }
1981
+
1982
+ if (kind === 'skill' && id === 'cron') {
1983
+ if (enabled) {
1984
+ cfg.tools = cfg.tools || { profile: 'full', exec: { host: 'gateway', security: 'full', ask: 'off' } };
1985
+ cfg.tools.alsoAllow = Array.from(new Set([...(cfg.tools.alsoAllow || []), 'group:automation']));
1986
+ cfg.commands = cfg.commands || {};
1987
+ cfg.commands.ownerAllowFrom = Array.from(new Set([...(cfg.commands.ownerAllowFrom || []), '*']));
1988
+ const cronGuide = `## ⏰ Cron / Lên lịch nhắc nhở (tool: \`cron\`)
1989
+ - **Tên tool chính xác:** Tên công cụ là \`cron\` (tuyệt đối không nhầm là \`native\` hay command line bên ngoài).
1990
+ - **Khi tạo cronjob mới (action \`add\`):**
1991
+ - **TUYỆT ĐỐI KHÔNG điền trường \`agentId\`** trong object \`job\` (hãy bỏ qua/omitted trường này). Hệ thống OpenClaw sẽ tự động gán chính xác ID của bạn vào job đó.
1992
+ - Tuyệt đối **không tự điền** \`agentId\` là \`"bot"\` hay \`"main"\`, vì làm vậy sẽ khiến cronjob thuộc về agent khác và bạn sẽ mất quyền kiểm soát/xóa nó sau này.
1993
+ - **Khi user yêu cầu tắt/bật/xóa cronjob:**
1994
+ 1. **Bước 1 (Tìm kiếm):** Gọi tool \`cron\` với action \`list\` (và \`includeDisabled: true\`) để xem danh sách tất cả cronjob đang chạy trên hệ thống và tìm đúng \`jobId\` phù hợp với yêu cầu.
1995
+ 2. **Bước 2 (Xử lý):**
1996
+ - Để xóa: Gọi action \`remove\` với \`id\` tìm được.
1997
+ - Để tắt/tạm dừng: Gọi action \`update\` với \`id\` và patch \`{"enabled": false}\`.
1998
+ - Để bật lại: Gọi action \`update\` với \`id\` và patch \`{"enabled": true}\`.
1999
+ 3. **Tuyên bố trung thực:** Tuyệt đối không bao giờ trả lời "đã xóa" hay "không có" dựa trên suy đoán của bản thân mà chưa gọi tool \`cron\` để kiểm tra thực tế.
2000
+ - Khi user yêu cầu tạo nhắc nhở / lệnh tự động định kỳ, bạn hãy TỰ ĐỘNG dùng tool \`cron\` (action \`add\`) để tạo. **Tuyệt đối không** bắt user dùng crontab hay Task Scheduler chạy tay trên host.
2001
+ - Khi thao tác tool cho cron/scheduler, **không điền \`current\` vào thư mục Session**.
2002
+ - **QUAN TRỌNG VỀ TARGETING GROUP CHAT**: Khi tạo hoặc cấu hình cron job gửi tin nhắn thông báo (announce mode) đến một Group Chat, giá trị của trường \`delivery.to\` **bắt buộc** phải sử dụng tiền tố \`group:\` trước ID của group (ví dụ: \`group:3815464776067464419\` hoặc \`group:xxxx\`). Tuyệt đối không được chỉ điền ID thuần túy vì hệ thống sẽ hiểu nhầm đó là một DM chat cá nhân (direct message) và gửi sai địa chỉ.
2003
+ - Bỏ qua việc tra cứu docs nội bộ như \`cron-jobs.mdx\`; tin tưởng khả năng dùng tool hiện có để hoàn thành yêu cầu.`;
2004
+ for (const a of cfg.agents.list) {
2005
+ const tf = await readWorkspaceText(projectDir, a, 'TOOLS.md');
2006
+ await fsp.writeFile(tf.file, upsertManagedBlock(tf.content, 'CRON_GUIDE', cronGuide), 'utf8');
2007
+ }
2008
+ } else {
2009
+ if (cfg.tools?.alsoAllow) cfg.tools.alsoAllow = cfg.tools.alsoAllow.filter((x) => x !== 'group:automation');
2010
+ if (cfg.commands?.ownerAllowFrom) cfg.commands.ownerAllowFrom = cfg.commands.ownerAllowFrom.filter((x) => x !== '*');
2011
+ for (const a of cfg.agents.list) {
2012
+ const tf = await readWorkspaceText(projectDir, a, 'TOOLS.md');
2013
+ await fsp.writeFile(tf.file, removeManagedBlock(tf.content, 'CRON_GUIDE'), 'utf8');
2014
+ }
2015
+ }
2016
+
2017
+ // Write cfgPath early so recreation reads updated openclaw.json
2018
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
2019
+
2020
+ // Recreate container to apply updated openclaw.json tools/commands rules
2021
+ const hasDocker = existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'));
2022
+ if (hasDocker) {
2023
+ sendLog(`[docker] Cron skill toggled to ${enabled}. Recreating containers...`);
2024
+ await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] Warning: Failed to recreate container: ${err.message}`));
2025
+ }
2026
+ }
2027
+
2028
+ if (kind === 'plugin') {
2029
+ cfg.plugins = cfg.plugins || { entries: {} };
2030
+ cfg.plugins.entries = cfg.plugins.entries || {};
2031
+ const pluginAliasMap = {
2032
+ 'openclaw-browser-automation': ['browser-automation', 'openclaw-browser-automation'],
2033
+ 'openclaw-zalo-mod': ['zalo-mod', 'openclaw-zalo-mod'],
2034
+ 'openclaw-facebook-crawler': ['openclaw-facebook-crawler', 'openclaw-n8n-facebook-crawler', 'n8n-facebook-crawler'],
2035
+ 'openclaw-n8n-facebook-poster': ['openclaw-n8n-facebook-poster', 'openclaw-facebook-poster', 'facebook-poster'],
2036
+ };
2037
+ const aliases = pluginAliasMap[id] || [id];
2038
+ const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2039
+ cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2040
+ cfg.plugins.entries[existingKey].enabled = !!enabled;
2041
+ if (existingKey === 'zalo-mod') {
2042
+ cfg.plugins.entries[existingKey].hooks = cfg.plugins.entries[existingKey].hooks || {};
2043
+ cfg.plugins.entries[existingKey].hooks.allowConversationAccess = true;
2044
+ }
2045
+ // Only add the canonical config key to allow list (not all aliases)
2046
+ cfg.plugins.allow = cfg.plugins.allow || [];
2047
+ if (!cfg.plugins.allow.includes(existingKey)) cfg.plugins.allow.push(existingKey);
2048
+ }
2049
+
2050
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
2051
+ return { ok: true };
2052
+ }
2053
+
2054
+ async function installFeature(projectDir, agentId, kind, id) {
2055
+ if (kind === 'plugin') {
2056
+ let composeDir = null;
2057
+ if (existsSync(join(projectDir, 'docker-compose.yml'))) {
2058
+ composeDir = projectDir;
2059
+ } else if (existsSync(join(projectDir, 'docker', 'openclaw', 'docker-compose.yml'))) {
2060
+ composeDir = join(projectDir, 'docker', 'openclaw');
2061
+ }
2062
+
2063
+ if (composeDir) {
2064
+ const botContainer = getBotContainerName(projectDir);
2065
+ sendLog(`[plugin] Installing clawhub:${id} inside container ${botContainer}...`);
2066
+
2067
+ let installSuccess = true;
2068
+ const cleanCmd = `cd /root/project && (openclaw plugins uninstall ${id} --force 2>/dev/null || true) && (openclaw plugins uninstall ${id.replace('openclaw-', '')} --force 2>/dev/null || true) && rm -rf .openclaw/extensions/${id} .openclaw/extensions/${id.replace('openclaw-', '')} && openclaw plugins install clawhub:${id}`;
2069
+ const cmdOut = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', cleanCmd], { cwd: projectDir, shell: false }).catch((err) => {
2070
+ const aliases = ['openclaw-zalo-mod', 'zalo-mod', id, id.replace('openclaw-', '')];
2071
+ const folderExists = aliases.some((a) => existsSync(join(projectDir, '.openclaw', 'extensions', a)));
2072
+ if (folderExists) {
2073
+ sendLog(`[plugin] Warning: installation reported errors, but plugin folder successfully written. Proceeding.`);
2074
+ return { stdout: '', stderr: err.message };
2075
+ } else {
2076
+ installSuccess = false;
2077
+ throw err;
2078
+ }
2079
+ });
2080
+ if (cmdOut) {
2081
+ for (const line of `${cmdOut.stdout}\n${cmdOut.stderr}`.split(/\r?\n/).filter(Boolean)) sendLog(line);
2082
+ }
2083
+
2084
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
2085
+ if (existsSync(cfgPath)) {
2086
+ const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
2087
+ cfg.plugins = cfg.plugins || { entries: {} };
2088
+ cfg.plugins.entries = cfg.plugins.entries || {};
2089
+ const pluginAliasMap = {
2090
+ 'openclaw-browser-automation': ['browser-automation', 'openclaw-browser-automation'],
2091
+ 'openclaw-zalo-mod': ['zalo-mod', 'openclaw-zalo-mod'],
2092
+ 'openclaw-facebook-crawler': ['openclaw-facebook-crawler', 'openclaw-n8n-facebook-crawler', 'n8n-facebook-crawler'],
2093
+ 'openclaw-n8n-facebook-poster': ['openclaw-n8n-facebook-poster', 'openclaw-facebook-poster', 'facebook-poster'],
2094
+ };
2095
+ const aliases = pluginAliasMap[id] || [id];
2096
+ const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2097
+ cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2098
+ cfg.plugins.entries[existingKey].enabled = true;
2099
+ if (existingKey === 'zalo-mod') {
2100
+ cfg.plugins.entries[existingKey].hooks = cfg.plugins.entries[existingKey].hooks || {};
2101
+ cfg.plugins.entries[existingKey].hooks.allowConversationAccess = true;
2102
+ // Auto-assign dashboard port = gateway port + 1 to avoid conflicts between bots
2103
+ const gwPort = Number(cfg.gateway?.port) || state.gatewayPort || 18789;
2104
+ cfg.plugins.entries[existingKey].config = cfg.plugins.entries[existingKey].config || {};
2105
+ if (!cfg.plugins.entries[existingKey].config.dashboardPort) {
2106
+ cfg.plugins.entries[existingKey].config.dashboardPort = gwPort + 1;
2107
+ }
2108
+ }
2109
+ // Only add the canonical config key to allow list (not all aliases)
2110
+ if (!cfg.plugins.allow.includes(existingKey)) cfg.plugins.allow.push(existingKey);
2111
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
2112
+ }
2113
+
2114
+ // Auto-expose zalo-mod dashboard port in docker-compose.yml
2115
+ const isZaloMod = id === 'openclaw-zalo-mod' || id === 'zalo-mod';
2116
+ if (isZaloMod) {
2117
+ const composeFile = join(composeDir, 'docker-compose.yml');
2118
+ if (existsSync(composeFile)) {
2119
+ let composeContent = await fsp.readFile(composeFile, 'utf8');
2120
+ const gwPort = Number(state.gatewayPort) || 18789;
2121
+ const dashPort = gwPort + 1;
2122
+ const dashPortMapping = `"127.0.0.1:${dashPort}:${dashPort}"`;
2123
+ if (!composeContent.includes(`:${dashPort}`)) {
2124
+ // Insert dashboard port after the gateway port line
2125
+ const gwPortStr = String(gwPort);
2126
+ composeContent = composeContent.replace(
2127
+ new RegExp(`^(\\s*-\\s*"(?:\\d+:)?${gwPortStr}(?::${gwPortStr})?"\\s*)$`, 'm'),
2128
+ `$1\n - ${dashPortMapping} # zalo-mod dashboard`
2129
+ );
2130
+ await fsp.writeFile(composeFile, composeContent, 'utf8');
2131
+ sendLog(`[plugin] Added dashboard port ${dashPort} to docker-compose.yml`);
2132
+ }
2133
+ }
2134
+ }
2135
+
2136
+ // Browser-automation plugin needs Docker rebuild for Playwright/Chromium deps
2137
+ const isBrowserPlugin = id === 'openclaw-browser-automation' || id === 'browser-automation';
2138
+ if (isBrowserPlugin && composeDir) {
2139
+ sendLog(`[plugin] Browser plugin requires Docker rebuild for Playwright/Chromium...`);
2140
+ const svcName = getBotServiceName(projectDir);
2141
+ await run('docker', ['compose', '-f', join(composeDir, 'docker-compose.yml'), 'up', '-d', '--build', '--force-recreate', svcName], { shell: false }).catch((err) => {
2142
+ sendLog(`[plugin] Docker rebuild failed: ${err.message}. Falling back to restart...`);
2143
+ return run('docker', ['restart', botContainer], { shell: false });
2144
+ });
2145
+ } else if (isZaloMod && composeDir) {
2146
+ // Use docker compose up to apply new port mappings from docker-compose.yml
2147
+ const svcName = getBotServiceName(projectDir);
2148
+ await run('docker', ['compose', '-f', join(composeDir, 'docker-compose.yml'), 'up', '-d', '--force-recreate', '--no-deps', svcName], { shell: false }).catch(() =>
2149
+ run('docker', ['restart', botContainer], { shell: false })
2150
+ );
2151
+ } else {
2152
+ sendLog(`[plugin] Restarting docker container to apply plugin...`);
2153
+ await run('docker', ['restart', botContainer], { shell: false });
2154
+ }
2155
+ } else {
2156
+ // Fix any legacy config issues first
2157
+ await run('openclaw', ['doctor', '--fix'], { cwd: projectDir, env: openclawProjectEnv(projectDir) }).catch((err) => sendLog(`[plugin] doctor --fix skipped: ${err.message}`));
2158
+ sendLog(`[plugin] Installing clawhub:${id}...`);
2159
+
2160
+ let installSuccess = true;
2161
+ await run('openclaw', ['plugins', 'install', `clawhub:${id}`], {
2162
+ cwd: projectDir,
2163
+ env: openclawProjectEnv(projectDir),
2164
+ resolveOnPattern: /Installed plugin:/
2165
+ }).catch((err) => {
2166
+ // Fallback verification: if the plugin's folder or mapped key is present, it succeeded despite integrity warnings
2167
+ const aliases = ['openclaw-zalo-mod', 'zalo-mod', id, id.replace('openclaw-', '')];
2168
+ const folderExists = aliases.some((a) => existsSync(join(projectDir, '.openclaw', 'extensions', a)));
2169
+ if (folderExists) {
2170
+ sendLog(`[plugin] Warning: installation reported errors, but plugin folder successfully written. Proceeding.`);
2171
+ } else {
2172
+ installSuccess = false;
2173
+ throw err;
2174
+ }
2175
+ });
2176
+
2177
+ // Automatically enable it in config after install
2178
+ const cfgPath = join(projectDir, '.openclaw', 'openclaw.json');
2179
+ if (existsSync(cfgPath)) {
2180
+ const cfg = ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8')));
2181
+ cfg.plugins = cfg.plugins || { entries: {} };
2182
+ cfg.plugins.entries = cfg.plugins.entries || {};
2183
+ const pluginAliasMap = {
2184
+ 'openclaw-zalo-mod': ['zalo-mod', 'openclaw-zalo-mod'],
2185
+ 'openclaw-facebook-crawler': ['openclaw-facebook-crawler', 'openclaw-n8n-facebook-crawler', 'n8n-facebook-crawler'],
2186
+ 'openclaw-n8n-facebook-poster': ['openclaw-n8n-facebook-poster', 'openclaw-facebook-poster', 'facebook-poster'],
2187
+ };
2188
+ const aliases = pluginAliasMap[id] || [id];
2189
+ const existingKey = aliases.find((a) => cfg.plugins.entries[a]) || aliases[0];
2190
+ cfg.plugins.entries[existingKey] = cfg.plugins.entries[existingKey] || {};
2191
+ cfg.plugins.entries[existingKey].enabled = true;
2192
+ if (existingKey === 'zalo-mod') {
2193
+ cfg.plugins.entries[existingKey].hooks = cfg.plugins.entries[existingKey].hooks || {};
2194
+ cfg.plugins.entries[existingKey].hooks.allowConversationAccess = true;
2195
+ // Auto-assign dashboard port = gateway port + 1 to avoid conflicts between bots
2196
+ const gwPort = Number(cfg.gateway?.port) || state.gatewayPort || 18789;
2197
+ cfg.plugins.entries[existingKey].config = cfg.plugins.entries[existingKey].config || {};
2198
+ if (!cfg.plugins.entries[existingKey].config.dashboardPort) {
2199
+ cfg.plugins.entries[existingKey].config.dashboardPort = gwPort + 1;
2200
+ }
2201
+ }
2202
+ await fsp.writeFile(cfgPath, JSON.stringify(cfg, null, 2), 'utf8');
2203
+ }
2204
+ }
2205
+ }
2206
+ return { ok: true };
2207
+ }
2208
+
2209
+ async function getInstalledPluginVersion(projectDir, aliases = []) {
2210
+ if (!projectDir) return '';
2211
+ try {
2212
+ const instPath = join(projectDir, '.openclaw', 'plugins', 'installs.json');
2213
+ if (existsSync(instPath)) {
2214
+ const j = JSON.parse(await fsp.readFile(instPath, 'utf8'));
2215
+ const found = (j.plugins || []).find(p => aliases.some(a => String(p.pluginId || '').toLowerCase() === String(a).toLowerCase()));
2216
+ if (found && found.version) return found.version;
2217
+ }
2218
+ } catch (e) {}
2219
+
2220
+ for (const alias of aliases) {
2221
+ try {
2222
+ const pkgPath = join(projectDir, '.openclaw', 'extensions', alias, 'package.json');
2223
+ if (existsSync(pkgPath)) {
2224
+ const pkg = JSON.parse(await fsp.readFile(pkgPath, 'utf8'));
2225
+ if (pkg.version) return pkg.version;
2226
+ }
2227
+ } catch (e) {}
2228
+ }
2229
+ return '';
2230
+ }
2231
+
2232
+ async function getFeatureFlags(projectDir, agentId = '') {
2233
+ const cfgPath = join(projectDir || '', '.openclaw', 'openclaw.json');
2234
+ const cfg = existsSync(cfgPath) ? ensureConfigShape(JSON.parse(await fsp.readFile(cfgPath, 'utf8').catch(() => '{}'))) : {};
2235
+ const aid = agentId || cfg.agents?.list?.[0]?.id || 'bot';
2236
+ const browserOn = !!cfg.browser?.enabled;
2237
+ const cronOn = !!(cfg.tools?.alsoAllow || []).includes('group:automation');
2238
+ const fresh = cfg;
2239
+ const freshSaved = {};
2240
+ const installsPath = join(projectDir || '', '.openclaw', 'plugins', 'installs.json');
2241
+ const installs = existsSync(installsPath) ? JSON.parse(await fsp.readFile(installsPath, 'utf8').catch(() => '{}')) : {};
2242
+ const installRecords = installs.installRecords || {};
2243
+ const installedKeys = new Set(Object.keys(installRecords).map((k) => String(k || '').toLowerCase()));
2244
+ const installedSpecs = new Set(Object.values(installRecords).flatMap((r) => {
2245
+ const out = [];
2246
+ const spec = String(r?.spec || '').toLowerCase();
2247
+ const pkg = String(r?.clawhubPackage || '').toLowerCase();
2248
+ const resolved = String(r?.resolvedName || '').toLowerCase();
2249
+ if (spec) out.push(spec);
2250
+ if (pkg) out.push(pkg);
2251
+ if (resolved) out.push(resolved);
2252
+ return out;
2253
+ }));
2254
+ const allowSet = new Set((fresh.plugins?.allow || []).map((x) => String(x || '').toLowerCase()));
2255
+ const entryMap = fresh.plugins?.entries || {};
2256
+ const hasEntry = (aliases = []) => aliases.some((a) => !!entryMap[a]);
2257
+ const isEnabled = (aliases = []) => aliases.some((a) => !!entryMap[a]?.enabled);
2258
+ const isInstalledByRecord = (aliases = []) =>
2259
+ aliases.some((a) =>
2260
+ installedKeys.has(a) ||
2261
+ Array.from(installedSpecs).some((spec) => spec.includes(a)) ||
2262
+ allowSet.has(a)
2263
+ );
2264
+ const aliases = {
2265
+ browser: ['openclaw-browser-automation', 'browser-automation'],
2266
+ zalo: ['openclaw-zalo-mod', 'zalo-mod'],
2267
+ crawler: ['openclaw-facebook-crawler', 'openclaw-n8n-facebook-crawler', 'n8n-facebook-crawler'],
2268
+ poster: ['openclaw-n8n-facebook-poster', 'openclaw-facebook-poster', 'facebook-poster'],
2269
+ };
2270
+ const flags = {
2271
+ 'skill:browser': browserOn,
2272
+ 'skill:cron': cronOn,
2273
+ 'plugin:openclaw-browser-automation': isEnabled(aliases.browser),
2274
+ 'plugin:openclaw-zalo-mod': isEnabled(aliases.zalo),
2275
+ 'plugin:openclaw-facebook-crawler': isEnabled(aliases.crawler),
2276
+ 'plugin:openclaw-n8n-facebook-poster': isEnabled(aliases.poster),
2277
+ };
2278
+ const extensionsDir = join(projectDir || '', '.openclaw', 'extensions');
2279
+ const extensionDirExists = (aliases = []) =>
2280
+ aliases.some((a) => existsSync(join(extensionsDir, a)));
2281
+ const isActuallyInstalled = (aliases = []) =>
2282
+ extensionDirExists(aliases) || isInstalledByRecord(aliases);
2283
+ const installed = {
2284
+ 'plugin:openclaw-browser-automation': isActuallyInstalled(aliases.browser),
2285
+ 'plugin:openclaw-zalo-mod': isActuallyInstalled(aliases.zalo),
2286
+ 'plugin:openclaw-facebook-crawler': isActuallyInstalled(aliases.crawler),
2287
+ 'plugin:openclaw-n8n-facebook-poster': isActuallyInstalled(aliases.poster),
2288
+ };
2289
+ const versions = {
2290
+ 'plugin:openclaw-browser-automation': await getInstalledPluginVersion(projectDir, aliases.browser),
2291
+ 'plugin:openclaw-zalo-mod': await getInstalledPluginVersion(projectDir, aliases.zalo),
2292
+ 'plugin:openclaw-facebook-crawler': await getInstalledPluginVersion(projectDir, aliases.crawler),
2293
+ 'plugin:openclaw-n8n-facebook-poster': await getInstalledPluginVersion(projectDir, aliases.poster),
2294
+ };
2295
+ return { flags, installed, versions };
2296
+ }
2297
+
2298
+ async function serveStatic(req, res) {
2299
+ const url = new URL(req.url, 'http://local');
2300
+ let pathname = decodeURIComponent(url.pathname);
2301
+ if (pathname === '/') pathname = '/index.html';
2302
+ const file = resolve(WEB_DIR, pathname.slice(1));
2303
+ if (!file.startsWith(WEB_DIR) || !existsSync(file)) return false;
2304
+ const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png' };
2305
+ res.writeHead(200, {
2306
+ 'content-type': types[extname(file)] || 'application/octet-stream',
2307
+ 'cache-control': 'no-store, no-cache, must-revalidate',
2308
+ pragma: 'no-cache',
2309
+ expires: '0',
2310
+ });
2311
+ createReadStream(file).pipe(res);
2312
+ return true;
2313
+ }
2314
+
2315
+ async function handler(req, res, rootProjectDir) {
2316
+ try {
2317
+ const url = new URL(req.url, 'http://local');
2318
+ if (url.pathname === '/api/install/logs') {
2319
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
2320
+ logClients.add(res);
2321
+ res.write(`data: ${JSON.stringify({ line: 'log stream connected', ts: new Date().toISOString() })}\n\n`);
2322
+ req.on('close', () => logClients.delete(res));
2323
+ return;
2324
+ }
2325
+ if (url.pathname === '/api/system' && req.method === 'GET') {
2326
+ const osChoice = detectOs();
2327
+ const [nodeStatus, npmStatus, dockerStatus, currentVersions] = await Promise.all([commandExists('node'), commandExists('npm'), commandExists('docker', ['version', '--format', '{{.Server.Version}}']), getCurrentRuntimeVersions()]);
2328
+ const projectDir = state.projectDir && existsSync(join(state.projectDir, '.openclaw', 'openclaw.json')) ? state.projectDir : null;
2329
+ const projectVersions = await resolveProjectRuntimeVersions(projectDir, state.mode).catch(() => null);
2330
+ const mergedVersions = {
2331
+ openclaw: projectVersions?.openclaw || currentVersions.openclaw || OPENCLAW_NPM_SPEC,
2332
+ nineRouter: projectVersions?.nineRouter || currentVersions.nineRouter || NINE_ROUTER_NPM_SPEC,
2333
+ node: projectVersions?.node || currentVersions.node || String(nodeStatus?.output || '').trim(),
2334
+ };
2335
+ const projects = await discoverProjects(rootProjectDir).catch(() => []);
2336
+ return json(res, { os: osChoice, platform: process.platform, arch: process.arch, recommendedMode: recommendedMode(osChoice), node: nodeStatus, npm: npmStatus, docker: dockerStatus, versions: { desiredOpenclaw: OPENCLAW_NPM_SPEC, desiredNineRouter: NINE_ROUTER_NPM_SPEC, currentOpenclaw: mergedVersions.openclaw, currentNineRouter: mergedVersions.nineRouter, currentNode: mergedVersions.node, openclaw: mergedVersions.openclaw, nineRouter: mergedVersions.nineRouter, node: mergedVersions.node, setup: SETUP_VERSION }, projects });
2337
+ }
2338
+ if (url.pathname === '/api/projects/discover' && req.method === 'GET') {
2339
+ return json(res, { ok: true, projects: await discoverProjects(rootProjectDir).catch(() => []) });
2340
+ }
2341
+ if (url.pathname === '/api/project/pick-folder' && req.method === 'POST') {
2342
+ return json(res, await pickProjectFolder());
2343
+ }
2344
+ if (url.pathname === '/api/project/delete' && req.method === 'POST') {
2345
+ const body = await readJson(req);
2346
+ return json(res, await deleteProjectFolder(body.projectDir, rootProjectDir));
2347
+ }
2348
+ if (url.pathname === '/api/install' && req.method === 'POST') {
2349
+ if (state.installing) return json(res, { ok: false, error: 'Install already running' }, 409);
2350
+ const body = await readJson(req);
2351
+ const osChoice = body.os || detectOs();
2352
+ const mode = body.mode || recommendedMode(osChoice);
2353
+ const projectDir = body.projectDir ? resolve(String(body.projectDir)) : resolve(rootProjectDir, body.projectName || DEFAULT_PROJECT_NAME);
2354
+
2355
+ // Auto-allocate unique, free ports to avoid collision (reserving gatewayPort + 1 for Zalo-mod UI)
2356
+ const projects = await discoverProjects(rootProjectDir).catch(() => []);
2357
+ const usedPorts = new Set();
2358
+ for (const p of projects) {
2359
+ const gw = Number(p.gatewayPort);
2360
+ if (gw) {
2361
+ usedPorts.add(gw);
2362
+ usedPorts.add(gw + 1); // Zalo-mod UI port of existing project
2363
+ }
2364
+ }
2365
+ const usedRouterPorts = new Set(projects.map(p => Number(p.routerPort)).filter(Boolean));
2366
+
2367
+ let gatewayPort = 18789;
2368
+ while (usedPorts.has(gatewayPort) || usedPorts.has(gatewayPort + 1)) {
2369
+ gatewayPort++;
2370
+ }
2371
+
2372
+ let routerPort = 20128;
2373
+ while (usedRouterPorts.has(routerPort)) {
2374
+ routerPort++;
2375
+ }
2376
+
2377
+ state.gatewayPort = gatewayPort;
2378
+ state.routerPort = routerPort;
2379
+ state.gatewayUrl = `http://127.0.0.1:${gatewayPort}`;
2380
+ state.routerUrl = `http://127.0.0.1:${routerPort}`;
2381
+
2382
+ installCore({ osChoice, mode, projectDir, gatewayPort, routerPort }).catch(() => {});
2383
+ state.projectDir = projectDir;
2384
+ state.mode = mode;
2385
+ state.os = osChoice;
2386
+ saveState(rootProjectDir);
2387
+ return json(res, { ok: true, projectDir, state });
2388
+ }
2389
+ if (url.pathname === '/api/project/connect' && req.method === 'POST') {
2390
+ const body = await readJson(req);
2391
+ return json(res, await connectExistingProject(body.projectDir, rootProjectDir));
2392
+ }
2393
+ if (url.pathname === '/api/project/connect-picked' && req.method === 'POST') {
2394
+ const body = await readJson(req);
2395
+ return json(res, await connectPickedProject(body.projectName, rootProjectDir));
2396
+ }
2397
+ if (url.pathname === '/api/bot/status' && req.method === 'GET') {
2398
+ await resolveProjectDir(rootProjectDir);
2399
+ return json(res, await buildBotStatus());
2400
+ }
2401
+ if (url.pathname === '/api/bot/credentials' && req.method === 'PUT') {
2402
+ const body = await readJson(req);
2403
+ const projectDir = await resolveProjectDir(rootProjectDir, body);
2404
+ const credentials = await updateBotCredentials(projectDir, body);
2405
+ sendLog('Credentials updated: 9Router API key');
2406
+ return json(res, { ok: true, credentials });
2407
+ }
2408
+ if (url.pathname === '/api/runtime/update' && req.method === 'POST') {
2409
+ const body = await readJson(req);
2410
+ const projectDir = await resolveProjectDir(rootProjectDir, body);
2411
+ const target = body.target === '9router' ? '9router' : 'openclaw';
2412
+ sendLog(`[update] Updating ${target}...`);
2413
+ const result = await updateRuntime(target, projectDir);
2414
+ sendLog(`[update] ${target} update completed (${result.mode})`);
2415
+ return json(res, result);
2416
+ }
2417
+ if (url.pathname === '/api/bot/create' && req.method === 'POST') {
2418
+ const body = await readJson(req);
2419
+ const projectDir = await resolveProjectDir(rootProjectDir, body);
2420
+ const result = await createBotInProject(projectDir, body, { mode: state.mode, os: state.os });
2421
+ await saveState(rootProjectDir);
2422
+ sendLog(`✅ Bot created: ${result.agentId} (${result.channel})`);
2423
+ if (result.warning) sendLog(`⚠️ ${result.warning}`);
2424
+ await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] recreate skipped/failed: ${err.message}`));
2425
+
2426
+ if (result.channel === 'telegram') {
2427
+ const botContainer = getBotContainerName(projectDir);
2428
+ const token = String(body.token || '').trim();
2429
+ sendLog(`[telegram] Registering Telegram channel via CLI inside ${botContainer}...`);
2430
+ try {
2431
+ const regResult = await runCapture('docker', ['exec', botContainer, 'sh', '-lc', `cd /root/project && openclaw channels add telegram --token "${token}"`], { cwd: projectDir, shell: false });
2432
+ sendLog(`[telegram] CLI registration output:\n${regResult.stdout}\n${regResult.stderr}`);
2433
+ sendLog(`[telegram] Restarting ${botContainer} container to load the registered channel...`);
2434
+ await restartDockerBotContainer(projectDir).catch((err) => sendLog(`[telegram] Container restart failed: ${err.message}`));
2435
+ sendLog(`[telegram] ${botContainer} restarted. Try chatting with your Telegram bot now.`);
2436
+ } catch (err) {
2437
+ sendLog(`[telegram] Warning: CLI registration failed: ${err.message}`);
2438
+ }
2439
+ }
2440
+
2441
+ if (result.channel === 'zalo-personal') {
2442
+ result.loginStarted = true;
2443
+ result.loginHint = 'Generating Zalo QR. Keep this modal open...';
2444
+ result.zaloQrDataUrl = '';
2445
+ // Delay login start to let the recreated container fully boot gateway + plugins
2446
+ setTimeout(async () => {
2447
+ try {
2448
+ const login = await startZaloUserLogin(projectDir, state.mode);
2449
+ if (login?.qrDataUrl) sendLog(`[zalouser:qr] ${login.qrDataUrl}`);
2450
+ if (login?.message) sendLog(`[zalouser] ${login.message}`);
2451
+ } catch (err) {
2452
+ sendLog(`[zalouser] Login failed: ${err.message}`);
2453
+ }
2454
+ }, 5000);
2455
+ }
2456
+ return json(res, result);
2457
+ }
2458
+ if (url.pathname.startsWith('/api/bot/') && req.method === 'PUT' && !url.pathname.startsWith('/api/bot/files/')) {
2459
+ const agentId = decodeURIComponent(url.pathname.split('/').pop() || '');
2460
+ const body = await readJson(req);
2461
+ const projectDir = await resolveProjectDir(rootProjectDir, body);
2462
+ const result = await updateBotInProject(projectDir, agentId, body, { mode: state.mode, os: state.os });
2463
+ await saveState(rootProjectDir);
2464
+ await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] recreate skipped/failed: ${err.message}`));
2465
+ return json(res, result);
2466
+ }
2467
+ if (url.pathname === '/api/zalo/login' && req.method === 'POST') {
2468
+ const projectDir = await resolveProjectDir(rootProjectDir);
2469
+ setImmediate(async () => {
2470
+ try {
2471
+ const login = await startZaloUserLogin(projectDir, state.mode);
2472
+ if (login?.qrDataUrl) sendLog(`[zalouser:qr] ${login.qrDataUrl}`);
2473
+ if (login?.message) sendLog(`[zalouser] ${login.message}`);
2474
+ } catch (err) {
2475
+ sendLog(`[zalouser] Login failed: ${err.message}`);
2476
+ }
2477
+ });
2478
+ return json(res, { ok: true, message: 'Zalo login initiated. QR will appear in UI.' });
2479
+ }
2480
+ if (url.pathname.startsWith('/api/bot/') && req.method === 'DELETE' && !url.pathname.startsWith('/api/bot/files/')) {
2481
+ const agentId = decodeURIComponent(url.pathname.replace('/api/bot/', ''));
2482
+ const projectDir = await resolveProjectDir(rootProjectDir);
2483
+ const result = await deleteBotInProject(projectDir, agentId);
2484
+ sendLog(`? Bot deleted: ${agentId}`);
2485
+ await recreateDockerBot(projectDir).catch((err) => sendLog(`[docker] recreate skipped/failed: ${err.message}`));
2486
+ return json(res, result);
2487
+ }
2488
+ if (url.pathname === '/api/bot/files' && req.method === 'GET') {
2489
+ await resolveProjectDir(rootProjectDir);
2490
+ return json(res, { files: state.projectDir ? await listMarkdownFiles(state.projectDir, url.searchParams.get('agentId') || '') : [] });
2491
+ }
2492
+ if (url.pathname.startsWith('/api/bot/files/') && state.projectDir) {
2493
+ const name = decodeURIComponent(url.pathname.replace('/api/bot/files/', ''));
2494
+ const file = safeJoin(state.projectDir, name);
2495
+ if (req.method === 'GET') return json(res, { name, content: await fsp.readFile(file, 'utf8') });
2496
+ if (req.method === 'PUT') {
2497
+ if (!name.endsWith('.md')) throw httpError(400, 'Only markdown files (.md) can be modified');
2498
+ const body = await readJson(req);
2499
+ await fsp.writeFile(file, String(body.content || ''), 'utf8');
2500
+ return json(res, { ok: true });
2501
+ }
2502
+ }
2503
+ if (url.pathname === '/api/catalog' && req.method === 'GET') return json(res, {
2504
+ skills: [
2505
+ { name: 'Browser', slug: 'browser' },
2506
+ { name: 'Cron', slug: 'cron' },
2507
+ ],
2508
+ plugins: [
2509
+ { name: 'openclaw-browser-automation', package: 'openclaw-browser-automation' },
2510
+ { name: 'openclaw-zalo-mod', package: 'openclaw-zalo-mod' },
2511
+ { name: 'openclaw-facebook-crawler', package: 'openclaw-facebook-crawler' },
2512
+ { name: 'openclaw-n8n-facebook-poster', package: 'openclaw-n8n-facebook-poster' },
2513
+ ]
2514
+ });
2515
+ if (url.pathname === '/api/features' && req.method === 'GET') {
2516
+ const projectDir = await resolveProjectDir(rootProjectDir);
2517
+ return json(res, await getFeatureFlags(projectDir, url.searchParams.get('agentId') || ''));
2518
+ }
2519
+ if (url.pathname === '/api/features/toggle' && req.method === 'POST') {
2520
+ const body = await readJson(req);
2521
+ const projectDir = await resolveProjectDir(rootProjectDir);
2522
+ return json(res, await applyFeatureToggle(projectDir, body.agentId || '', body.kind, body.id, !!body.enabled));
2523
+ }
2524
+ if (url.pathname === '/api/features/install' && req.method === 'POST') {
2525
+ const body = await readJson(req);
2526
+ const projectDir = await resolveProjectDir(rootProjectDir);
2527
+ return json(res, await installFeature(projectDir, body.agentId || '', body.kind, body.id));
2528
+ }
2529
+ if (await serveStatic(req, res)) return;
2530
+ json(res, { error: 'Not found' }, 404);
2531
+ } catch (err) {
2532
+ json(res, { error: err.message }, err.status || 500);
2533
+ }
2534
+ }
2535
+
2536
+ function findPort(host, preferredPort) {
2537
+ return new Promise((resolve, reject) => {
2538
+ const server = net.createServer();
2539
+ server.unref();
2540
+ server.on('error', () => resolve(findPort(host, preferredPort + 1)));
2541
+ server.listen(preferredPort, host, () => {
2542
+ const port = server.address().port;
2543
+ server.close(() => resolve(port));
2544
+ });
2545
+ });
2546
+ }
2547
+
2548
+ function openUrl(url) {
2549
+ const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open';
2550
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
2551
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore', shell: false, windowsHide: true });
2552
+ child.unref();
2553
+ }
2554
+
2555
+ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort = 51789, openBrowser = true, projectDir = process.cwd() } = {}) {
2556
+ const port = await findPort(host, preferredPort);
2557
+ const server = http.createServer((req, res) => handler(req, res, projectDir));
2558
+ await new Promise((resolve) => server.listen(port, host, resolve));
2559
+ const url = `http://${host}:${port}`;
2560
+ console.log(`OpenClaw Setup UI: ${url}`);
2561
+ console.log('Legacy CLI: create-openclaw-bot legacy');
2562
+ if (openBrowser) openUrl(url);
2563
+ }
2564
+
2565
+ export { createBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloUserLogin, readBotCredentials, resolveProject9RouterApiKey };
2566
+
2567
+
2568
+