fix(portable): 微信扫码重定向/Telegram botToken/Windows 启动端口 (v2.1.11)

- 微信扫码卡死: config-server 跟随 IDC scaned_but_redirect 把后续轮询切到
  redirect_host,刷新二维码时重置 pollBaseUrl(扫码后不再卡住等不到 confirmed)。新增测试。
- Telegram 渠道改用 botToken(顶层 telegram 只认 botToken,旧 token 别名被静默忽略 → 连不上)。
- Windows-Start: 先起 config-server 再探测 gateway 端口,从 runtime.json 读实际
  configServerPort 并按该端口开 Config Center / 传给 wait-gateway(18788 被占也能开)。
- wait-gateway.bat: 用 ping 代替 timeout /t(避开 PATH 上 GNU coreutils timeout 冲突),接受 CONFIG_PORT 参数。
- Windows-Menu: IF 块内 echo 的 () 转义为 ^(^),修 v2.1.10 闪退回归。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hfshfg
2026-06-24 00:41:20 +08:00
parent 3439db32ad
commit 6ab87c6b67
8 changed files with 177 additions and 34 deletions

View File

@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import test from 'node:test';
import assert from 'node:assert/strict';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const server = readFileSync(join(repoRoot, 'portable', 'config-server', 'server.js'), 'utf8');
const configUi = readFileSync(join(repoRoot, 'portable', 'config-server', 'public', 'index.html'), 'utf8');
// The ilink QR API (StatusResponse in openclaw-weixin/src/auth/login-qr.ts) can return
// status "scaned_but_redirect" with a redirect_host: after the user scans, polling must
// move to a new IDC host or "confirmed" never arrives and the QR screen hangs forever.
// The config-server must mirror the plugin's redirect handling.
test('config-server follows the WeChat scaned_but_redirect IDC redirect', () => {
// Status polling uses a redirect-aware host, not the fixed apiBaseUrl directly.
assert.match(
server,
/pollWeChatQrStatus\(\s*login\.pollBaseUrl\s*\|\|\s*login\.apiBaseUrl/,
'status polling must use login.pollBaseUrl (falls back to apiBaseUrl)',
);
// On scaned_but_redirect, switch the poll host to redirect_host.
assert.match(
server,
/scaned_but_redirect[\s\S]{0,200}login\.pollBaseUrl\s*=\s*['"]https:\/\/['"]\s*\+\s*result\.redirect_host/,
'must set login.pollBaseUrl to https://<redirect_host> on scaned_but_redirect',
);
// The redirect case is reported to the client as "scaned" so it keeps polling.
assert.match(
server,
/scaned_but_redirect[\s\S]{0,260}return\s*\{\s*status:\s*['"]scaned['"]\s*\}/,
'scaned_but_redirect should surface as status "scaned" to the client',
);
});
test('config-server writes the Telegram bot token under the field OpenClaw reads (botToken)', () => {
// OpenClaw's top-level telegram channel schema only reads `botToken` (the legacy
// `token` alias is honored only inside accounts.<id>). Writing flat `token` silently
// disables Telegram, so the Config Center must write `botToken`.
assert.match(
configUi,
/channels\.telegram\s*=\s*\{[^}]*botToken:\s*tgToken/,
'telegram channel must be saved with botToken',
);
assert.doesNotMatch(
configUi,
/channels\.telegram\s*=\s*\{[^}]*\btoken:\s*tgToken/,
'telegram channel must not use the flat `token` field (ignored by OpenClaw)',
);
});
test('config-server resets the poll host when the QR is refreshed', () => {
// A refreshed QR comes from the original host, so the redirected poll host must reset,
// otherwise the new QR would be polled against a stale redirect host.
assert.match(
server,
/status:\s*'refreshed'[\s\S]{0,400}/,
'refresh branch should exist',
);
assert.match(
server,
/login\.pollBaseUrl\s*=\s*null;[\s\S]{0,200}status:\s*'refreshed'/,
'QR refresh must reset login.pollBaseUrl before returning refreshed',
);
});

View File

@@ -50,7 +50,7 @@ test('Windows startup keeps Config Center available even after model setup', ()
assert.match(
script,
/Opening Config Center[\s\S]*start "" http:\/\/127\.0\.0\.1:18788\//,
/Opening Config Center[\s\S]*start "" http:\/\/127\.0\.0\.1:%CONFIG_PORT%\//,
'Windows-Start.bat should always open Config Center for model/channel changes',
);
assert.doesNotMatch(
@@ -65,7 +65,7 @@ test('Windows gateway fallback does not force-open Dashboard', () => {
assert.match(
script,
/:timeout[\s\S]*start "" http:\/\/127\.0\.0\.1:18788\//,
/:timeout[\s\S]*start "" http:\/\/127\.0\.0\.1:%CONFIG_PORT%\//,
'wait-gateway.bat should return users to Config Center on timeout',
);
assert.doesNotMatch(
@@ -147,6 +147,48 @@ test('OpenClaw doctor launcher is read-only (no destructive repair flags)', () =
assert.doesNotMatch(bat, /doctor[^\n]*--force/);
});
// cmd.exe treats ')' as the end of an IF/FOR ( ... ) block, so an unescaped paren in
// an `echo` *inside* a block aborts parsing ("was unexpected at this time") and the
// window flash-closes. This shipped once in v2.1.10 (echo Direct-connect (NO_PROXY)).
// Track block depth structurally and flag any unescaped ( or ) in echoes inside a block.
function unescapedParenEchoesInsideBlocks(bat) {
const offenders = [];
let depth = 0;
for (const raw of bat.split(/\r?\n/)) {
const line = raw.trim();
if (depth > 0 && /^echo\b/i.test(line)) {
// cmd eats an unescaped ')' that sits at the END of an echo (cosmetic: the ')'
// just disappears). The flash-exit only happens when a ')' is followed by more
// text on the line (e.g. "(NO_PROXY): value" → ')' closes the block, ": value"
// then errors). So: drop escaped ^), drop trailing ')'/whitespace, and flag any
// ')' that survives (meaning it had text after it).
let s = line.replace(/\^\)/g, '').replace(/[)\s]+$/, '');
if (s.includes(')')) offenders.push(raw.trim());
}
// structural depth: ') else (' keeps depth; leading ')' closes; if/for line ending in '(' opens
if (/^\)\s*else\b.*\($/i.test(line)) { /* same depth */ }
else if (/^\)/.test(line)) depth = Math.max(0, depth - 1);
if (/^\($/.test(line) || /\b(if|for)\b.*[^^]\(\s*$/i.test(line)) depth += 1;
}
return offenders;
}
test('Windows launchers have no unescaped parens in echoes inside IF/FOR blocks (v2.1.10 flash-exit regression)', () => {
for (const name of [
'Windows-Start.bat',
'Windows-IntranetFix.bat',
'Windows-LocalModel.bat',
'OpenClaw-Doctor.bat',
'Windows-Diagnose.bat',
'Windows-Menu.bat',
'Windows-Install.bat',
]) {
const bat = readRepoFile('portable', name);
const offenders = unescapedParenEchoesInsideBlocks(bat);
assert.deepEqual(offenders, [], `${name} has unescaped parens in block echo(es): ${offenders.join(' | ')}`);
}
});
test('PowerShell installer generated start.bat disables OpenClaw bonjour discovery', () => {
const script = readRepoFile('install', 'install.ps1');