create-openclaw-bot 5.10.1 → 5.11.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.
- package/README.md +23 -6
- package/README.vi.md +23 -6
- package/dist/server/local-server.js +208 -325
- package/dist/setup/shared/docker-gen.js +22 -12
- package/dist/setup/shared/workspace-gen.js +819 -154
- package/dist/web/app.js +50 -16
- package/dist/web/styles.css +6 -1
- package/package.json +1 -1
|
@@ -388,9 +388,9 @@ async function getAvailableDrives() {
|
|
|
388
388
|
return drives.length ? drives : ['C:\\', 'D:\\'];
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
return '
|
|
391
|
+
// Docker is the only supported deploy mode now (native was removed).
|
|
392
|
+
function recommendedMode() {
|
|
393
|
+
return 'docker';
|
|
394
394
|
}
|
|
395
395
|
|
|
396
396
|
function commandExists(cmd, args = ['--version']) {
|
|
@@ -438,27 +438,6 @@ function run(cmd, args, opts = {}) {
|
|
|
438
438
|
});
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
-
function startDetached(cmd, args, opts = {}) {
|
|
442
|
-
sendLog(`$ ${cmd} ${args.join(' ')} &`);
|
|
443
|
-
const shell = process.platform === 'win32';
|
|
444
|
-
const rawBin = resolveBinPath(cmd);
|
|
445
|
-
const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
|
|
446
|
-
const child = spawn(bin, args, {
|
|
447
|
-
cwd: opts.cwd,
|
|
448
|
-
shell,
|
|
449
|
-
detached: true,
|
|
450
|
-
stdio: 'ignore',
|
|
451
|
-
windowsHide: opts.windowsHide ?? true,
|
|
452
|
-
env: { ...process.env, ...(opts.env || {}) },
|
|
453
|
-
});
|
|
454
|
-
child.on('error', (err) => {
|
|
455
|
-
sendLog(`[error] Failed to start background command "${cmd}": ${err.message}`);
|
|
456
|
-
console.error(`Failed to start background command "${cmd}":`, err);
|
|
457
|
-
});
|
|
458
|
-
child.unref();
|
|
459
|
-
return child.pid;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
441
|
async function getCurrentRuntimeVersions() {
|
|
463
442
|
const [openclaw, nineRouter, node] = await Promise.all([
|
|
464
443
|
commandExists('openclaw', ['--version']),
|
|
@@ -605,135 +584,6 @@ Timed out after ${opts.timeout}ms`.trim() : stderr });
|
|
|
605
584
|
});
|
|
606
585
|
}
|
|
607
586
|
|
|
608
|
-
// ── Native process supervision (auto-restart) ───────────────────────────────
|
|
609
|
-
// Docker containers get `restart: always` for free. For native installs we register the gateway
|
|
610
|
-
// and 9router as OS services so they survive crashes and reboots, mirroring that guarantee:
|
|
611
|
-
// • macOS → per-user launchd LaunchAgent (KeepAlive + RunAtLoad)
|
|
612
|
-
// • Linux → systemd unit (system unit when root, else --user) with Restart=always
|
|
613
|
-
// • Windows / anything else → plain detached process (no supervision; unchanged behaviour)
|
|
614
|
-
// Every step is best-effort: any failure falls back to startDetached, so a native install can
|
|
615
|
-
// never end up worse off than before this feature existed.
|
|
616
|
-
function escapeXml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
617
|
-
function nativeServiceId(projectDir) { return slugify(basename(projectDir || 'openclaw')) || 'bot'; }
|
|
618
|
-
function nativeServiceLabels(projectDir) {
|
|
619
|
-
const id = nativeServiceId(projectDir);
|
|
620
|
-
return {
|
|
621
|
-
'9router': { launchd: `com.openclaw.9router.${id}`, systemd: `openclaw-9router-${id}.service` },
|
|
622
|
-
sync: { launchd: `com.openclaw.sync.${id}`, systemd: `openclaw-sync-${id}.service` },
|
|
623
|
-
gateway: { launchd: `com.openclaw.gateway.${id}`, systemd: `openclaw-gateway-${id}.service` },
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
async function resolveAbsoluteBin(cmd) {
|
|
627
|
-
try {
|
|
628
|
-
const out = await runCapture(process.platform === 'win32' ? 'where' : 'which', [cmd], { shell: false, timeout: 5000 });
|
|
629
|
-
const first = String(out.stdout || '').split(/\r?\n/).map((s) => s.trim()).find(Boolean);
|
|
630
|
-
if (out.code === 0 && first) return first;
|
|
631
|
-
} catch {}
|
|
632
|
-
return resolveBinPath(cmd);
|
|
633
|
-
}
|
|
634
|
-
function systemdUserUnitDir() { return join(getRealHomedir(), '.config', 'systemd', 'user'); }
|
|
635
|
-
function isRootUser() { return typeof process.getuid === 'function' && process.getuid() === 0; }
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* Start one native process under OS supervision (auto-restart). Falls back to a plain detached
|
|
639
|
-
* process on Windows/unknown platforms or if the service tooling errors. Returns the method used:
|
|
640
|
-
* 'launchd' | 'systemd' | 'detached'.
|
|
641
|
-
*/
|
|
642
|
-
async function startNativeService({ projectDir, name, cmd, args, desc, env }) {
|
|
643
|
-
const labels = nativeServiceLabels(projectDir)[name];
|
|
644
|
-
const logDir = join(projectDir, '.openclaw', 'logs');
|
|
645
|
-
await fsp.mkdir(logDir, { recursive: true }).catch(() => {});
|
|
646
|
-
const serviceEnv = { PATH: process.env.PATH || '', HOME: getRealHomedir(), ...env };
|
|
647
|
-
try {
|
|
648
|
-
const absBin = await resolveAbsoluteBin(cmd);
|
|
649
|
-
if (process.platform === 'darwin') {
|
|
650
|
-
const agentsDir = join(getRealHomedir(), 'Library', 'LaunchAgents');
|
|
651
|
-
await fsp.mkdir(agentsDir, { recursive: true });
|
|
652
|
-
const progArgs = [absBin, ...args].map((a) => ` <string>${escapeXml(a)}</string>`).join('\n');
|
|
653
|
-
const envDict = Object.entries(serviceEnv).map(([k, v]) => ` <key>${escapeXml(k)}</key><string>${escapeXml(String(v))}</string>`).join('\n');
|
|
654
|
-
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
655
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
656
|
-
<plist version="1.0"><dict>
|
|
657
|
-
<key>Label</key><string>${labels.launchd}</string>
|
|
658
|
-
<key>ProgramArguments</key><array>
|
|
659
|
-
${progArgs}
|
|
660
|
-
</array>
|
|
661
|
-
<key>WorkingDirectory</key><string>${escapeXml(projectDir)}</string>
|
|
662
|
-
<key>EnvironmentVariables</key><dict>
|
|
663
|
-
${envDict}
|
|
664
|
-
</dict>
|
|
665
|
-
<key>RunAtLoad</key><true/>
|
|
666
|
-
<key>KeepAlive</key><true/>
|
|
667
|
-
<key>StandardOutPath</key><string>${escapeXml(join(logDir, name + '.out.log'))}</string>
|
|
668
|
-
<key>StandardErrorPath</key><string>${escapeXml(join(logDir, name + '.err.log'))}</string>
|
|
669
|
-
</dict></plist>`;
|
|
670
|
-
const plistPath = join(agentsDir, `${labels.launchd}.plist`);
|
|
671
|
-
await fsp.writeFile(plistPath, plist, 'utf8');
|
|
672
|
-
await run('launchctl', ['unload', plistPath], {}).catch(() => {});
|
|
673
|
-
await run('launchctl', ['load', '-w', plistPath], {});
|
|
674
|
-
sendLog(`[native] launchd service ${labels.launchd} loaded (auto-restart)`);
|
|
675
|
-
return 'launchd';
|
|
676
|
-
}
|
|
677
|
-
if (process.platform === 'linux') {
|
|
678
|
-
const root = isRootUser();
|
|
679
|
-
const unitDir = root ? '/etc/systemd/system' : systemdUserUnitDir();
|
|
680
|
-
await fsp.mkdir(unitDir, { recursive: true });
|
|
681
|
-
const sc = (a) => run('systemctl', root ? a : ['--user', ...a], {});
|
|
682
|
-
const envLines = Object.entries(serviceEnv).map(([k, v]) => `Environment="${k}=${String(v).replace(/\n/g, ' ')}"`).join('\n');
|
|
683
|
-
const execStart = [absBin, ...args].map((a) => (/[\s"']/.test(a) ? JSON.stringify(a) : a)).join(' ');
|
|
684
|
-
const unit = `[Unit]
|
|
685
|
-
Description=${desc}
|
|
686
|
-
After=network-online.target
|
|
687
|
-
Wants=network-online.target
|
|
688
|
-
|
|
689
|
-
[Service]
|
|
690
|
-
Type=simple
|
|
691
|
-
WorkingDirectory=${projectDir}
|
|
692
|
-
${envLines}
|
|
693
|
-
ExecStart=${execStart}
|
|
694
|
-
Restart=always
|
|
695
|
-
RestartSec=3
|
|
696
|
-
|
|
697
|
-
[Install]
|
|
698
|
-
WantedBy=${root ? 'multi-user.target' : 'default.target'}
|
|
699
|
-
`;
|
|
700
|
-
await fsp.writeFile(join(unitDir, labels.systemd), unit, 'utf8');
|
|
701
|
-
await sc(['daemon-reload']);
|
|
702
|
-
await sc(['enable', '--now', labels.systemd]);
|
|
703
|
-
sendLog(`[native] systemd ${root ? 'system' : 'user'} service ${labels.systemd} enabled (Restart=always)`);
|
|
704
|
-
return 'systemd';
|
|
705
|
-
}
|
|
706
|
-
} catch (e) {
|
|
707
|
-
sendLog(`[native] ${name} service setup failed (${e.message}); falling back to detached process`);
|
|
708
|
-
}
|
|
709
|
-
const pid = startDetached(cmd, args, { cwd: projectDir, env });
|
|
710
|
-
if (name === 'gateway') state.botPid = pid;
|
|
711
|
-
return 'detached';
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
/** Remove any launchd/systemd services registered for a native project (used on delete). */
|
|
715
|
-
async function removeNativeAutostart(projectDir) {
|
|
716
|
-
const labels = nativeServiceLabels(projectDir);
|
|
717
|
-
try {
|
|
718
|
-
if (process.platform === 'darwin') {
|
|
719
|
-
const agentsDir = join(getRealHomedir(), 'Library', 'LaunchAgents');
|
|
720
|
-
for (const k of ['9router', 'sync', 'gateway']) {
|
|
721
|
-
const p = join(agentsDir, `${labels[k].launchd}.plist`);
|
|
722
|
-
if (existsSync(p)) { await run('launchctl', ['unload', p], {}).catch(() => {}); await fsp.unlink(p).catch(() => {}); }
|
|
723
|
-
}
|
|
724
|
-
} else if (process.platform === 'linux') {
|
|
725
|
-
const root = isRootUser();
|
|
726
|
-
const unitDir = root ? '/etc/systemd/system' : systemdUserUnitDir();
|
|
727
|
-
const sc = (a) => run('systemctl', root ? a : ['--user', ...a], {}).catch(() => {});
|
|
728
|
-
let changed = false;
|
|
729
|
-
for (const k of ['9router', 'sync', 'gateway']) {
|
|
730
|
-
const u = join(unitDir, labels[k].systemd);
|
|
731
|
-
if (existsSync(u)) { await sc(['disable', '--now', labels[k].systemd]); await fsp.unlink(u).catch(() => {}); changed = true; }
|
|
732
|
-
}
|
|
733
|
-
if (changed) await sc(['daemon-reload']);
|
|
734
|
-
}
|
|
735
|
-
} catch (e) { sendLog(`[native] autostart teardown skipped: ${e.message}`); }
|
|
736
|
-
}
|
|
737
587
|
|
|
738
588
|
function safeJoin(root, name) {
|
|
739
589
|
const clean = normalize(String(name || '')).replace(/^([/\\])+/, '');
|
|
@@ -2079,104 +1929,7 @@ try{
|
|
|
2079
1929
|
return { message: 'Generating Zalo QR. The image will appear automatically.' };
|
|
2080
1930
|
}
|
|
2081
1931
|
|
|
2082
|
-
|
|
2083
|
-
// Same flow as Docker but on the host: run `openclaw channels login` directly,
|
|
2084
|
-
// read the QR PNG straight off the host filesystem, then restart the native
|
|
2085
|
-
// gateway so the saved credentials take effect.
|
|
2086
|
-
const gatewayPort = state.gatewayPort || 18789;
|
|
2087
|
-
const runtimeEnv = {
|
|
2088
|
-
...process.env,
|
|
2089
|
-
OPENCLAW_HOME: join(projectDir, '.openclaw'),
|
|
2090
|
-
OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
|
|
2091
|
-
OPENCLAW_GATEWAY_PORT: String(gatewayPort),
|
|
2092
|
-
OPENCLAW_PORT: String(gatewayPort),
|
|
2093
|
-
};
|
|
2094
|
-
|
|
2095
|
-
// The runtime writes the QR to a uid-scoped temp dir; probe the likely locations.
|
|
2096
|
-
const uid = (typeof process.getuid === 'function') ? process.getuid() : '';
|
|
2097
|
-
const qrNames = profile === 'default'
|
|
2098
|
-
? ['openclaw-zalouser-qr.png', 'openclaw-zalouser-qr-default.png', `openclaw-zalouser-qr-${profile}.png`]
|
|
2099
|
-
: [`openclaw-zalouser-qr-${profile}.png`];
|
|
2100
|
-
const qrDirs = [join(os.tmpdir(), 'openclaw'), '/tmp/openclaw', `/tmp/openclaw-${uid}`, '/tmp/openclaw-1000', join(os.tmpdir(), `openclaw-${uid}`)];
|
|
2101
|
-
const nativeQrPaths = [...new Set(qrDirs.flatMap((d) => qrNames.map((n) => join(d, n))))];
|
|
2102
|
-
|
|
2103
|
-
// Clean stale credentials & QR files so we only ever surface a fresh code.
|
|
2104
|
-
const credFile = profile === 'default' ? 'credentials.json' : `credentials-${profile}.json`;
|
|
2105
|
-
const credPath = join(projectDir, '.openclaw', 'credentials', 'zalouser', credFile);
|
|
2106
|
-
try { await fsp.rm(credPath, { force: true }); } catch {}
|
|
2107
|
-
for (const p of nativeQrPaths) { try { await fsp.rm(p, { force: true }); } catch {} }
|
|
2108
|
-
|
|
2109
|
-
sendLog('[zalouser] Generating Zalo QR (native). The image will appear automatically.');
|
|
2110
|
-
|
|
2111
|
-
const MAX_LOGIN_ATTEMPTS = 4;
|
|
2112
|
-
const RETRY_DELAYS = [0, 8000, 15000, 20000];
|
|
2113
|
-
let loginAttempt = 0;
|
|
2114
|
-
let sent = false;
|
|
2115
|
-
let restartAfterLogin = false;
|
|
2116
|
-
|
|
2117
|
-
// Poll the host filesystem for the QR PNG across all retry attempts.
|
|
2118
|
-
let tries = 0;
|
|
2119
|
-
const poll = setInterval(() => {
|
|
2120
|
-
if (sent || tries++ > 120) {
|
|
2121
|
-
clearInterval(poll);
|
|
2122
|
-
if (!sent) sendLog('[zalouser] QR not found yet. Try closing/reopening login, or run `openclaw channels login --channel zalouser --verbose` on the host.');
|
|
2123
|
-
return;
|
|
2124
|
-
}
|
|
2125
|
-
for (const p of nativeQrPaths) {
|
|
2126
|
-
try {
|
|
2127
|
-
if (existsSync(p) && fs.statSync(p).size > 100) {
|
|
2128
|
-
const b64 = fs.readFileSync(p).toString('base64');
|
|
2129
|
-
if (b64.length > 100) {
|
|
2130
|
-
sent = true;
|
|
2131
|
-
clearInterval(poll);
|
|
2132
|
-
sendLog(`[zalouser:qr] data:image/png;base64,${b64}`);
|
|
2133
|
-
sendLog('[zalouser] Scan this QR with the Zalo app.');
|
|
2134
|
-
break;
|
|
2135
|
-
}
|
|
2136
|
-
}
|
|
2137
|
-
} catch {}
|
|
2138
|
-
}
|
|
2139
|
-
}, 1000);
|
|
2140
|
-
|
|
2141
|
-
const loginArgs = ['channels', 'login', '--channel', 'zalouser', '--account', profile, '--verbose'];
|
|
2142
|
-
const runLoginAttempt = () => {
|
|
2143
|
-
loginAttempt++;
|
|
2144
|
-
if (loginAttempt > 1) sendLog(`[zalouser] Retry attempt ${loginAttempt}/${MAX_LOGIN_ATTEMPTS}...`);
|
|
2145
|
-
const child = spawn('openclaw', loginArgs, { cwd: projectDir, env: runtimeEnv, shell: false, windowsHide: true });
|
|
2146
|
-
const handleLoginLine = (line) => {
|
|
2147
|
-
sendLog(line);
|
|
2148
|
-
if (/login successful|saved auth/i.test(line)) restartAfterLogin = true;
|
|
2149
|
-
};
|
|
2150
|
-
child.stdout.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLoginLine));
|
|
2151
|
-
child.stderr.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLoginLine));
|
|
2152
|
-
child.on('error', (err) => sendLog(`[zalouser] Login process failed: ${err.message}. Is the 'openclaw' CLI installed on PATH?`));
|
|
2153
|
-
child.on('close', async (code) => {
|
|
2154
|
-
sendLog(`[zalouser] Login process exited ${code}`);
|
|
2155
|
-
if (code === 0 || restartAfterLogin || sent) {
|
|
2156
|
-
if (restartAfterLogin) {
|
|
2157
|
-
sendLog('[zalouser] Login saved. Restarting native gateway so Zalo User can receive messages...');
|
|
2158
|
-
try {
|
|
2159
|
-
await run('openclaw', ['gateway', 'stop'], { cwd: projectDir, env: runtimeEnv });
|
|
2160
|
-
state.botPid = startDetached('openclaw', ['gateway', 'run'], { cwd: projectDir, env: runtimeEnv });
|
|
2161
|
-
sendLog(`[zalouser] Native gateway restarted (pid=${state.botPid || 'unknown'}). Try sending a Zalo message now.`);
|
|
2162
|
-
} catch (err) {
|
|
2163
|
-
sendLog(`[zalouser] Gateway restart failed: ${err.message}. Restart it manually: openclaw gateway run`);
|
|
2164
|
-
}
|
|
2165
|
-
}
|
|
2166
|
-
zaloLoginInFlight = false;
|
|
2167
|
-
} else if (loginAttempt < MAX_LOGIN_ATTEMPTS && !sent) {
|
|
2168
|
-
const delay = RETRY_DELAYS[loginAttempt] || 10000;
|
|
2169
|
-
sendLog(`[zalouser] QR not ready yet. Waiting ${delay / 1000}s before retry...`);
|
|
2170
|
-
setTimeout(runLoginAttempt, delay);
|
|
2171
|
-
} else {
|
|
2172
|
-
sendLog('[zalouser] All login attempts exhausted. Try clicking "Đăng nhập Zalo" again.');
|
|
2173
|
-
zaloLoginInFlight = false;
|
|
2174
|
-
}
|
|
2175
|
-
});
|
|
2176
|
-
};
|
|
2177
|
-
|
|
2178
|
-
runLoginAttempt();
|
|
2179
|
-
return { message: 'Generating Zalo QR (native). The image will appear automatically.' };
|
|
1932
|
+
throw httpError(400, 'Zalo login cần project Docker đang chạy (không tìm thấy docker-compose.yml).');
|
|
2180
1933
|
}
|
|
2181
1934
|
|
|
2182
1935
|
function getBotServiceName(projectDir) {
|
|
@@ -2392,16 +2145,7 @@ async function updateRuntime(target, projectDir) {
|
|
|
2392
2145
|
probeCacheClear();
|
|
2393
2146
|
return { ok: true, target, spec, mode: 'docker' };
|
|
2394
2147
|
}
|
|
2395
|
-
|
|
2396
|
-
if (isRouter) {
|
|
2397
|
-
await run('openclaw', ['gateway', 'stop'], { cwd: projectDir }).catch(() => {});
|
|
2398
|
-
await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
|
|
2399
|
-
} else {
|
|
2400
|
-
await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
|
|
2401
|
-
}
|
|
2402
|
-
await syncRuntimeState(projectDir, { full: true }).catch(() => {});
|
|
2403
|
-
probeCacheClear();
|
|
2404
|
-
return { ok: true, target, spec, mode: 'native' };
|
|
2148
|
+
throw httpError(400, 'Không có project Docker để cập nhật runtime.');
|
|
2405
2149
|
}
|
|
2406
2150
|
|
|
2407
2151
|
async function restartDockerBotContainer(projectDir = state.projectDir) {
|
|
@@ -2604,6 +2348,192 @@ async function writeCoreProject({ projectDir, osChoice, mode, gatewayPort = 1878
|
|
|
2604
2348
|
}
|
|
2605
2349
|
}
|
|
2606
2350
|
|
|
2351
|
+
// Locate a real Chrome/Chromium binary on the host (for the "grant Chrome to the bot" button).
|
|
2352
|
+
async function findChromeBinary() {
|
|
2353
|
+
if (process.platform === 'darwin') {
|
|
2354
|
+
for (const p of [
|
|
2355
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
2356
|
+
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
2357
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
2358
|
+
]) if (existsSync(p)) return p;
|
|
2359
|
+
return '';
|
|
2360
|
+
}
|
|
2361
|
+
if (process.platform === 'win32') {
|
|
2362
|
+
for (const p of [
|
|
2363
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
2364
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
2365
|
+
join(process.env.LOCALAPPDATA || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
2366
|
+
]) if (p && existsSync(p)) return p;
|
|
2367
|
+
return '';
|
|
2368
|
+
}
|
|
2369
|
+
for (const c of ['google-chrome', 'google-chrome-stable', 'chromium-browser', 'chromium']) {
|
|
2370
|
+
const r = await commandExists(c, ['--version']);
|
|
2371
|
+
if (r.ok) return c;
|
|
2372
|
+
}
|
|
2373
|
+
return '';
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// TCP relay for headless VPS: `ssh -R 9222:...` binds the VPS loopback only (sshd GatewayPorts
|
|
2377
|
+
// defaults to "no"), which the bot container cannot reach. This relay listens on the docker
|
|
2378
|
+
// bridge IP (host.docker.internal from inside the container) and pipes to the loopback tunnel.
|
|
2379
|
+
let _chromeRelayServer = null;
|
|
2380
|
+
async function getDockerBridgeIp() {
|
|
2381
|
+
try {
|
|
2382
|
+
const out = await runCapture('sh', ['-c', "ip -4 -o addr show docker0 | awk '{print $4}' | cut -d/ -f1"], { shell: false, timeout: 4000 });
|
|
2383
|
+
const ip = String(out.stdout || '').trim();
|
|
2384
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return ip;
|
|
2385
|
+
} catch {}
|
|
2386
|
+
return '172.17.0.1';
|
|
2387
|
+
}
|
|
2388
|
+
async function ensureChromeRelay() {
|
|
2389
|
+
if (_chromeRelayServer) return true;
|
|
2390
|
+
const bridgeIp = await getDockerBridgeIp();
|
|
2391
|
+
return new Promise((resolveP) => {
|
|
2392
|
+
const relay = net.createServer((client) => {
|
|
2393
|
+
const upstream = net.connect(9222, '127.0.0.1');
|
|
2394
|
+
client.pipe(upstream).pipe(client);
|
|
2395
|
+
client.on('error', () => upstream.destroy());
|
|
2396
|
+
upstream.on('error', () => client.destroy());
|
|
2397
|
+
});
|
|
2398
|
+
relay.once('error', () => resolveP(false)); // EADDRINUSE etc. → likely already relayed
|
|
2399
|
+
relay.listen(9222, bridgeIp, () => {
|
|
2400
|
+
_chromeRelayServer = relay;
|
|
2401
|
+
sendLog(`[chrome] Relay ${bridgeIp}:9222 → 127.0.0.1:9222 sẵn sàng (chờ SSH tunnel từ máy bạn).`);
|
|
2402
|
+
// Ubuntu VPSes usually run ufw with default-deny INPUT, which silently drops container→host
|
|
2403
|
+
// traffic to the relay. Open the port scoped to the PRIVATE bridge IP only (not reachable
|
|
2404
|
+
// from the internet). Best-effort; `ufw allow` skips duplicates on re-runs.
|
|
2405
|
+
run('sh', ['-c', `command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active" && ufw allow in to ${bridgeIp} port 9222 proto tcp comment "openclaw chrome-debug relay (docker bridge only)" || true`])
|
|
2406
|
+
.catch(() => sendLog('[chrome] Không thể tự mở firewall cho relay — nếu bot không thấy Chrome, chạy: ufw allow in to ' + bridgeIp + ' port 9222 proto tcp'));
|
|
2407
|
+
resolveP(true);
|
|
2408
|
+
});
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// Launch real host Chrome in remote-debugging mode (port 9222) so the browser-automation plugin
|
|
2413
|
+
// can drive the user's actual Chrome (logged-in profile) instead of headless Chromium. The bot
|
|
2414
|
+
// reaches it via CDP (host.docker.internal:9222 from the container). Detached: keeps running after
|
|
2415
|
+
// this request. `--remote-allow-origins=*` is required by modern Chrome for cross-origin CDP.
|
|
2416
|
+
// On a headless VPS there is no Chrome to open here — instead we start the bridge relay and hand
|
|
2417
|
+
// back copy-paste commands so the user runs Chrome on THEIR machine + a reverse SSH tunnel.
|
|
2418
|
+
async function startChromeDebug() {
|
|
2419
|
+
if (isHeadlessServer()) {
|
|
2420
|
+
await ensureChromeRelay();
|
|
2421
|
+
const ip = (await getPublicIp().catch(() => '')) || '<IP-VPS>';
|
|
2422
|
+
const user = sshUserName();
|
|
2423
|
+
return {
|
|
2424
|
+
ok: true,
|
|
2425
|
+
headless: true,
|
|
2426
|
+
port: 9222,
|
|
2427
|
+
chromeCmdMac: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --user-data-dir="$HOME/.openclaw-chrome-debug" --remote-allow-origins='*'`,
|
|
2428
|
+
chromeCmdWin: `"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir=%TEMP%\\openclaw-chrome-debug --remote-allow-origins=*`,
|
|
2429
|
+
tunnelCmd: `ssh -N -R 9222:127.0.0.1:9222 ${user}@${ip}`,
|
|
2430
|
+
};
|
|
2431
|
+
}
|
|
2432
|
+
const bin = await findChromeBinary();
|
|
2433
|
+
if (!bin) {
|
|
2434
|
+
throw httpError(400, process.platform === 'linux'
|
|
2435
|
+
? 'Không tìm thấy Chrome/Chromium trên máy. Cài google-chrome hoặc chromium rồi thử lại (không áp dụng cho VPS không có giao diện).'
|
|
2436
|
+
: 'Không tìm thấy Google Chrome. Hãy cài Chrome rồi thử lại.');
|
|
2437
|
+
}
|
|
2438
|
+
const port = 9222;
|
|
2439
|
+
const userDataDir = join(os.tmpdir(), 'openclaw-chrome-debug');
|
|
2440
|
+
const args = [
|
|
2441
|
+
`--remote-debugging-port=${port}`,
|
|
2442
|
+
`--user-data-dir=${userDataDir}`,
|
|
2443
|
+
'--remote-allow-origins=*',
|
|
2444
|
+
'--no-first-run',
|
|
2445
|
+
'--no-default-browser-check',
|
|
2446
|
+
];
|
|
2447
|
+
const child = spawn(bin, args, { detached: true, stdio: 'ignore', windowsHide: false });
|
|
2448
|
+
child.on('error', (e) => sendLog(`[chrome] Không mở được Chrome debug: ${e.message}`));
|
|
2449
|
+
child.unref();
|
|
2450
|
+
sendLog(`[chrome] Đã mở Chrome debug ở cổng ${port} (${bin}). Bot sẽ ưu tiên dùng Chrome này.`);
|
|
2451
|
+
return { ok: true, port, browser: bin, userDataDir };
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// Poll until the Docker daemon responds (or timeout). Returns true if ready.
|
|
2455
|
+
async function waitForDockerDaemon(timeoutMs) {
|
|
2456
|
+
const deadline = Date.now() + timeoutMs;
|
|
2457
|
+
for (;;) {
|
|
2458
|
+
const ok = await commandExists('docker', ['version', '--format', '{{.Server.Version}}']);
|
|
2459
|
+
if (ok.ok) return true;
|
|
2460
|
+
if (Date.now() >= deadline) return false;
|
|
2461
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// Ensure Docker (engine + compose) is available before a docker-mode install, auto-installing the
|
|
2466
|
+
// latest version appropriate for the host OS when it is missing:
|
|
2467
|
+
// • Linux → Docker's official convenience script (get.docker.com; auto-detects the distro) + start daemon
|
|
2468
|
+
// • macOS → Docker Desktop via Homebrew cask, then launch it
|
|
2469
|
+
// • Windows → Docker Desktop via winget (fallback Chocolatey), then launch it
|
|
2470
|
+
// macOS/Windows Docker Desktop needs a GUI/WSL startup (and sometimes a reboot), so we install +
|
|
2471
|
+
// launch + wait, and give a clear next-step if the daemon still isn't up when we time out.
|
|
2472
|
+
async function ensureDockerInstalled(osChoice) {
|
|
2473
|
+
if (await waitForDockerDaemon(0)) return; // already running
|
|
2474
|
+
const cliOk = await commandExists('docker', ['--version']);
|
|
2475
|
+
|
|
2476
|
+
if (process.platform === 'linux') {
|
|
2477
|
+
const root = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
2478
|
+
const sudo = root ? '' : 'sudo ';
|
|
2479
|
+
if (!cliOk.ok) {
|
|
2480
|
+
sendLog('[docker] Chưa có Docker — đang tự cài Docker Engine mới nhất qua script chính thức get.docker.com (1–3 phút)...');
|
|
2481
|
+
await run('sh', ['-c', `curl -fsSL https://get.docker.com -o /tmp/get-docker.sh && ${sudo}sh /tmp/get-docker.sh`]);
|
|
2482
|
+
if (!root) await run('sh', ['-c', 'sudo usermod -aG docker "$USER" || true']).catch(() => {});
|
|
2483
|
+
}
|
|
2484
|
+
sendLog('[docker] Bật & khởi động Docker daemon...');
|
|
2485
|
+
await run('sh', ['-c', `${sudo}systemctl enable --now docker`]).catch(() => {});
|
|
2486
|
+
if (!(await waitForDockerDaemon(20000))) {
|
|
2487
|
+
throw httpError(500, 'Đã cài Docker nhưng daemon chưa chạy. Hãy chạy `systemctl start docker` (hoặc đăng nhập lại nếu vừa thêm vào nhóm docker) rồi cài lại.');
|
|
2488
|
+
}
|
|
2489
|
+
sendLog('[docker] Docker đã sẵn sàng.');
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
if (process.platform === 'darwin') {
|
|
2494
|
+
if (!cliOk.ok) {
|
|
2495
|
+
const brew = await commandExists('brew', ['--version']);
|
|
2496
|
+
if (!brew.ok) {
|
|
2497
|
+
throw httpError(400, 'macOS: cần Homebrew để tự cài Docker Desktop. Cài Homebrew tại https://brew.sh (hoặc cài Docker Desktop thủ công) rồi cài lại.');
|
|
2498
|
+
}
|
|
2499
|
+
sendLog('[docker] macOS: đang cài Docker Desktop mới nhất qua Homebrew (brew install --cask docker)...');
|
|
2500
|
+
await run('brew', ['install', '--cask', 'docker']);
|
|
2501
|
+
}
|
|
2502
|
+
sendLog('[docker] Mở Docker Desktop và chờ daemon khởi động...');
|
|
2503
|
+
await run('open', ['-a', 'Docker']).catch(() => {});
|
|
2504
|
+
if (!(await waitForDockerDaemon(120000))) {
|
|
2505
|
+
throw httpError(500, 'Đã cài Docker Desktop — hãy mở Docker Desktop, hoàn tất cấp quyền lần đầu, đợi biểu tượng cá voi báo "running" rồi cài lại.');
|
|
2506
|
+
}
|
|
2507
|
+
sendLog('[docker] Docker đã sẵn sàng.');
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
if (process.platform === 'win32') {
|
|
2512
|
+
if (!cliOk.ok) {
|
|
2513
|
+
const winget = await commandExists('winget', ['--version']);
|
|
2514
|
+
const choco = await commandExists('choco', ['--version']);
|
|
2515
|
+
if (winget.ok) {
|
|
2516
|
+
sendLog('[docker] Windows: đang cài Docker Desktop mới nhất qua winget...');
|
|
2517
|
+
await run('winget', ['install', '-e', '--id', 'Docker.DockerDesktop', '--accept-source-agreements', '--accept-package-agreements']);
|
|
2518
|
+
} else if (choco.ok) {
|
|
2519
|
+
sendLog('[docker] Windows: đang cài Docker Desktop qua Chocolatey...');
|
|
2520
|
+
await run('choco', ['install', 'docker-desktop', '-y']);
|
|
2521
|
+
} else {
|
|
2522
|
+
throw httpError(400, 'Windows: cần winget hoặc Chocolatey để tự cài Docker Desktop (hoặc cài thủ công tại https://www.docker.com). Cài xong rồi thử lại.');
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
sendLog('[docker] Mở Docker Desktop và chờ daemon khởi động...');
|
|
2526
|
+
await run('cmd', ['/c', 'start', '', '%ProgramFiles%\\Docker\\Docker\\Docker Desktop.exe']).catch(() => {});
|
|
2527
|
+
if (!(await waitForDockerDaemon(120000))) {
|
|
2528
|
+
throw httpError(500, 'Đã cài Docker Desktop — Windows có thể cần bật WSL2 và khởi động lại máy. Hãy mở Docker Desktop, đợi "running" (hoặc reboot nếu được yêu cầu) rồi cài lại.');
|
|
2529
|
+
}
|
|
2530
|
+
sendLog('[docker] Docker đã sẵn sàng.');
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
throw httpError(400, 'Hệ điều hành không được hỗ trợ tự cài Docker. Hãy cài Docker thủ công rồi thử lại.');
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2607
2537
|
async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, routerPort = 20128 }) {
|
|
2608
2538
|
state.installing = true;
|
|
2609
2539
|
state.installed = false;
|
|
@@ -2615,6 +2545,9 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
|
|
|
2615
2545
|
try {
|
|
2616
2546
|
sendLog('OpenClaw local installer started');
|
|
2617
2547
|
sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
|
|
2548
|
+
// Make sure Docker is present (auto-install on Linux/VPS) before doing any work — fail fast
|
|
2549
|
+
// with a clear message rather than deep inside `docker compose up`.
|
|
2550
|
+
await ensureDockerInstalled(osChoice);
|
|
2618
2551
|
await writeCoreProject({ projectDir, osChoice, mode, gatewayPort, routerPort });
|
|
2619
2552
|
await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
|
|
2620
2553
|
await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
|
|
@@ -2631,63 +2564,6 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
|
|
|
2631
2564
|
await run('docker', ['compose', 'up', '-d', '--build'], { cwd: dockerDir });
|
|
2632
2565
|
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
2633
2566
|
await recreateDockerBot(projectDir).catch(() => {});
|
|
2634
|
-
} else {
|
|
2635
|
-
const nineRouterDataDir = join(projectDir, '.9router');
|
|
2636
|
-
const nineRouterDbPath = join(nineRouterDataDir, 'db', 'data.sqlite');
|
|
2637
|
-
const runtimeEnv = {
|
|
2638
|
-
OPENCLAW_HOME: join(projectDir, '.openclaw'),
|
|
2639
|
-
OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
|
|
2640
|
-
DATA_DIR: nineRouterDataDir,
|
|
2641
|
-
OPENCLAW_GATEWAY_PORT: String(gatewayPort),
|
|
2642
|
-
OPENCLAW_PORT: String(gatewayPort),
|
|
2643
|
-
// Parity with the docker runtime env (docker-gen.js) so the gateway and browser plugin
|
|
2644
|
-
// behave the same natively: allow the private-network control-UI websocket and tell the
|
|
2645
|
-
// browser plugin which host OS it is running on.
|
|
2646
|
-
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: '1',
|
|
2647
|
-
OPENCLAW_SETUP_OS: osChoice || '',
|
|
2648
|
-
OPENCLAW_BROWSER_HOST_OS: osChoice || '',
|
|
2649
|
-
};
|
|
2650
|
-
await run('openclaw', ['gateway', 'stop'], { cwd: projectDir, env: runtimeEnv }).catch(() => {});
|
|
2651
|
-
// 9router binds loopback in native mode on purpose: openclaw runs on the SAME host and
|
|
2652
|
-
// reaches it via localhost (see get9RouterBaseUrl), so there is no reason to expose the LLM
|
|
2653
|
-
// proxy on 0.0.0.0 — even on a VPS that would be an open relay. Only the gateway is exposed.
|
|
2654
|
-
// Start 9router first (auto-restart supervised), then sync, then the gateway — so the gateway
|
|
2655
|
-
// boots after 9router is reachable and after the API key has been written to its config.
|
|
2656
|
-
const routerMethod = await startNativeService({
|
|
2657
|
-
projectDir, name: '9router', cmd: '9router', desc: `OpenClaw 9router (${nativeServiceId(projectDir)})`,
|
|
2658
|
-
args: ['-n', '-l', '-H', '127.0.0.1', '-p', String(routerPort), '--skip-update'], env: runtimeEnv,
|
|
2659
|
-
});
|
|
2660
|
-
// Smart-route auto-sync: the docker sidecar runs a script that disables 9router's login
|
|
2661
|
-
// requirement and builds the default `smart-route` combo from the active providers. Native
|
|
2662
|
-
// mode skipped this, so the default model had no backing models. Reuse the exact docker
|
|
2663
|
-
// script with the native DB path (passed via env to stay cross-platform / shell-safe).
|
|
2664
|
-
try {
|
|
2665
|
-
const artifacts = buildDockerArtifacts({ is9Router: true, osChoice, openClawNpmSpec: OPENCLAW_NPM_SPEC, gatewayPort, routerPort });
|
|
2666
|
-
if (artifacts.syncScript) {
|
|
2667
|
-
await fsp.mkdir(nineRouterDataDir, { recursive: true });
|
|
2668
|
-
const syncPath = join(nineRouterDataDir, 'sync.js');
|
|
2669
|
-
await fsp.writeFile(syncPath, artifacts.syncScript, 'utf8');
|
|
2670
|
-
// Run the sync under supervision too (managed service) so it is torn down cleanly on
|
|
2671
|
-
// delete instead of leaking an orphaned `node sync.js` process.
|
|
2672
|
-
await startNativeService({
|
|
2673
|
-
projectDir, name: 'sync', cmd: process.execPath, args: [syncPath],
|
|
2674
|
-
desc: `OpenClaw 9router smart-route sync (${nativeServiceId(projectDir)})`,
|
|
2675
|
-
env: { ...runtimeEnv, NINEROUTER_DB_PATH: nineRouterDbPath, PORT: String(routerPort) },
|
|
2676
|
-
});
|
|
2677
|
-
sendLog('[native] 9router smart-route sync started');
|
|
2678
|
-
}
|
|
2679
|
-
} catch (e) {
|
|
2680
|
-
sendLog(`[native] smart-route sync setup skipped: ${e.message}`);
|
|
2681
|
-
}
|
|
2682
|
-
// Give 9router a moment to boot + sync to disable its login gate, then resolve its API key
|
|
2683
|
-
// into openclaw.json BEFORE the gateway starts (so the gateway reads it on first boot).
|
|
2684
|
-
await new Promise((r) => setTimeout(r, 8000));
|
|
2685
|
-
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
2686
|
-
const gatewayMethod = await startNativeService({
|
|
2687
|
-
projectDir, name: 'gateway', cmd: 'openclaw', desc: `OpenClaw gateway (${nativeServiceId(projectDir)})`,
|
|
2688
|
-
args: ['gateway', 'run'], env: runtimeEnv,
|
|
2689
|
-
});
|
|
2690
|
-
sendLog(`Native runtime started — 9router via ${routerMethod}, gateway via ${gatewayMethod}${state.botPid ? ` (pid=${state.botPid})` : ''}`);
|
|
2691
2567
|
}
|
|
2692
2568
|
state.installed = true;
|
|
2693
2569
|
sendLog('✅ Install completed');
|
|
@@ -3092,10 +2968,6 @@ async function deleteProjectFolder(projectDir, rootProjectDir) {
|
|
|
3092
2968
|
});
|
|
3093
2969
|
// Sleep 2.5 seconds to let Windows file system release overlays/locks
|
|
3094
2970
|
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
3095
|
-
} else {
|
|
3096
|
-
// Native project: stop & remove any launchd/systemd auto-restart services so they don't keep
|
|
3097
|
-
// respawning a deleted bot. No-op when no services were registered.
|
|
3098
|
-
await removeNativeAutostart(resolved);
|
|
3099
2971
|
}
|
|
3100
2972
|
|
|
3101
2973
|
try {
|
|
@@ -3984,6 +3856,9 @@ async function handler(req, res, rootProjectDir) {
|
|
|
3984
3856
|
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
3985
3857
|
return json(res, await addBotMount(projectDir, body.hostPath, body.mountName));
|
|
3986
3858
|
}
|
|
3859
|
+
if (url.pathname === '/api/browser/start-chrome-debug' && req.method === 'POST') {
|
|
3860
|
+
return json(res, await startChromeDebug());
|
|
3861
|
+
}
|
|
3987
3862
|
if (url.pathname === '/api/setup/update' && req.method === 'POST') {
|
|
3988
3863
|
const installerDir = resolve(__dirname, '../..');
|
|
3989
3864
|
const isGit = existsSync(resolve(installerDir, '.git'));
|
|
@@ -4100,11 +3975,19 @@ async function handler(req, res, rootProjectDir) {
|
|
|
4100
3975
|
return json(res, { name, content: await fsp.readFile(file, 'utf8') });
|
|
4101
3976
|
}
|
|
4102
3977
|
if (req.method === 'PUT') {
|
|
4103
|
-
|
|
3978
|
+
// Allow the same text types the file tree marks editable (it exposes .json/.js/.yml/…,
|
|
3979
|
+
// not just .md — the old .md-only guard made "Save" silently fail on those files).
|
|
3980
|
+
const writableExt = new Set(['.md', '.txt', '.json', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.yml', '.yaml', '.env', '.sh', '.bat', '.ps1', '.html', '.css']);
|
|
3981
|
+
if (!writableExt.has(extname(name).toLowerCase())) throw httpError(400, `Loại file này không hỗ trợ sửa từ UI (${extname(name) || 'không có đuôi'})`);
|
|
4104
3982
|
const body = await readJson(req);
|
|
4105
3983
|
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
4106
3984
|
const file = safeJoin(projectDir, name);
|
|
4107
|
-
|
|
3985
|
+
const content = String(body.content || '');
|
|
3986
|
+
// Don't let a typo brick openclaw.json & friends — reject invalid JSON with a clear error.
|
|
3987
|
+
if (extname(name).toLowerCase() === '.json') {
|
|
3988
|
+
try { JSON.parse(content); } catch (e) { throw httpError(400, `JSON không hợp lệ: ${e.message}`); }
|
|
3989
|
+
}
|
|
3990
|
+
await fsp.writeFile(file, content, 'utf8');
|
|
4108
3991
|
return json(res, { ok: true });
|
|
4109
3992
|
}
|
|
4110
3993
|
}
|
|
@@ -4318,7 +4201,7 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
4318
4201
|
printRemoteAccessHint(port).catch(() => {});
|
|
4319
4202
|
}
|
|
4320
4203
|
|
|
4321
|
-
export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloUserLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder
|
|
4204
|
+
export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloUserLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder };
|
|
4322
4205
|
|
|
4323
4206
|
|
|
4324
4207
|
|