create-openclaw-bot 5.15.4 → 5.16.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 +2 -2
- package/README.vi.md +2 -2
- package/dist/server/local-server.js +435 -23
- package/dist/setup/shared/common-gen.js +14 -2
- package/dist/setup/shared/docker-gen.js +51 -1
- package/dist/web/app.js +65 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*Run one command → open the dashboard → your bot is live. Windows · macOS · Linux · VPS — Docker-powered, auto-installed for you.*
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
-
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.
|
|
10
|
+
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.16.1-0EA5E9?style=for-the-badge" alt="Version 5.16.1" /></a>
|
|
11
11
|
<a href="https://github.com/tuanminhhole/openclaw-setup?tab=MIT-1-ov-file"><img src="https://img.shields.io/badge/LICENSE-MIT-success?style=for-the-badge" alt="MIT License" /></a>
|
|
12
12
|
<a href="https://www.npmjs.com/package/create-openclaw-bot"><img src="https://img.shields.io/npm/v/create-openclaw-bot?style=for-the-badge&label=CLI&color=2563EB&logo=npm&logoColor=white" alt="NPM Version" /></a>
|
|
13
13
|
<a href="https://github.com/tuanminhhole/openclaw-setup/stargazers"><img src="https://img.shields.io/github/stars/tuanminhhole/openclaw-setup?style=for-the-badge&color=eab308&logo=github&logoColor=white" alt="GitHub Stars" /></a>
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
|
40
|
-
## 🆕 What's New in v5.
|
|
40
|
+
## 🆕 What's New in v5.16.1
|
|
41
41
|
|
|
42
42
|
- 🖥️ **"Control PC" — let the bot open Chrome & apps on your machine**: a new toggle on each bot card turns on the ability for the bot to open Chrome or an allow-listed app (TeamViewer, Zalo, …) on the computer running it. **Off by default**, token-gated, allow-list only, desktop-only.
|
|
43
43
|
- 🌐 **Browsing works out of the box on every OS**: the bot drives your real Chrome on a desktop (your logged-in profile, so sites don't flag it) and starts its own headless Chromium on a server — no more "no browser available" just because Chrome wasn't running.
|
package/README.vi.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*Chạy một lệnh → mở dashboard → bot lên sóng. Windows · macOS · Linux · VPS — chạy trên Docker, tự cài giúp bạn.*
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
-
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.
|
|
10
|
+
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.16.1-0EA5E9?style=for-the-badge" alt="Version 5.16.1" /></a>
|
|
11
11
|
<a href="https://github.com/tuanminhhole/openclaw-setup?tab=MIT-1-ov-file"><img src="https://img.shields.io/badge/LICENSE-MIT-success?style=for-the-badge" alt="MIT License" /></a>
|
|
12
12
|
<a href="https://www.npmjs.com/package/create-openclaw-bot"><img src="https://img.shields.io/npm/v/create-openclaw-bot?style=for-the-badge&label=CLI&color=2563EB&logo=npm&logoColor=white" alt="NPM Version" /></a>
|
|
13
13
|
<a href="https://github.com/tuanminhhole/openclaw-setup/stargazers"><img src="https://img.shields.io/github/stars/tuanminhhole/openclaw-setup?style=for-the-badge&color=eab308&logo=github&logoColor=white" alt="GitHub Stars" /></a>
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
---
|
|
39
39
|
|
|
40
|
-
## 🆕 Có gì mới trong v5.
|
|
40
|
+
## 🆕 Có gì mới trong v5.16.1
|
|
41
41
|
|
|
42
42
|
- 🖥️ **"Điều khiển máy" — cho bot mở Chrome & ứng dụng trên máy bạn**: nút mới trên thẻ mỗi bot, bật lên là bot mở được Chrome hoặc app trong danh sách cho phép (TeamViewer, Zalo, …) trên máy đang chạy bot. **Mặc định TẮT**, cần token, chỉ giới hạn danh sách bạn khai, chỉ dùng trên máy có màn hình.
|
|
43
43
|
- 🌐 **Duyệt web chạy ngay trên mọi hệ điều hành**: máy để bàn thì bot dùng **Chrome thật** (profile đã đăng nhập, web ít nghi là bot), server thì tự mở Chromium headless — hết cảnh "không có trình duyệt" chỉ vì chưa mở Chrome.
|
|
@@ -15,7 +15,7 @@ function loadSharedModule(modulePath, globalName) {
|
|
|
15
15
|
}
|
|
16
16
|
const { buildWorkspaceFileMap, buildCronjobSkillMd, buildInfographicGeneratorSkillMd, buildInfographicGeneratorJs } = loadSharedModule('../setup/shared/workspace-gen.js', '__openclawWorkspace');
|
|
17
17
|
const { buildOpenclawJson, buildEnvFileContent, buildExecApprovalsJson, buildZaloConnectChannelConfig } = loadSharedModule('../setup/shared/bot-config-gen.js', '__openclawBotConfig');
|
|
18
|
-
const { buildDockerArtifacts } = loadSharedModule('../setup/shared/docker-gen.js', '__openclawDockerGen');
|
|
18
|
+
const { buildDockerArtifacts, contextDefaultsScript } = loadSharedModule('../setup/shared/docker-gen.js', '__openclawDockerGen');
|
|
19
19
|
const { HOST_UI_PS1, HOST_UI_PS1_VERSION } = loadSharedModule('../setup/shared/host-ui-ps1.js', '__openclawHostUiPs1');
|
|
20
20
|
const { OPENCLAW_NPM_SPEC, NINE_ROUTER_NPM_SPEC, ZALO_CHANNEL_ID, ZALO_PLUGIN_ID, ZALO_CONNECT_VERSION, ZALO_CONNECT_PLUGIN_SPEC, build9RouterProviderConfig, get9RouterBaseUrl } = loadSharedModule('../setup/shared/common-gen.js', '__openclawCommon');
|
|
21
21
|
const dataExport = loadSharedModule('../setup/data/index.js', '__openclawData');
|
|
@@ -646,18 +646,73 @@ function getRealHomedir() {
|
|
|
646
646
|
return home;
|
|
647
647
|
}
|
|
648
648
|
|
|
649
|
+
/**
|
|
650
|
+
* Directories to search when a bare command is not sitting next to the running node.
|
|
651
|
+
*
|
|
652
|
+
* resolveBinPath's original assumption — "the CLI lives beside the node that runs me" — holds only
|
|
653
|
+
* while the Setup UI is launched by the same node that installed openclaw globally. It breaks the
|
|
654
|
+
* moment the UI runs under the SYSTEM node while openclaw lives in an nvm prefix: `openclaw` stays a
|
|
655
|
+
* bare name, spawn's own PATH lookup finds nothing either, and every skill/plugin install dies with
|
|
656
|
+
* `spawn openclaw ENOENT`. Hit on a customer VPS 2026-08-28, right after the UI became a systemd
|
|
657
|
+
* unit whose PATH carried only the system directories — `openclaw` existed the whole time, at
|
|
658
|
+
* /root/.nvm/versions/node/v24.20.0/bin/openclaw.
|
|
659
|
+
*
|
|
660
|
+
* PATH first (so an operator's own choice still wins), then nvm's per-version bins newest first,
|
|
661
|
+
* then the usual global npm prefixes. Cached: this runs on every spawn.
|
|
662
|
+
*/
|
|
663
|
+
let extraBinDirsCache = null;
|
|
664
|
+
function extraBinDirs() {
|
|
665
|
+
if (extraBinDirsCache) return extraBinDirsCache;
|
|
666
|
+
const dirs = String(process.env.PATH || '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
|
|
667
|
+
const home = os.homedir();
|
|
668
|
+
const nvmRoot = process.env.NVM_DIR ? join(process.env.NVM_DIR, 'versions', 'node') : join(home, '.nvm', 'versions', 'node');
|
|
669
|
+
// Newest version first: a box with several nvm nodes almost always installed the CLI under the
|
|
670
|
+
// latest one, and picking the oldest would run openclaw on a node its engines field rejects.
|
|
671
|
+
try {
|
|
672
|
+
for (const v of fs.readdirSync(nvmRoot).sort().reverse()) dirs.push(join(nvmRoot, v, 'bin'));
|
|
673
|
+
} catch (e) {}
|
|
674
|
+
for (const d of ['/usr/local/bin', '/usr/bin', '/opt/homebrew/bin', join(home, '.npm-global', 'bin'), join(home, '.local', 'bin')]) dirs.push(d);
|
|
675
|
+
extraBinDirsCache = dirs;
|
|
676
|
+
return dirs;
|
|
677
|
+
}
|
|
678
|
+
|
|
649
679
|
function resolveBinPath(cmd) {
|
|
650
680
|
if (!cmd || cmd.includes('/') || cmd.includes('\\')) return cmd;
|
|
681
|
+
const names = process.platform === 'win32' ? [`${cmd}.cmd`, `${cmd}.exe`, cmd] : [cmd];
|
|
651
682
|
const nodeBinDir = dirname(process.argv[0]);
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
683
|
+
for (const name of names) {
|
|
684
|
+
const p = join(nodeBinDir, name);
|
|
685
|
+
if (existsSync(p)) return p;
|
|
686
|
+
}
|
|
656
687
|
const nodeModulesBin = join(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? `${cmd}.cmd` : cmd);
|
|
657
688
|
if (existsSync(nodeModulesBin)) return nodeModulesBin;
|
|
689
|
+
for (const dir of extraBinDirs()) {
|
|
690
|
+
for (const name of names) {
|
|
691
|
+
const p = join(dir, name);
|
|
692
|
+
if (existsSync(p)) return p;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
658
695
|
return cmd;
|
|
659
696
|
}
|
|
660
697
|
|
|
698
|
+
/**
|
|
699
|
+
* Child env for a resolved binary, with the binary's own directory on PATH.
|
|
700
|
+
*
|
|
701
|
+
* Load-bearing for anything picked out of an nvm prefix: `openclaw` there is a shebang script
|
|
702
|
+
* (`#!/usr/bin/env node`), so running it by absolute path still resolves `node` from the CHILD's
|
|
703
|
+
* PATH. Without this the CLI is found and then executed by whatever node happens to be first —
|
|
704
|
+
* the system one — which is not the node it was installed for.
|
|
705
|
+
*/
|
|
706
|
+
function binEnv(bin, extra = {}) {
|
|
707
|
+
const env = { ...process.env, ...extra };
|
|
708
|
+
if (!bin.includes('/') && !bin.includes('\\')) return env;
|
|
709
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
710
|
+
const dir = dirname(bin);
|
|
711
|
+
const cur = String(env.PATH || '').split(sep).filter(Boolean);
|
|
712
|
+
if (!cur.includes(dir)) env.PATH = [dir, ...cur].join(sep);
|
|
713
|
+
return env;
|
|
714
|
+
}
|
|
715
|
+
|
|
661
716
|
// Blacklist of Windows system/large directories that should never be walked
|
|
662
717
|
const SYSTEM_DIR_BLACKLIST = new Set([
|
|
663
718
|
'windows', 'program files', 'program files (x86)', 'programdata',
|
|
@@ -704,7 +759,7 @@ function run(cmd, args, opts = {}) {
|
|
|
704
759
|
const shell = process.platform === 'win32';
|
|
705
760
|
const rawBin = resolveBinPath(cmd);
|
|
706
761
|
const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
|
|
707
|
-
const child = spawn(bin, args, { cwd: opts.cwd, shell, env:
|
|
762
|
+
const child = spawn(bin, args, { cwd: opts.cwd, shell, env: binEnv(rawBin, opts.env) });
|
|
708
763
|
let stdout = '';
|
|
709
764
|
let resolved = false;
|
|
710
765
|
child.stdout.on('data', (d) => {
|
|
@@ -877,7 +932,7 @@ function runStreamed(cmd, args, opts = {}) {
|
|
|
877
932
|
cwd: opts.cwd,
|
|
878
933
|
shell,
|
|
879
934
|
windowsHide: opts.windowsHide ?? true,
|
|
880
|
-
env:
|
|
935
|
+
env: binEnv(rawBin, opts.env),
|
|
881
936
|
});
|
|
882
937
|
child.stdout.on('data', (d) => sendLog(String(d).trimEnd()));
|
|
883
938
|
child.stderr.on('data', (d) => sendLog(String(d).trimEnd()));
|
|
@@ -895,7 +950,7 @@ function runStreamedToLogFile(cmd, args, logFile, opts = {}) {
|
|
|
895
950
|
cwd: opts.cwd,
|
|
896
951
|
shell,
|
|
897
952
|
windowsHide: opts.windowsHide ?? true,
|
|
898
|
-
env:
|
|
953
|
+
env: binEnv(rawBin, opts.env),
|
|
899
954
|
});
|
|
900
955
|
let offset = 0;
|
|
901
956
|
const poll = setInterval(async () => {
|
|
@@ -923,7 +978,7 @@ function runCapture(cmd, args, opts = {}) {
|
|
|
923
978
|
cwd: opts.cwd,
|
|
924
979
|
shell,
|
|
925
980
|
windowsHide: opts.windowsHide ?? true,
|
|
926
|
-
env:
|
|
981
|
+
env: binEnv(rawBin, opts.env),
|
|
927
982
|
});
|
|
928
983
|
// Some callers need to feed stdin (pbcopy/xclip take the clipboard text that way).
|
|
929
984
|
if (opts.input != null) {
|
|
@@ -1605,6 +1660,26 @@ function validateOpenclawConfig(cfg) {
|
|
|
1605
1660
|
}
|
|
1606
1661
|
if (!cfg.channels || typeof cfg.channels !== 'object') throw httpError(500, 'openclaw.json missing channels');
|
|
1607
1662
|
|
|
1663
|
+
// Self-healing: two zalo-connect bindings can never share one account — one Zalo number, one
|
|
1664
|
+
// listener. An older edit path hardcoded `accountId: 'default'`, so an edited bot could end up
|
|
1665
|
+
// squatting the first bot's account while its own account sat unbound (no listener, and the
|
|
1666
|
+
// other bot answered in its place). Only repair where the intent is unambiguous: the agent
|
|
1667
|
+
// already has an account of its own provisioned under its agentId.
|
|
1668
|
+
const zaloAccounts = cfg.channels?.['zalo-connect']?.accounts;
|
|
1669
|
+
if (zaloAccounts) {
|
|
1670
|
+
const owner = new Map();
|
|
1671
|
+
for (const binding of cfg.bindings || []) {
|
|
1672
|
+
if (binding.match?.channel !== 'zalo-connect') continue;
|
|
1673
|
+
const accountId = binding.match?.accountId || 'default';
|
|
1674
|
+
const agentId = binding.agentId;
|
|
1675
|
+
if (!owner.has(accountId)) { owner.set(accountId, agentId); continue; }
|
|
1676
|
+
if (owner.get(accountId) === agentId || !zaloAccounts[agentId]) continue;
|
|
1677
|
+
binding.match.accountId = agentId;
|
|
1678
|
+
owner.set(agentId, agentId);
|
|
1679
|
+
sendLog(`[config] Bot "${agentId}" đang dùng chung account Zalo "${accountId}" với "${owner.get(accountId)}" — đã trả về account riêng "${agentId}".`);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1608
1683
|
// Self-healing: Garbage collect any orphaned telegram accounts that are no longer bound to any active agent
|
|
1609
1684
|
if (cfg.channels?.telegram?.accounts) {
|
|
1610
1685
|
const boundAccountIds = new Set(
|
|
@@ -2012,9 +2087,15 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
|
|
|
2012
2087
|
agent.workspace = `/home/node/project/.openclaw/${workspaceDir}`;
|
|
2013
2088
|
agent.agentDir = `agents/${agentId}/agent`;
|
|
2014
2089
|
|
|
2015
|
-
//
|
|
2016
|
-
|
|
2017
|
-
|
|
2090
|
+
// Capture this agent's current accountId per channel BEFORE its bindings are dropped.
|
|
2091
|
+
// The zalo-connect branch below used to hardcode 'default' (and this lookup only covered
|
|
2092
|
+
// telegram), so editing ANY Zalo bot re-pointed it at the FIRST bot's Zalo account: two
|
|
2093
|
+
// bindings claimed `default`, the earlier one won, and the edited bot's own account was left
|
|
2094
|
+
// with no binding at all — so its listener never started and messages were answered by the
|
|
2095
|
+
// other bot. Seen live on a 5-bot install (bot answered under the first bot's name).
|
|
2096
|
+
const previousBindings = (cfg.bindings || []).filter((b) => b.agentId === agentId);
|
|
2097
|
+
const previousAccountId = (ch) => previousBindings.find((b) => b.match?.channel === ch)?.match?.accountId || null;
|
|
2098
|
+
const existingAccountId = previousAccountId('telegram');
|
|
2018
2099
|
cfg.bindings = (cfg.bindings || []).filter((b) => b.agentId !== agentId);
|
|
2019
2100
|
if (channel === 'telegram') {
|
|
2020
2101
|
ensureTelegramChannel(cfg);
|
|
@@ -2030,7 +2111,16 @@ async function updateBotInProject(projectDir, agentId, body = {}, runtime = {})
|
|
|
2030
2111
|
cfg.bindings.push({ agentId, match: { channel: 'telegram', accountId } });
|
|
2031
2112
|
} else if (channel === 'zalo-personal') {
|
|
2032
2113
|
ensureZaloConnectChannel(cfg);
|
|
2033
|
-
|
|
2114
|
+
const zc = cfg.channels['zalo-connect'];
|
|
2115
|
+
zc.accounts = zc.accounts || {};
|
|
2116
|
+
// Keep the account this bot already owns; only a bot that never had one falls back to the
|
|
2117
|
+
// create-path rule (first Zalo bot takes `default`, the rest get their own agentId account).
|
|
2118
|
+
// This agent's bindings are already filtered out above, so what remains belongs to others.
|
|
2119
|
+
const otherZaloBindings = cfg.bindings.filter((b) => b.match?.channel === 'zalo-connect').length;
|
|
2120
|
+
const accountId = previousAccountId('zalo-connect') || (otherZaloBindings === 0 ? 'default' : agentId);
|
|
2121
|
+
zc.accounts[accountId] = zc.accounts[accountId] || { enabled: true };
|
|
2122
|
+
zc.defaultAccount = zc.defaultAccount || 'default';
|
|
2123
|
+
cfg.bindings.push({ agentId, match: { channel: 'zalo-connect', accountId } });
|
|
2034
2124
|
} else if (channel === 'fb-messenger') {
|
|
2035
2125
|
ensureFbMessengerChannel(cfg, String(body.pageId || '').trim(), String(body.appId || '').trim());
|
|
2036
2126
|
cfg.bindings.push({ agentId, match: { channel: 'fb-messenger', accountId: 'default' } });
|
|
@@ -2640,6 +2730,11 @@ const NATIVE_MARKER = 'native.json';
|
|
|
2640
2730
|
// handles coexistence by asking the host what is actually taken, which the fixed offset never did.
|
|
2641
2731
|
const NATIVE_DEFAULT_GATEWAY_PORT = 18789;
|
|
2642
2732
|
const NATIVE_DEFAULT_ROUTER_PORT = 20128;
|
|
2733
|
+
// The gateway's startup migrations hold a lease on the state directory for FIVE MINUTES, and a boot
|
|
2734
|
+
// that collides with it exits immediately instead of waiting. Any health wait shorter than that
|
|
2735
|
+
// lease reports failure on a gateway that was always going to come up on its own — the old 180s
|
|
2736
|
+
// could not outlast it even in the best case.
|
|
2737
|
+
const NATIVE_GATEWAY_HEALTH_TIMEOUT_MS = 420000;
|
|
2643
2738
|
|
|
2644
2739
|
function nativeMarkerPath(projectDir) {
|
|
2645
2740
|
return join(projectDir || state.projectDir || '', '.openclaw', NATIVE_MARKER);
|
|
@@ -2717,13 +2812,22 @@ async function waitForNativeGatewayHealthy(projectDir, timeoutMs = 120000) {
|
|
|
2717
2812
|
const port = String(meta.gatewayPort || state.gatewayPort || NATIVE_DEFAULT_GATEWAY_PORT);
|
|
2718
2813
|
const started = Date.now();
|
|
2719
2814
|
let attempts = 0;
|
|
2815
|
+
let diagnosed = false;
|
|
2720
2816
|
while (Date.now() - started < timeoutMs) {
|
|
2721
2817
|
if (await probeHttpOk(`http://127.0.0.1:${port}/health`, 2500)) return true;
|
|
2722
2818
|
attempts++;
|
|
2723
2819
|
if (attempts % 5 === 0) sendLog(`[native] Waiting for gateway on ${port}... (${Math.round((Date.now() - started) / 1000)}s)`);
|
|
2820
|
+
// Half a minute of silence is already abnormal for a gateway that is merely slow, and the two
|
|
2821
|
+
// causes that never resolve on their own (a stolen port, an abandoned unit) look exactly like
|
|
2822
|
+
// "still booting" from here. Say what is wrong once, early, and keep waiting either way.
|
|
2823
|
+
if (attempts === 10 && !diagnosed) {
|
|
2824
|
+
diagnosed = true;
|
|
2825
|
+
await reportNativeGatewayBlockage(projectDir, port).catch(() => {});
|
|
2826
|
+
}
|
|
2724
2827
|
await new Promise((r) => setTimeout(r, 3000));
|
|
2725
2828
|
}
|
|
2726
2829
|
sendLog(`[native] Gateway did not answer /health on ${port} within ${Math.round(timeoutMs / 1000)}s.`);
|
|
2830
|
+
if (!diagnosed) await reportNativeGatewayBlockage(projectDir, port).catch(() => {});
|
|
2727
2831
|
return false;
|
|
2728
2832
|
}
|
|
2729
2833
|
|
|
@@ -2766,7 +2870,33 @@ async function ocDaemon(projectDir, verb, extraArgs = []) {
|
|
|
2766
2870
|
* perfectly healthy gateway reports "restart failed" — which used to send callers down a pointless
|
|
2767
2871
|
* stop+start that raced the migration lease all over again.
|
|
2768
2872
|
*/
|
|
2873
|
+
/**
|
|
2874
|
+
* Docker projects replay the idempotent config migrations on every container start — the
|
|
2875
|
+
* entrypoint embeds contextDefaultsScript. Native projects have no entrypoint, so an existing
|
|
2876
|
+
* native bot never received those fixes (a bot created before 5.16.0 kept the smart-route
|
|
2877
|
+
* contextWindow at 200000 and stayed exposed to the compaction deadlock). Replaying the exact
|
|
2878
|
+
* same script here, right before every gateway (re)start, gives native the docker semantics:
|
|
2879
|
+
* migrate first, then boot on the upgraded config.
|
|
2880
|
+
*/
|
|
2881
|
+
async function runNativeConfigMigrations(projectDir) {
|
|
2882
|
+
const res = await runCapture(process.execPath, ['-e', contextDefaultsScript], {
|
|
2883
|
+
cwd: projectDir,
|
|
2884
|
+
env: nativeEnv(projectDir),
|
|
2885
|
+
shell: false,
|
|
2886
|
+
});
|
|
2887
|
+
if (res.code !== 0) sendLog(`[native] config migrations skipped: ${String(res.stderr || res.stdout || '').trim().slice(0, 200)}`);
|
|
2888
|
+
return res.code === 0;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2769
2891
|
async function restartNativeRuntime(projectDir) {
|
|
2892
|
+
// Every restart is a chance to repair a project installed before these fixes existed — the
|
|
2893
|
+
// calls are no-ops once the service env is complete, stray files are adopted, and the config
|
|
2894
|
+
// already carries the migrated defaults.
|
|
2895
|
+
await adoptStrayNativeHome(projectDir).catch(() => {});
|
|
2896
|
+
await syncNativeServiceEnv(projectDir).catch(() => {});
|
|
2897
|
+
await runNativeConfigMigrations(projectDir).catch(() => {});
|
|
2898
|
+
await hardenNativeServiceRestarts(projectDir).catch(() => {});
|
|
2899
|
+
await clearNativeServiceFailure(projectDir).catch(() => {});
|
|
2770
2900
|
const stopStart = async () => {
|
|
2771
2901
|
await ocDaemon(projectDir, 'stop');
|
|
2772
2902
|
return ocDaemon(projectDir, 'start');
|
|
@@ -2791,14 +2921,111 @@ async function restartNativeRuntime(projectDir) {
|
|
|
2791
2921
|
res = await ocDaemon(projectDir, 'restart');
|
|
2792
2922
|
if (res.code !== 0) res = await stopStart();
|
|
2793
2923
|
}
|
|
2794
|
-
//
|
|
2795
|
-
//
|
|
2796
|
-
|
|
2924
|
+
// With the start limit lifted (hardenNativeServiceRestarts) systemd really does keep restarting a
|
|
2925
|
+
// crash-looping unit every RestartSec, so a gateway blocked by a lease we never saw still comes up
|
|
2926
|
+
// on its own — give it the full lease window before calling the restart a failure.
|
|
2927
|
+
if (!(await waitForNativeGatewayHealthy(projectDir, NATIVE_GATEWAY_HEALTH_TIMEOUT_MS))) {
|
|
2797
2928
|
throw new Error('gateway did not answer /health after restart');
|
|
2798
2929
|
}
|
|
2799
2930
|
return true;
|
|
2800
2931
|
}
|
|
2801
2932
|
|
|
2933
|
+
/**
|
|
2934
|
+
* Make the generated service carry everything nativeEnv() promises.
|
|
2935
|
+
*
|
|
2936
|
+
* `openclaw daemon install` propagates only a fixed allow-list into the service it writes:
|
|
2937
|
+
* OPENCLAW_STATE_DIR survives, but OPENCLAW_HOME does NOT — verified on both a systemd user unit and
|
|
2938
|
+
* a launchd env-wrapper. Anything resolving paths from OPENCLAW_HOME then falls back to `~/.openclaw`
|
|
2939
|
+
* and writes OUTSIDE the project. zalo-connect is the visible casualty: it stages inbound files and
|
|
2940
|
+
* its Zalo session credentials under the wrong home, so a PDF sent to the bot lands somewhere the
|
|
2941
|
+
* agent's workspace cannot reach ("em chưa trích xuất được nội dung từ PDF") and the session sits in
|
|
2942
|
+
* a different home from the config that describes it.
|
|
2943
|
+
*
|
|
2944
|
+
* Idempotent: keys already present are left alone, so this is safe to run on every restart and it
|
|
2945
|
+
* self-heals projects created before the fix.
|
|
2946
|
+
*/
|
|
2947
|
+
async function syncNativeServiceEnv(projectDir) {
|
|
2948
|
+
if (!isNativeProject(projectDir)) return [];
|
|
2949
|
+
const want = nativeEnv(projectDir);
|
|
2950
|
+
const label = nativeServiceLabel(projectDir);
|
|
2951
|
+
|
|
2952
|
+
if (process.platform === 'linux') {
|
|
2953
|
+
const unit = `${label}.service`;
|
|
2954
|
+
const shown = await runCapture('systemctl', ['--user', 'show', '-p', 'FragmentPath', '--value', unit], { shell: false, timeout: 10000 });
|
|
2955
|
+
const path = String(shown.stdout || '').trim() || join(os.homedir(), '.config', 'systemd', 'user', unit);
|
|
2956
|
+
if (!existsSync(path)) return [];
|
|
2957
|
+
const lines = (await fsp.readFile(path, 'utf8')).split('\n');
|
|
2958
|
+
const have = new Set();
|
|
2959
|
+
for (const line of lines) {
|
|
2960
|
+
const m = line.trim().match(/^Environment="?([A-Z_0-9]+)=/);
|
|
2961
|
+
if (m) have.add(m[1]);
|
|
2962
|
+
}
|
|
2963
|
+
const missing = Object.entries(want).filter(([k, v]) => !have.has(k) && v !== '');
|
|
2964
|
+
if (!missing.length) return [];
|
|
2965
|
+
// Insert after the last existing Environment= line so the additions stay inside [Service].
|
|
2966
|
+
let at = -1;
|
|
2967
|
+
lines.forEach((line, i) => { if (line.startsWith('Environment=')) at = i; });
|
|
2968
|
+
if (at < 0) at = lines.findIndex((line) => line.trim() === '[Service]');
|
|
2969
|
+
if (at < 0) return [];
|
|
2970
|
+
lines.splice(at + 1, 0, ...missing.map(([k, v]) => `Environment=${k}=${v}`));
|
|
2971
|
+
await fsp.copyFile(path, `${path}.bak`).catch(() => {});
|
|
2972
|
+
await fsp.writeFile(path, lines.join('\n'), 'utf8');
|
|
2973
|
+
await run('systemctl', ['--user', 'daemon-reload'], {}).catch(() => {});
|
|
2974
|
+
sendLog(`[native] service env completed: ${missing.map(([k]) => k).join(', ')}`);
|
|
2975
|
+
return missing.map(([k]) => k);
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
if (process.platform === 'darwin') {
|
|
2979
|
+
// launchd runs the gateway through an env-wrapper that sources this file, so patching it is the
|
|
2980
|
+
// launchd equivalent of adding Environment= lines to a unit.
|
|
2981
|
+
const envFile = join(projectDir, '.openclaw', 'service-env', `${label}.env`);
|
|
2982
|
+
if (!existsSync(envFile)) return [];
|
|
2983
|
+
const body = await fsp.readFile(envFile, 'utf8');
|
|
2984
|
+
const have = new Set([...body.matchAll(/^\s*export\s+([A-Z_0-9]+)=/gm)].map((m) => m[1]));
|
|
2985
|
+
const missing = Object.entries(want).filter(([k, v]) => !have.has(k) && v !== '');
|
|
2986
|
+
if (!missing.length) return [];
|
|
2987
|
+
const added = missing.map(([k, v]) => `export ${k}='${String(v).replace(/'/g, "'\\''")}'`).join('\n');
|
|
2988
|
+
await fsp.copyFile(envFile, `${envFile}.bak`).catch(() => {});
|
|
2989
|
+
await fsp.writeFile(envFile, `${body.replace(/\n*$/, '')}\n${added}\n`, 'utf8');
|
|
2990
|
+
sendLog(`[native] service env completed: ${missing.map(([k]) => k).join(', ')}`);
|
|
2991
|
+
return missing.map(([k]) => k);
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
return [];
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
/**
|
|
2998
|
+
* Reunite a native project with the files an unset OPENCLAW_HOME scattered into `~/.openclaw`.
|
|
2999
|
+
*
|
|
3000
|
+
* This MUST run before syncNativeServiceEnv takes effect: once OPENCLAW_HOME is finally correct, the
|
|
3001
|
+
* plugin looks for its Zalo session inside the project — and if the credentials are still sitting in
|
|
3002
|
+
* the home directory it finds nothing and demands a fresh QR login. Copy (never move) so a failed
|
|
3003
|
+
* run leaves the working original in place; skip anything the project already has.
|
|
3004
|
+
*/
|
|
3005
|
+
async function adoptStrayNativeHome(projectDir) {
|
|
3006
|
+
if (!isNativeProject(projectDir)) return [];
|
|
3007
|
+
const projectHome = join(projectDir, '.openclaw');
|
|
3008
|
+
const strayHome = join(os.homedir(), '.openclaw');
|
|
3009
|
+
if (resolve(strayHome) === resolve(projectHome) || !existsSync(strayHome)) return [];
|
|
3010
|
+
const moved = [];
|
|
3011
|
+
const entries = await fsp.readdir(strayHome, { withFileTypes: true }).catch(() => []);
|
|
3012
|
+
for (const entry of entries) {
|
|
3013
|
+
// `state` is deliberately excluded: the project has its own live database and merging two
|
|
3014
|
+
// sqlite files is not something a copy can do correctly.
|
|
3015
|
+
const isCreds = /^zalo-connect-credentials.*\.json$/.test(entry.name);
|
|
3016
|
+
if (!isCreds && entry.name !== 'media') continue;
|
|
3017
|
+
const from = join(strayHome, entry.name);
|
|
3018
|
+
const to = join(projectHome, entry.name);
|
|
3019
|
+
if (existsSync(to)) continue;
|
|
3020
|
+
await fsp.cp(from, to, { recursive: true }).catch(() => {});
|
|
3021
|
+
if (existsSync(to)) moved.push(entry.name);
|
|
3022
|
+
}
|
|
3023
|
+
if (moved.length) {
|
|
3024
|
+
sendLog(`[migrate] Native: adopted ${moved.join(', ')} from ${strayHome} (written there while OPENCLAW_HOME was unset).`);
|
|
3025
|
+
}
|
|
3026
|
+
return moved;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
2802
3029
|
/**
|
|
2803
3030
|
* `openclaw daemon install` has no `--system` flag, so on Linux the gateway becomes a systemd USER
|
|
2804
3031
|
* unit — and a user manager without linger is torn down when that user's last session exits. On a
|
|
@@ -2824,6 +3051,112 @@ async function ensureSystemdLinger() {
|
|
|
2824
3051
|
return false;
|
|
2825
3052
|
}
|
|
2826
3053
|
|
|
3054
|
+
/**
|
|
3055
|
+
* Stop systemd abandoning the gateway while the state lease is still held.
|
|
3056
|
+
*
|
|
3057
|
+
* `openclaw daemon install` writes `StartLimitBurst=5` / `StartLimitIntervalSec=60` next to
|
|
3058
|
+
* `Restart=always`. The gateway's startup migrations take a lease on the state directory that lasts
|
|
3059
|
+
* five minutes, and a boot that collides with it exits 1 *immediately* — so five collisions burn the
|
|
3060
|
+
* whole limit in 30 seconds, systemd logs "Start request repeated too quickly", and the unit stays
|
|
3061
|
+
* dead for good even though the lease frees itself minutes later. Verified on a fresh Ubuntu 24.04
|
|
3062
|
+
* VPS (2026-08-28): the setup UI sat on "Waiting for gateway on 18789..." until it timed out, while
|
|
3063
|
+
* `systemctl --user start` afterwards brought the same unit up on the first try.
|
|
3064
|
+
*
|
|
3065
|
+
* A drop-in rather than an edit to the unit body: `daemon install --force` rewrites the unit on every
|
|
3066
|
+
* run, and drop-ins survive that.
|
|
3067
|
+
*/
|
|
3068
|
+
async function hardenNativeServiceRestarts(projectDir) {
|
|
3069
|
+
if (process.platform !== 'linux' || !isNativeProject(projectDir)) return false;
|
|
3070
|
+
const unit = `${nativeServiceLabel(projectDir)}.service`;
|
|
3071
|
+
const dir = join(os.homedir(), '.config', 'systemd', 'user', `${unit}.d`);
|
|
3072
|
+
const file = join(dir, '99-openclaw-setup.conf');
|
|
3073
|
+
const body = [
|
|
3074
|
+
'# Written by openclaw-setup — regenerated on every install, do not edit.',
|
|
3075
|
+
'# Startup migrations hold the state lease for ~5 minutes and a colliding boot exits at once, so',
|
|
3076
|
+
'# the stock StartLimitBurst=5/StartLimitIntervalSec=60 abandons the unit for good within 30s.',
|
|
3077
|
+
'# No start limit = systemd keeps retrying every RestartSec until the lease frees itself.',
|
|
3078
|
+
'[Unit]',
|
|
3079
|
+
'StartLimitIntervalSec=0',
|
|
3080
|
+
'[Service]',
|
|
3081
|
+
'RestartSec=10',
|
|
3082
|
+
'',
|
|
3083
|
+
].join('\n');
|
|
3084
|
+
if ((await fsp.readFile(file, 'utf8').catch(() => '')) === body) return true;
|
|
3085
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
3086
|
+
await fsp.writeFile(file, body, 'utf8');
|
|
3087
|
+
await run('systemctl', ['--user', 'daemon-reload'], {}).catch(() => {});
|
|
3088
|
+
sendLog('[native] service hardened: systemd start limit lifted (a migration lease can take 5 min).');
|
|
3089
|
+
return true;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
/**
|
|
3093
|
+
* A unit parked at "Start request repeated too quickly" refuses every later `start` until its
|
|
3094
|
+
* failure is cleared — including the one that would finally succeed. Cheap and idempotent, so it
|
|
3095
|
+
* runs before each start and also repairs a project abandoned by an earlier install.
|
|
3096
|
+
*/
|
|
3097
|
+
async function clearNativeServiceFailure(projectDir) {
|
|
3098
|
+
if (process.platform !== 'linux' || !isNativeProject(projectDir)) return false;
|
|
3099
|
+
const unit = `${nativeServiceLabel(projectDir)}.service`;
|
|
3100
|
+
const shown = await runCapture('systemctl', ['--user', 'show', '-p', 'ActiveState', '--value', unit], { shell: false, timeout: 10000 });
|
|
3101
|
+
if (!/^failed/i.test(String(shown.stdout || '').trim())) return false;
|
|
3102
|
+
await run('systemctl', ['--user', 'reset-failed', unit], {}).catch(() => {});
|
|
3103
|
+
sendLog(`[native] cleared the previous failed state of ${unit} before starting it.`);
|
|
3104
|
+
return true;
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
/**
|
|
3108
|
+
* Name whatever already owns a port, so a stalled wait can say why it will never end.
|
|
3109
|
+
*
|
|
3110
|
+
* The trap this exists for: an operator runs the port-forward command out of `ssh <host>-setup`
|
|
3111
|
+
* (`ssh -L 18789:127.0.0.1:18789 root@<host>`) while already logged INTO that host instead of from
|
|
3112
|
+
* their own machine. ssh binds 18789 locally and forwards it to 18789 on the same box — a self-loop
|
|
3113
|
+
* that accepts every connection and answers nothing. The gateway can then never bind its port, and
|
|
3114
|
+
* health probes hang instead of failing, so the port looks alive and is useless. Seen on a customer
|
|
3115
|
+
* VPS 2026-08-28. `ss` (iproute2) is used on Linux rather than `lsof`, which a minimal Ubuntu lacks.
|
|
3116
|
+
*/
|
|
3117
|
+
async function describePortHolder(port) {
|
|
3118
|
+
const trim = (v) => String(v || '').trim();
|
|
3119
|
+
if (process.platform === 'win32') {
|
|
3120
|
+
const out = await runCapture('powershell', ['-NoProfile', '-Command', `$p=(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1).OwningProcess; if ($p) { "pid $p: " + (Get-Process -Id $p -ErrorAction SilentlyContinue).Path }`], { shell: false, timeout: 15000 });
|
|
3121
|
+
return trim(out.stdout);
|
|
3122
|
+
}
|
|
3123
|
+
if (process.platform === 'linux') {
|
|
3124
|
+
const ss = await runCapture('ss', ['-ltnp'], { shell: false, timeout: 10000 });
|
|
3125
|
+
const line = String(ss.stdout || '').split('\n').find((l) => new RegExp(`[:.]${port}\\s`).test(l)) || '';
|
|
3126
|
+
const pid = (line.match(/pid=(\d+)/) || [])[1];
|
|
3127
|
+
if (!pid) return trim(line);
|
|
3128
|
+
const ps = await runCapture('ps', ['-o', 'args=', '-p', pid], { shell: false, timeout: 10000 });
|
|
3129
|
+
return `pid ${pid}: ${trim(ps.stdout) || trim(line)}`;
|
|
3130
|
+
}
|
|
3131
|
+
const out = await runCapture('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], { shell: false, timeout: 10000 });
|
|
3132
|
+
return trim(String(out.stdout || '').split('\n')[1]);
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
/**
|
|
3136
|
+
* Explain a gateway that is not answering, instead of counting down in silence.
|
|
3137
|
+
*
|
|
3138
|
+
* Three causes, all indistinguishable from "still booting" without this: an ssh port-forward
|
|
3139
|
+
* self-loop holding the port (describePortHolder), the migration lease plus systemd's start limit
|
|
3140
|
+
* leaving the unit dead for good (hardenNativeServiceRestarts), or an ordinary crash on boot — for
|
|
3141
|
+
* which the unit's own journal is the only thing that ever says so.
|
|
3142
|
+
*/
|
|
3143
|
+
async function reportNativeGatewayBlockage(projectDir, port) {
|
|
3144
|
+
const holder = await describePortHolder(port).catch(() => '');
|
|
3145
|
+
if (holder) {
|
|
3146
|
+
sendLog(`[native] Port ${port} is already held by → ${holder}`);
|
|
3147
|
+
if (/(^|\/|\s)ssh(\s|$)/.test(holder) && new RegExp(`-L\\s*\\d*:?${port}:`).test(holder)) {
|
|
3148
|
+
sendLog(`[native] That is an SSH port-forward, not the gateway. \`ssh -L ${port}:...\` belongs on YOUR OWN machine — run it on the server and it steals port ${port} from the gateway and answers nothing. Kill that ssh process on the server, then start the gateway again.`);
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
3151
|
+
if (process.platform !== 'linux' || !isNativeProject(projectDir)) return;
|
|
3152
|
+
const unit = `${nativeServiceLabel(projectDir)}.service`;
|
|
3153
|
+
const st = await runCapture('systemctl', ['--user', 'show', '-p', 'ActiveState', '-p', 'SubState', '-p', 'NRestarts', '--value', unit], { shell: false, timeout: 10000 }).catch(() => ({}));
|
|
3154
|
+
const vals = String(st.stdout || '').split('\n').map((v) => v.trim()).filter(Boolean);
|
|
3155
|
+
if (vals.length) sendLog(`[native] ${unit} → ${vals.join(' / ')}`);
|
|
3156
|
+
const log = await runCapture('journalctl', ['--user', '-u', unit, '-n', '15', '--no-pager'], { shell: false, timeout: 15000 }).catch(() => ({}));
|
|
3157
|
+
for (const line of String(log.stdout || '').split('\n').map((l) => l.trimEnd()).filter(Boolean)) sendLog(`[native] ${line}`);
|
|
3158
|
+
}
|
|
3159
|
+
|
|
2827
3160
|
/**
|
|
2828
3161
|
* Native counterpart of the docker entrypoint's `ensure_plugin` (docker-gen.js).
|
|
2829
3162
|
*
|
|
@@ -2836,6 +3169,16 @@ async function ensureSystemdLinger() {
|
|
|
2836
3169
|
*/
|
|
2837
3170
|
async function ensureNativePlugins(projectDir, { restart = false } = {}) {
|
|
2838
3171
|
if (!isNativeProject(projectDir)) return [];
|
|
3172
|
+
// Same cleanup the container entrypoint does (docker-gen.js): an interrupted `plugins install`
|
|
3173
|
+
// leaves extensions/.openclaw-install-stage-XXXXXX behind, and it still carries a plugin manifest —
|
|
3174
|
+
// so the gateway logs "duplicate plugin id detected" every boot and a stale build competes with the
|
|
3175
|
+
// real one for the same id. Native has no entrypoint, so it has to happen here.
|
|
3176
|
+
const extRoot = join(projectDir, '.openclaw', 'extensions');
|
|
3177
|
+
for (const entry of await fsp.readdir(extRoot, { withFileTypes: true }).catch(() => [])) {
|
|
3178
|
+
if (!entry.isDirectory() || !entry.name.startsWith('.openclaw-install-stage-')) continue;
|
|
3179
|
+
await fsp.rm(join(extRoot, entry.name), { recursive: true, force: true }).catch(() => {});
|
|
3180
|
+
sendLog(`[native] removed abandoned plugin staging dir ${entry.name}`);
|
|
3181
|
+
}
|
|
2839
3182
|
let cfg = {};
|
|
2840
3183
|
try { cfg = JSON.parse(await fsp.readFile(join(projectDir, '.openclaw', 'openclaw.json'), 'utf8')); } catch {}
|
|
2841
3184
|
// learning-memory backs plugins.slots.contextEngine for every bot; zalo-connect only when a bot
|
|
@@ -2873,7 +3216,7 @@ function startDetached(cmd, args, opts = {}) {
|
|
|
2873
3216
|
detached: true,
|
|
2874
3217
|
stdio: 'ignore',
|
|
2875
3218
|
windowsHide: opts.windowsHide ?? true,
|
|
2876
|
-
env:
|
|
3219
|
+
env: binEnv(rawBin, opts.env),
|
|
2877
3220
|
});
|
|
2878
3221
|
child.on('error', (err) => sendLog(`[native] Failed to start "${cmd}": ${err.message}`));
|
|
2879
3222
|
child.unref();
|
|
@@ -2967,15 +3310,38 @@ async function startNativeRuntime({ projectDir, osChoice = '', gatewayPort, rout
|
|
|
2967
3310
|
// straight away, needs no follow-up restart, and prints no "plugin not found" warnings.
|
|
2968
3311
|
await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin bootstrap skipped: ${e.message}`));
|
|
2969
3312
|
|
|
3313
|
+
// Config migrations BEFORE the first boot too — same ordering the container entrypoint uses
|
|
3314
|
+
// (migrate, then start), so an adopted pre-existing project boots on the upgraded config.
|
|
3315
|
+
await runNativeConfigMigrations(projectDir).catch(() => {});
|
|
3316
|
+
|
|
2970
3317
|
// Managed service = auto-restart (KeepAlive/Restart=always) and start-at-login, the native
|
|
2971
3318
|
// equivalent of docker's `restart: always`. --force so re-running install updates the port.
|
|
2972
3319
|
const env = nativeEnv(projectDir);
|
|
2973
3320
|
await ensureSystemdLinger();
|
|
2974
3321
|
await run('openclaw', ['daemon', 'install', '--force', '--port', String(gwPort)], { cwd: projectDir, env });
|
|
2975
|
-
await
|
|
2976
|
-
|
|
2977
|
-
//
|
|
2978
|
-
|
|
3322
|
+
await hardenNativeServiceRestarts(projectDir).catch((e) => sendLog(`[native] service hardening skipped: ${e.message}`));
|
|
3323
|
+
await clearNativeServiceFailure(projectDir).catch(() => {});
|
|
3324
|
+
// `daemon install` already STARTED the unit, and that first boot begins the state migrations that
|
|
3325
|
+
// hold a five-minute lease on the state directory. This used to restart it right away — to pick up
|
|
3326
|
+
// the env completed below — and the restart landed mid-migration: the lease was left behind, every
|
|
3327
|
+
// following boot exited 1 until it expired, and systemd's start limit abandoned the unit long
|
|
3328
|
+
// before that. The setup UI then waited out its whole timeout on a service that was never coming
|
|
3329
|
+
// back (real Ubuntu 24.04 VPS, 2026-08-28). So let this boot finish UNDISTURBED first, and only
|
|
3330
|
+
// then repair the env and restart a settled gateway.
|
|
3331
|
+
const firstBootOk = await waitForNativeGatewayHealthy(projectDir, NATIVE_GATEWAY_HEALTH_TIMEOUT_MS);
|
|
3332
|
+
// Order is load-bearing: adopt the stray files FIRST, then complete the service env. The other way
|
|
3333
|
+
// round, the gateway boots with a corrected OPENCLAW_HOME, finds no Zalo session there, and asks
|
|
3334
|
+
// for a new QR scan even though a perfectly good session exists in the home directory.
|
|
3335
|
+
await adoptStrayNativeHome(projectDir).catch((e) => sendLog(`[migrate] stray home skipped: ${e.message}`));
|
|
3336
|
+
const envAdded = await syncNativeServiceEnv(projectDir).catch((e) => { sendLog(`[native] service env sync skipped: ${e.message}`); return []; });
|
|
3337
|
+
// One restart, and only when there is something to gain by it: a completed env to load, or a first
|
|
3338
|
+
// boot that never answered (in which case the restart is the repair attempt).
|
|
3339
|
+
if (envAdded.length || !firstBootOk) {
|
|
3340
|
+
sendLog(envAdded.length
|
|
3341
|
+
? '[native] restarting gateway to load the completed service env'
|
|
3342
|
+
: '[native] first boot never answered /health — restarting once to recover');
|
|
3343
|
+
await restartNativeRuntime(projectDir).catch((e) => sendLog(`[native] restart after install: ${e.message}`));
|
|
3344
|
+
}
|
|
2979
3345
|
sendLog(`[native] gateway service "${label}" running on 127.0.0.1:${gwPort}, 9router on 127.0.0.1:${rtPort}`);
|
|
2980
3346
|
return { gatewayPort: gwPort, routerPort: rtPort, label };
|
|
2981
3347
|
}
|
|
@@ -3128,7 +3494,7 @@ async function recreateDockerBot(projectDir) {
|
|
|
3128
3494
|
// under a state lease, a restart mid-migration exits 1, and systemd's start limit can then
|
|
3129
3495
|
// abandon the unit. This is the same trap the docker path avoids by waiting for the container
|
|
3130
3496
|
// before touching it (see startZaloConnectLogin) — wait for /health first.
|
|
3131
|
-
await waitForNativeGatewayHealthy(projectDir,
|
|
3497
|
+
await waitForNativeGatewayHealthy(projectDir, NATIVE_GATEWAY_HEALTH_TIMEOUT_MS);
|
|
3132
3498
|
// The bot that was just created/edited may have added the Zalo channel or the context engine to
|
|
3133
3499
|
// openclaw.json; put those plugins on disk now so this one reload loads them too.
|
|
3134
3500
|
await ensureNativePlugins(projectDir).catch((e) => sendLog(`[native] plugin ensure skipped: ${e.message}`));
|
|
@@ -6628,11 +6994,57 @@ async function printRemoteAccessHint(uiPort) {
|
|
|
6628
6994
|
console.log(` ssh ${fwd} ${sshUserName()}@${ip}`);
|
|
6629
6995
|
console.log(` then open: http://localhost:${uiPort}`);
|
|
6630
6996
|
console.log(` (forwards ${ports.length} live port${ports.length > 1 ? 's' : ''}: ${ports.join(', ')})`);
|
|
6997
|
+
// Spelled out because the obvious mistake is to paste that line into the shell it was printed in.
|
|
6998
|
+
// Run there, ssh binds every one of those ports on THIS box and forwards them straight back to
|
|
6999
|
+
// itself: the gateway can no longer bind 18789, and the self-loop accepts connections while
|
|
7000
|
+
// answering nothing, so the port looks alive and the whole install hangs on "Waiting for gateway".
|
|
7001
|
+
// Cost a customer VPS install on 2026-08-28.
|
|
7002
|
+
console.log(' ⚠️ Run that line on YOUR OWN machine — NOT in this shell. On the server it');
|
|
7003
|
+
console.log(` steals port ${ports.join('/')} from the bot and the install never finishes.`);
|
|
6631
7004
|
console.log('');
|
|
6632
7005
|
}
|
|
6633
7006
|
|
|
7007
|
+
/** True when whatever answers on host:port already looks like an OpenClaw Setup UI. */
|
|
7008
|
+
async function detectExistingSetupUi(host, port) {
|
|
7009
|
+
try {
|
|
7010
|
+
const res = await fetch(`http://${host}:${port}/`, { signal: AbortSignal.timeout(2500) });
|
|
7011
|
+
if (!res.ok) return false;
|
|
7012
|
+
const body = (await res.text()).slice(0, 65536);
|
|
7013
|
+
return /openclaw/i.test(body);
|
|
7014
|
+
} catch (_) {
|
|
7015
|
+
return false;
|
|
7016
|
+
}
|
|
7017
|
+
}
|
|
7018
|
+
|
|
6634
7019
|
export async function startLocalInstaller({ host = '127.0.0.1', preferredPort = 51789, openBrowser = true, projectDir = process.cwd() } = {}) {
|
|
6635
7020
|
const port = await findPort(host, preferredPort);
|
|
7021
|
+
if (port !== preferredPort && (await detectExistingSetupUi(host, preferredPort))) {
|
|
7022
|
+
// Another Setup UI already owns the preferred port (a systemd service, an earlier npx run…).
|
|
7023
|
+
// Hopping to :51790 here is exactly how operators end up with SSH tunnels and printed hints
|
|
7024
|
+
// pointing at a port nothing serves — so reuse the running instance instead of starting a
|
|
7025
|
+
// second one, and keep this process alive so an `ssh -L … "npx create-openclaw-bot"`
|
|
7026
|
+
// one-liner still holds the tunnel open. If the other instance ever goes away, take the
|
|
7027
|
+
// port over so the URL keeps working without the operator re-running anything.
|
|
7028
|
+
const url = `http://${host}:${preferredPort}`;
|
|
7029
|
+
activeUiHost = host;
|
|
7030
|
+
activeUiPort = preferredPort;
|
|
7031
|
+
activeUiProjectDir = projectDir;
|
|
7032
|
+
console.log(`OpenClaw Setup UI is already running: ${url}`);
|
|
7033
|
+
console.log('Reusing the running instance — nothing new was started. Keep this window open if it holds your SSH tunnel.');
|
|
7034
|
+
ensureReopenShortcut();
|
|
7035
|
+
if (openBrowser) openUrl(url);
|
|
7036
|
+
printRemoteAccessHint(preferredPort).catch(() => {});
|
|
7037
|
+
const takeover = setInterval(async () => {
|
|
7038
|
+
if ((await findPort(host, preferredPort)) !== preferredPort) return; // still busy
|
|
7039
|
+
clearInterval(takeover);
|
|
7040
|
+
console.log(`Port ${preferredPort} freed up — starting a Setup UI there to keep ${url} working.`);
|
|
7041
|
+
startLocalInstaller({ host, preferredPort, openBrowser: false, projectDir }).catch(() => {});
|
|
7042
|
+
}, 5000);
|
|
7043
|
+
return;
|
|
7044
|
+
}
|
|
7045
|
+
if (port !== preferredPort) {
|
|
7046
|
+
console.log(`⚠ Port ${preferredPort} is busy with something that is not a Setup UI — using ${port} instead.`);
|
|
7047
|
+
}
|
|
6636
7048
|
activeUiHost = host;
|
|
6637
7049
|
activeUiPort = port;
|
|
6638
7050
|
activeUiProjectDir = projectDir;
|
|
@@ -6658,4 +7070,4 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
6658
7070
|
]).catch(() => {});
|
|
6659
7071
|
}
|
|
6660
7072
|
|
|
6661
|
-
export { patchBrowserAutomationHostPreference, debugChromeProfileDir, defaultChromeProfileDir, createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations, runHostCommand, detectHostCommands, detectHostCapabilityCommands, grantHostCapabilities, detectCodexApp, detectCodexMarketplace, resolveCodexCli, openPrivacyPane, projectDeployMode, isNativeProject, nativeServiceLabel, nativeEnv, ocArgv, migrateNativePaths, discoverNativeProjectRoots, detectOs, stripCliWarnings, migrationLeaseDeadline, ensureNativePlugins, findFreeHostPort };
|
|
7073
|
+
export { patchBrowserAutomationHostPreference, debugChromeProfileDir, defaultChromeProfileDir, createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder, buildZaloHealthSnapshot, removeEmptyWorkspaceAttestations, runHostCommand, detectHostCommands, detectHostCapabilityCommands, grantHostCapabilities, detectCodexApp, detectCodexMarketplace, resolveCodexCli, openPrivacyPane, projectDeployMode, isNativeProject, nativeServiceLabel, nativeEnv, ocArgv, migrateNativePaths, discoverNativeProjectRoots, detectOs, stripCliWarnings, migrationLeaseDeadline, ensureNativePlugins, findFreeHostPort, syncNativeServiceEnv, adoptStrayNativeHome, runNativeConfigMigrations, detectExistingSetupUi, hardenNativeServiceRestarts, clearNativeServiceFailure, describePortHolder, reportNativeGatewayBlockage };
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
(function (root) {
|
|
3
|
-
|
|
3
|
+
// GHIM version, khong dung @latest: Dockerfile sinh ra mang spec nay, nen "@latest" nghia la
|
|
4
|
+
// MOI lan rebuild image co the nhay han mot the he openclaw — schema config doi, state DB doi
|
|
5
|
+
// migration, va bot khach chet ngay sau mot lan bam rebuild vo hai (ca that 103.98.149.154,
|
|
6
|
+
// 31/08/2026: rebuild keo 2026.7.1-2 → 2026.8.1, gateway tu choi boot, 66 lan restart).
|
|
7
|
+
// Nang version = doi spec nay MOT CACH CO Y trong mot ban phat hanh setup, kem kiem chung.
|
|
8
|
+
const OPENCLAW_NPM_SPEC = 'openclaw@2026.8.1';
|
|
4
9
|
const OPENCLAW_RUNTIME_PACKAGES = 'grammy @grammyjs/runner @grammyjs/transformer-throttler @buape/carbon @larksuiteoapi/node-sdk @slack/web-api';
|
|
5
10
|
const NINE_ROUTER_NPM_SPEC = '9router@latest';
|
|
6
11
|
const NINE_ROUTER_PORT = 20128;
|
|
@@ -268,7 +273,14 @@ If setup reported a plugin install error, run this after the bot is running:
|
|
|
268
273
|
{
|
|
269
274
|
id: 'smart-route',
|
|
270
275
|
name: 'Smart Proxy (Auto Route)',
|
|
271
|
-
|
|
276
|
+
// 1M (Kent chot 01/09/2026) — dao lai quyet dinh 131072 cua 5.16.0. Boi canh de doc
|
|
277
|
+
// lai sau: 131072 sinh ra vi smart-route fan-out sang upstream free ma model nho nhat
|
|
278
|
+
// trong pool tran ~128k; khai 200k lam phien day den muc chinh loi goi compaction cung
|
|
279
|
+
// overflow ("auto-compaction could not recover" cho den /new). Gio pool upstream da la
|
|
280
|
+
// cac model cua so lon nen khai 1M de phien dai khong bi compaction som. TRADE-OFF: neu
|
|
281
|
+
// mot combo van con model 128k thi phien vuot 128k se chet dung nhu hoi 5.15.x — do la
|
|
282
|
+
// van de cua combo, sua bang cach chon model, khong sua bang cach ha window o day nua.
|
|
283
|
+
contextWindow: 1048576,
|
|
272
284
|
maxTokens: 8192,
|
|
273
285
|
input: ['text', 'image'],
|
|
274
286
|
},
|
|
@@ -194,6 +194,21 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
|
|
|
194
194
|
return `node -e \\"const fs=require('fs'),os=require('os'),path=require('path'),p=path.join(process.cwd(),'.openclaw','openclaw.json');if(fs.existsSync(p)){const c=JSON.parse(fs.readFileSync(p,'utf8'));const gp=Number(process.env.OPENCLAW_GATEWAY_PORT||process.env.OPENCLAW_PORT)||c.gateway?.port||18789;const a=new Set(['http://localhost:'+gp,'http://127.0.0.1:'+gp,'http://0.0.0.0:'+gp]);for(const entries of Object.values(os.networkInterfaces()||{})){for(const entry of entries||[]){if(!entry||entry.internal||entry.family!=='IPv4'||!entry.address)continue;a.add('http://' + entry.address + ':'+gp);}}const p9=c.models&&c.models.providers&&c.models.providers['9router'];if(p9){p9.request=Object.assign({},p9.request,{allowPrivateNetwork:true});}c.tools=Object.assign({},c.tools,{profile:'full',exec:{host:'gateway',security:'full',ask:'off'}});c.gateway=Object.assign({},c.gateway,{port:gp,bind:'custom',customBindHost:'0.0.0.0',controlUi:Object.assign({},c.gateway?.controlUi,{allowedOrigins:Array.from(a).filter(Boolean)})});fs.writeFileSync(p,JSON.stringify(c,null,2));}\\"`;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
// Idempotent config upgrade replayed on every runtime start. Docker embeds it in the container
|
|
198
|
+
// entrypoint; the native runtime (local-server.js) runs it via `node -e` before every gateway
|
|
199
|
+
// (re)start — one script, both deploy modes, so native bots stop missing config fixes like the
|
|
200
|
+
// smart-route contextWindow 200000/131072 → 1048576 (Kent chot 01/09/2026; chi dung den DUNG
|
|
201
|
+
// hai gia tri setup tung ghi, custom tuning giu nguyen). Full rationale sits with the
|
|
202
|
+
// entrypoint block inside buildDockerArtifacts.
|
|
203
|
+
// ⚠️ toolResultMaxChars: openclaw ≥2026.8 BO key nay khoi schema va TU CHOI BOOT khi thay no.
|
|
204
|
+
// Ban cu cua script nay them key vo dieu kien moi lan container start — nen mot khach rebuild
|
|
205
|
+
// image (Dockerfile keo openclaw moi) la roi vao vong: gateway chet vi key ⇒ xoa tay ⇒ entrypoint
|
|
206
|
+
// them lai ⇒ chet tiep, khong lo ra thu pham (ca that 103.98.149.154, 31/08/2026, 66 lan restart).
|
|
207
|
+
// Gio gate theo version openclaw THAT trong container: <2026.8 thi backfill nhu cu, ≥2026.8 thi
|
|
208
|
+
// GO key neu con (tu chua cac project da nhiem). Khong doc duoc version thi KHONG dong nao chay
|
|
209
|
+
// — tha thieu mot default con hon gieo key lam gateway tu choi boot.
|
|
210
|
+
const contextDefaultsScript = `const fs=require('fs'),path=require('path');const p=path.join(process.cwd(),'.openclaw','openclaw.json');if(fs.existsSync(p)){const c=JSON.parse(fs.readFileSync(p,'utf8'));let ch=false;c.skills=c.skills||{};c.skills.workshop=c.skills.workshop||{};if(!c.skills.workshop.approvalPolicy){c.skills.workshop.approvalPolicy='auto';ch=true;}if(c.browser&&c.browser.enabled!==false){c.tools=c.tools||{};const dn=Array.isArray(c.tools.deny)?c.tools.deny:[];if(!dn.includes('browser')){dn.push('browser');c.tools.deny=dn;ch=true;}}const d=(c.agents&&c.agents.defaults)?c.agents.defaults:null;if(d){if(d.imageMaxDimensionPx===undefined){d.imageMaxDimensionPx=1024;ch=true;}if(d.imageQuality===undefined){d.imageQuality='efficient';ch=true;}let ocMajorMinor=0;try{const v=String(require('child_process').execSync('openclaw --version',{stdio:['ignore','pipe','ignore']})).match(/(\\d{4})\\.(\\d+)/);if(v)ocMajorMinor=Number(v[1])*100+Number(v[2]);}catch(e){}if(ocMajorMinor&&ocMajorMinor<202608){d.contextLimits=d.contextLimits||{};if(d.contextLimits.toolResultMaxChars===undefined){d.contextLimits.toolResultMaxChars=12000;ch=true;}}else if(d.contextLimits&&d.contextLimits.toolResultMaxChars!==undefined){delete d.contextLimits.toolResultMaxChars;ch=true;}}const pr=c.models&&c.models.providers&&c.models.providers['9router'];if(pr&&Array.isArray(pr.models)){for(const m of pr.models){if(m&&m.id==='smart-route'&&(m.contextWindow===200000||m.contextWindow===131072)){m.contextWindow=1048576;ch=true;}}}if(ch)fs.writeFileSync(p,JSON.stringify(c,null,2));}`;
|
|
211
|
+
|
|
197
212
|
function buildDockerArtifacts(options) {
|
|
198
213
|
const {
|
|
199
214
|
openClawNpmSpec,
|
|
@@ -272,8 +287,13 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
|
|
|
272
287
|
// • imageMaxDimensionPx / imageQuality / contextLimits.toolResultMaxChars → keep one
|
|
273
288
|
// heavy turn (deep research, 4K chart read-back) from overflowing the context
|
|
274
289
|
// window mid tool-loop, which cannot be compacted and poisons the session.
|
|
290
|
+
// • smart-route contextWindow 200000 → 131072: the old declared window exceeded the
|
|
291
|
+
// smallest upstream in typical free-model combos, so full sessions deadlocked —
|
|
292
|
+
// the compaction summarize call itself overflowed and only /new recovered. Only the
|
|
293
|
+
// exact setup-written 200000 is rewritten; an operator's custom value is left alone.
|
|
275
294
|
// Each key is only filled in when absent, so an operator's own tuning is never clobbered.
|
|
276
|
-
|
|
295
|
+
// (The script itself lives at module scope — the native runtime replays the exact same
|
|
296
|
+
// migration before every gateway (re)start, since native projects have no entrypoint.)
|
|
277
297
|
// Companion backfill for the same older projects: their TOOLS.md was generated before the
|
|
278
298
|
// skill-authoring / long-turn guidance existed, and workspace files are only written when a
|
|
279
299
|
// bot is created — so a rebuild alone leaves the assistant stopping at "proposal awaiting
|
|
@@ -288,6 +308,17 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
|
|
|
288
308
|
'export OPENCLAW_HOME="${OPENCLAW_HOME:-$PWD/.openclaw}"',
|
|
289
309
|
'export OPENCLAW_STATE_DIR="${OPENCLAW_STATE_DIR:-$OPENCLAW_HOME}"',
|
|
290
310
|
'mkdir -p "$OPENCLAW_HOME" "$OPENCLAW_STATE_DIR"',
|
|
311
|
+
// `openclaw plugins install` unpacks into extensions/.openclaw-install-stage-XXXXXX and removes
|
|
312
|
+
// it when it finishes. An interrupted install leaves the staging copy behind — and it still
|
|
313
|
+
// carries a plugin manifest, so the gateway logs "duplicate plugin id detected" on every boot
|
|
314
|
+
// and a stale build competes with the real one for the same id. Found on a production host: a
|
|
315
|
+
// zalo-connect 3.0.7 stage dir shadowing 3.0.17 for a week. Nothing is installing at entrypoint
|
|
316
|
+
// time, so any stage dir here is by definition abandoned.
|
|
317
|
+
'for stage in "$OPENCLAW_HOME"/extensions/.openclaw-install-stage-*; do',
|
|
318
|
+
' [ -d "$stage" ] || continue',
|
|
319
|
+
' echo "[entrypoint] removing abandoned plugin staging dir $(basename "$stage")"',
|
|
320
|
+
' rm -rf "$stage"',
|
|
321
|
+
'done',
|
|
291
322
|
'if [ "$OPENCLAW_STATE_DIR" != "$OPENCLAW_HOME" ]; then',
|
|
292
323
|
' for path in "$OPENCLAW_HOME"/*; do',
|
|
293
324
|
' [ -e "$path" ] || continue',
|
|
@@ -436,6 +467,24 @@ if(touched){console.log('[patch-9router] Applied Codex compatibility patch.');}e
|
|
|
436
467
|
// `set -e` is on: a non-zero return here must never stop the gateway from starting.
|
|
437
468
|
'start_local_headless_chrome || true',
|
|
438
469
|
].join('\n'));
|
|
470
|
+
// ── Doctor-on-upgrade ─────────────────────────────────────────────────────────────────
|
|
471
|
+
// Update openclaw khong bao gio chi la "doi so version": schema config doi (key cu bi TU CHOI
|
|
472
|
+
// chu khong bo qua) va state DB doi migration (audit-events-v2…), va doctor chi chay sach khi
|
|
473
|
+
// gateway DANG TAT — tuc dung ngay tai day, truoc `gateway run`, trong container vua boot.
|
|
474
|
+
// So version hien tai voi lan boot truoc (marker trong $OPENCLAW_HOME); khac nhau moi chay,
|
|
475
|
+
// hai luot vi doctor boc migration theo LOP (ca that 103.98.149.154 can nhieu luot). `yes |`
|
|
476
|
+
// vi doctor co cau hoi tuong tac; loi doctor khong chan boot — gateway se tu noi not phan con
|
|
477
|
+
// thieu, con hon container chet cung khong ai chan doan duoc.
|
|
478
|
+
runtimeParts.push([
|
|
479
|
+
'OC_VER="$(openclaw --version 2>/dev/null | head -1)"',
|
|
480
|
+
'OC_VER_MARKER="$OPENCLAW_HOME/.openclaw-last-version"',
|
|
481
|
+
'if [ -n "$OC_VER" ] && [ "$OC_VER" != "$(cat "$OC_VER_MARKER" 2>/dev/null)" ]; then',
|
|
482
|
+
' echo "[entrypoint] openclaw version changed ($(cat "$OC_VER_MARKER" 2>/dev/null || echo none) -> $OC_VER); running doctor --fix"',
|
|
483
|
+
' yes | openclaw doctor --fix || true',
|
|
484
|
+
' yes | openclaw doctor --fix || true',
|
|
485
|
+
' printf %s "$OC_VER" > "$OC_VER_MARKER" || true',
|
|
486
|
+
'fi',
|
|
487
|
+
].join('\n'));
|
|
439
488
|
runtimeParts.push('openclaw gateway run');
|
|
440
489
|
const runtimeScript = ['#!/bin/sh', 'set -e', ...runtimeParts].join('\n');
|
|
441
490
|
let browserInstall = '';
|
|
@@ -682,6 +731,7 @@ ${appEnvironmentBlock}${plainSingleExtraHosts ? `${extraHostsBlock}\n` : ''}
|
|
|
682
731
|
build9RouterComposeEntrypointScript,
|
|
683
732
|
buildGatewayPatchCmd,
|
|
684
733
|
buildDockerArtifacts,
|
|
734
|
+
contextDefaultsScript,
|
|
685
735
|
};
|
|
686
736
|
|
|
687
737
|
})(typeof globalThis !== 'undefined' ? globalThis : {});
|
package/dist/web/app.js
CHANGED
|
@@ -413,7 +413,11 @@ function remoteAccessPanel(s = {}) {
|
|
|
413
413
|
const host = r.host || '<your-server-ip>';
|
|
414
414
|
const user = r.user || 'root';
|
|
415
415
|
const portOf = (raw, def) => { try { return new URL(raw).port || def; } catch { return def; } };
|
|
416
|
-
|
|
416
|
+
// zalo-mod's dashboard is gateway port + 1, not a fixed 18790 — the same rule the plugin card's
|
|
417
|
+
// "Open" button uses. Hardcoding it meant any project whose gateway is not on 18789 got a tunnel
|
|
418
|
+
// command missing the dashboard port, and the dashboard then simply failed to load with no clue why.
|
|
419
|
+
const gwPort = Number(portOf(s.gatewayUrl, 18789));
|
|
420
|
+
const ports = Array.from(new Set([r.uiPort || 51789, gwPort, Number(portOf(s.routerUrl, 20128)), gwPort + 1]));
|
|
417
421
|
const cmd = `ssh ${ports.map((p) => `-L ${p}:127.0.0.1:${p}`).join(' ')} ${user}@${host}`;
|
|
418
422
|
return `<details class="card" style="margin-top:12px;" ${r.headless ? 'open' : ''}>
|
|
419
423
|
<summary style="cursor:pointer; font-weight:600;">🌐 ${t('Mở từ máy khác (VPS/server)', 'Open from another machine (VPS/server)')}</summary>
|
|
@@ -1011,11 +1015,7 @@ function botListPanel(bots) {
|
|
|
1011
1015
|
const listItems = bots.map(b => {
|
|
1012
1016
|
const role = (b.role || b.desc || b.description || '').trim() || t('Tr\u1ee3 l\u00fd OpenClaw','OpenClaw assistant');
|
|
1013
1017
|
const isZalo = b.channel === 'zalo-personal';
|
|
1014
|
-
const
|
|
1015
|
-
const connection = !health ? t('Chưa rõ','Unknown') : health.running ? t('Đã kết nối','Connected') : health.lastError ? t('Mất kết nối','Disconnected') : t('Đang kết nối','Connecting');
|
|
1016
|
-
const login = !health ? t('Chưa rõ','Unknown') : health.sessionSaved ? t('Đã đăng nhập','Logged in') : t('Chưa đăng nhập','Not logged in');
|
|
1017
|
-
const connectionTone = health?.running ? 'ok' : health?.lastError ? 'bad' : 'warn';
|
|
1018
|
-
const loginTone = health?.sessionSaved ? 'ok' : health ? 'bad' : 'warn';
|
|
1018
|
+
const { connection, login, connectionTone, loginTone } = zaloHealthBadges(b);
|
|
1019
1019
|
return `<article class="bot-item ${state.activeBotId===b.id?'active':''}" data-bot-id="${escapeHtml(b.id)}"><div class="bot-item-actions"><button class="bot-edit" data-edit-bot="${escapeHtml(b.id)}" title="${t('Sửa bot','Edit bot')}" aria-label="${t('Sửa bot','Edit bot')}">${actionIcon('edit')}</button><button class="bot-delete" data-delete-bot="${escapeHtml(b.id)}" title="${t('X\u00f3a bot','Delete bot')}" aria-label="${t('X\u00f3a bot','Delete bot')}">×</button></div><div class="bot-item-title"><b>${escapeHtml(b.name)}</b></div><small title="${escapeHtml(role)}">${escapeHtml(role)}</small>${isZalo ? `<div class="zalo-bot-health"><div><span>${t('Kết nối','Connection')}</span><em class="${connectionTone}">${connection}</em></div><div><span>${t('Đăng nhập','Login')}</span><em class="${loginTone}">${login}</em></div></div>` : ''}</article>`;
|
|
1020
1020
|
});
|
|
1021
1021
|
listItems.push(`
|
|
@@ -1623,7 +1623,10 @@ document.querySelectorAll('[data-project-pick-folder]').forEach(btn => btn.oncli
|
|
|
1623
1623
|
body.projectDir = activeProjectDir();
|
|
1624
1624
|
body.channel = state.botChannel || 'telegram';
|
|
1625
1625
|
body.userTimezone = state.tz;
|
|
1626
|
-
|
|
1626
|
+
// Only the CREATE flow starts a Zalo QR login (the server kicks it off and sets
|
|
1627
|
+
// loginStarted). Editing a bot (rename, persona...) must never pop the QR modal —
|
|
1628
|
+
// the session is already saved and the PUT endpoint starts no login.
|
|
1629
|
+
if (body.channel === 'zalo-personal' && !state.botEditId) {
|
|
1627
1630
|
state.botModalOpen = false;
|
|
1628
1631
|
state.zaloLoginOpen = true;
|
|
1629
1632
|
state.zaloQrDataUrl = '';
|
|
@@ -1718,6 +1721,24 @@ function zaloAccountHealth(bot) {
|
|
|
1718
1721
|
|| accounts.find((account) => account.accountId === (bot?.accountId || 'default'))
|
|
1719
1722
|
|| null;
|
|
1720
1723
|
}
|
|
1724
|
+
// Connection/login labels for one bot. lastError wins over running: the gateway keeps
|
|
1725
|
+
// running=true while the Zalo listener is stuck in a retry loop (e.g. "Đăng nhập thất
|
|
1726
|
+
// bại"), so a running-first check painted dead bots green.
|
|
1727
|
+
function zaloHealthBadges(bot) {
|
|
1728
|
+
const health = bot?.channel === 'zalo-personal' ? zaloAccountHealth(bot) : null;
|
|
1729
|
+
const loginFailed = !!health?.lastError && /đăng nhập|log ?in|auth|credential|session/i.test(String(health.lastError));
|
|
1730
|
+
const connection = !health ? t('Chưa rõ','Unknown')
|
|
1731
|
+
: health.lastError ? t('Mất kết nối','Disconnected')
|
|
1732
|
+
: health.running ? t('Đã kết nối','Connected')
|
|
1733
|
+
: t('Đang kết nối','Connecting');
|
|
1734
|
+
const login = !health ? t('Chưa rõ','Unknown')
|
|
1735
|
+
: loginFailed ? t('Phiên hết hạn','Session expired')
|
|
1736
|
+
: health.sessionSaved ? t('Đã đăng nhập','Logged in')
|
|
1737
|
+
: t('Chưa đăng nhập','Not logged in');
|
|
1738
|
+
const connectionTone = !health ? 'warn' : health.lastError ? 'bad' : health.running ? 'ok' : 'warn';
|
|
1739
|
+
const loginTone = !health ? 'warn' : loginFailed ? 'bad' : health.sessionSaved ? 'ok' : 'bad';
|
|
1740
|
+
return { health, connection, login, connectionTone, loginTone };
|
|
1741
|
+
}
|
|
1721
1742
|
function zaloToolbar(channelBots = []) {
|
|
1722
1743
|
const active = channelBots.find((bot) => bot.id === state.activeBotId) || channelBots[0];
|
|
1723
1744
|
const health = zaloAccountHealth(active);
|
|
@@ -1729,6 +1750,42 @@ async function loadZaloHealth(silent=false){
|
|
|
1729
1750
|
try { state.zaloHealth = await api('/api/zalo/health' + projectQuery()); } catch (_) { state.zaloHealth = null; }
|
|
1730
1751
|
if (!silent) render();
|
|
1731
1752
|
}
|
|
1753
|
+
// Patch the connection/login badges of the rendered bot cards in place. render() rebuilds
|
|
1754
|
+
// the whole panel (killing focus/scroll), so the auto-refresh below must never call it.
|
|
1755
|
+
function updateZaloHealthDom() {
|
|
1756
|
+
const bots = state.install?.bots || [];
|
|
1757
|
+
document.querySelectorAll('.bot-item[data-bot-id] .zalo-bot-health').forEach((wrap) => {
|
|
1758
|
+
const id = wrap.closest('[data-bot-id]')?.dataset.botId;
|
|
1759
|
+
const bot = bots.find((b) => b.id === id);
|
|
1760
|
+
if (!bot) return;
|
|
1761
|
+
const badges = zaloHealthBadges(bot);
|
|
1762
|
+
const ems = wrap.querySelectorAll('em');
|
|
1763
|
+
if (ems[0]) { ems[0].className = badges.connectionTone; ems[0].textContent = badges.connection; }
|
|
1764
|
+
if (ems[1]) { ems[1].className = badges.loginTone; ems[1].textContent = badges.login; }
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
// Live status: poll the health endpoint while Zalo bots are on screen (the server caches
|
|
1768
|
+
// the ~3s CLI probe with a short TTL, so this stays cheap) and patch the badges in place —
|
|
1769
|
+
// no more pressing "Làm mới" to see a bot drop or come back.
|
|
1770
|
+
const ZALO_HEALTH_POLL_MS = 10000;
|
|
1771
|
+
let zaloHealthPollBusy = false;
|
|
1772
|
+
// Debounced instant refresh for log-visible state changes (login saved, container
|
|
1773
|
+
// restarted...) so the badges flip within ~2s instead of waiting out a poll tick.
|
|
1774
|
+
let zaloHealthRefreshTimer = null;
|
|
1775
|
+
function scheduleZaloHealthRefresh() {
|
|
1776
|
+
if (zaloHealthRefreshTimer) return;
|
|
1777
|
+
zaloHealthRefreshTimer = setTimeout(async () => {
|
|
1778
|
+
zaloHealthRefreshTimer = null;
|
|
1779
|
+
try { await loadZaloHealth(true); updateZaloHealthDom(); } catch (_) {}
|
|
1780
|
+
}, 1500);
|
|
1781
|
+
}
|
|
1782
|
+
setInterval(async () => {
|
|
1783
|
+
if (document.hidden || zaloHealthPollBusy) return;
|
|
1784
|
+
if (!activeProjectDir()) return;
|
|
1785
|
+
if (!document.querySelector('.bot-item[data-bot-id] .zalo-bot-health')) return;
|
|
1786
|
+
zaloHealthPollBusy = true;
|
|
1787
|
+
try { await loadZaloHealth(true); updateZaloHealthDom(); } finally { zaloHealthPollBusy = false; }
|
|
1788
|
+
}, ZALO_HEALTH_POLL_MS);
|
|
1732
1789
|
async function loadFeatureFlags(silent=false){
|
|
1733
1790
|
const botId=currentBotId();
|
|
1734
1791
|
if (!activeProjectDir()) { state.featureFlags = {}; state.featureInstalled = {}; state.featureVersions = {}; if (!silent) render(); return; }
|
|
@@ -1784,6 +1841,7 @@ function appendLogLine(line) {
|
|
|
1784
1841
|
});
|
|
1785
1842
|
return;
|
|
1786
1843
|
}
|
|
1844
|
+
if (/\[zalo-connect\].*(login saved|listener (connected|start failed)|restarted|login failed|logged out)/i.test(line)) scheduleZaloHealthRefresh();
|
|
1787
1845
|
const html = `<p>${escapeHtml(line)}</p>`;
|
|
1788
1846
|
if (state.zaloLoginOpen || /\[zalo-connect\]|zalo|qr|login|scan/i.test(line)) {
|
|
1789
1847
|
state.zaloLoginLines.push(cleanTerminalLine(line));
|