diff --git a/portable/Windows-Menu.bat b/portable/Windows-Menu.bat index b74c590..b2b6364 100644 --- a/portable/Windows-Menu.bat +++ b/portable/Windows-Menu.bat @@ -477,12 +477,12 @@ echo Fetching latest version... for /f "tokens=*" %%v in ('"%NODE_BIN%" -e "const https=require('https');https.get('https://registry.npmmirror.com/openclaw/latest',r=>{let d='';r.on('data',c=>d+=c);r.on('end',()=>{try{console.log(JSON.parse(d).version)}catch(e){console.log('error')}})})" 2^>nul') do set LATEST_VER=%%v if "!LATEST_VER!"=="" ( - echo Could not fetch latest version (network issue?) + echo Could not fetch latest version ^(network issue?^) pause goto :menu ) if "!LATEST_VER!"=="error" ( - echo Could not fetch latest version (network issue?) + echo Could not fetch latest version ^(network issue?^) pause goto :menu ) @@ -550,7 +550,7 @@ if !BK_COUNT! gtr 3 ( echo Cleaned !DEL_NUM! old backups. ) ) else ( - echo Backups: !BK_COUNT! (no cleanup needed) + echo Backups: !BK_COUNT! ^(no cleanup needed^) ) REM Clean old logs (>7 days) @@ -598,7 +598,7 @@ if "%plgchoice%"=="b" ( echo. echo Common plugins: echo @icesword760/openclaw-wechat - WeChat - echo @nicepkg/openclaw-plugin-qq - QQ (community) + echo @nicepkg/openclaw-plugin-qq - QQ ^(community^) echo. set /p plgname=" Plugin name (empty to cancel): " if not "!plgname!"=="" ( diff --git a/portable/Windows-Start.bat b/portable/Windows-Start.bat index f81a6bc..d287211 100644 --- a/portable/Windows-Start.bat +++ b/portable/Windows-Start.bat @@ -107,7 +107,8 @@ for /f "usebackq tokens=1,* delims==" %%a in (`""%NODE_BIN%" "%UCLAW_DIR%lib\res ) if defined NO_PROXY ( set "no_proxy=%NO_PROXY%" - echo Direct-connect (NO_PROXY): %NO_PROXY% + REM Note: no unescaped parens in echo inside this IF block - cmd treats ) as block-end. + echo Direct-connect via NO_PROXY: %NO_PROXY% ) REM Async update check (non-blocking, 5s timeout, silent failure) @@ -134,7 +135,30 @@ if exist "%WECHAT_PLUGIN_SRC%\openclaw.plugin.json" ( ) ) -REM Find available port +REM Start Config Server in background +echo Starting Config Center on port 18788... +set "CONFIG_SERVER=%UCLAW_DIR%config-server" +set "RUNTIME_JSON=%STATE_DIR%\runtime.json" +del "%RUNTIME_JSON%" >nul 2>&1 +start /B "" "%NODE_BIN%" "%CONFIG_SERVER%\server.js" >nul 2>&1 + +REM Wait for Config Server with polling instead of a fixed delay. +REM It writes runtime.json with the actual fallback port when ready. +set /a CFG_TRIES=0 +:wait_config +if exist "%RUNTIME_JSON%" goto :config_ready +set /a CFG_TRIES+=1 +if %CFG_TRIES% geq 20 goto :config_ready +ping -n 1 -w 300 127.0.0.1 >nul 2>&1 +goto :wait_config +:config_ready +set "CONFIG_PORT=18788" +if exist "%RUNTIME_JSON%" ( + for /f "usebackq tokens=*" %%p in (`powershell -NoProfile -Command "try { (Get-Content -Raw '%RUNTIME_JSON%' | ConvertFrom-Json).configServerPort } catch {}" 2^>nul`) do set "CONFIG_PORT=%%p" +) +echo Config Center port: %CONFIG_PORT% + +REM Find available gateway port after Config Center has bound its port. set PORT=18789 :check_port netstat -an | findstr ":%PORT% " | findstr "LISTENING" >nul 2>&1 @@ -152,24 +176,6 @@ if %errorlevel%==0 ( echo Starting OpenClaw on port %PORT%... echo. -REM Start Config Server in background -echo Starting Config Center on port 18788... -set "CONFIG_SERVER=%UCLAW_DIR%config-server" -start /B "" "%NODE_BIN%" "%CONFIG_SERVER%\server.js" >nul 2>&1 - -REM Wait for Config Server with polling instead of a fixed delay. -REM It is usually ready in under one second. Poll about every 0.3 seconds, -REM up to about 6 seconds, then continue anyway. -set /a CFG_TRIES=0 -:wait_config -netstat -an | findstr ":18788 " | findstr "LISTENING" >nul 2>&1 -if %errorlevel%==0 goto :config_ready -set /a CFG_TRIES+=1 -if %CFG_TRIES% geq 20 goto :config_ready -ping -n 1 -w 300 127.0.0.1 >nul 2>&1 -goto :wait_config -:config_ready - REM Do not open Dashboard before the gateway is ready. REM Slow USB drives may need tens of seconds to stage bundled deps. REM Open the local startup page now, and always open Config Center too. @@ -182,11 +188,11 @@ set "LOADING_URL=file:///%LOADING_PATH:\=/%?port=%PORT%&token=uclaw" start "" "%LOADING_URL%" echo Opening Config Center... -start "" http://127.0.0.1:18788/ +start "" http://127.0.0.1:%CONFIG_PORT%/ REM Fallback watcher: if the startup page cannot poll from file URLs, REM keep polling and reopen Config Center after the gateway is ready. -start /B "" cmd /c ""%UCLAW_DIR%lib\wait-gateway.bat" %PORT%" +start /B "" cmd /c ""%UCLAW_DIR%lib\wait-gateway.bat" %PORT% %CONFIG_PORT%" REM Prewarm gateway in the background after it becomes ready. start /B "" "%NODE_BIN%" "%UCLAW_DIR%lib\prewarm.mjs" %PORT% uclaw >nul 2>&1 diff --git a/portable/config-server/public/index.html b/portable/config-server/public/index.html index 9a970c4..3ea426d 100644 --- a/portable/config-server/public/index.html +++ b/portable/config-server/public/index.html @@ -678,7 +678,9 @@ async function saveChannelsAndOpen() { var channels = {}; var tgToken = document.getElementById('ch-telegram-token').value.trim(); - if (tgToken) channels.telegram = { enabled: true, token: tgToken, dmPolicy: 'pairing' }; + // OpenClaw 顶层 telegram 渠道只认 botToken(旧 token 别名仅在 accounts. 里有效)。 + // 之前写成 token 会被静默忽略 → Telegram 接不上。 + if (tgToken) channels.telegram = { enabled: true, botToken: tgToken, dmPolicy: 'pairing' }; var qqId = document.getElementById('ch-qqbot-appid').value.trim(); var qqSecret = document.getElementById('ch-qqbot-secret').value.trim(); diff --git a/portable/config-server/server.js b/portable/config-server/server.js index f196846..1cd1fba 100644 --- a/portable/config-server/server.js +++ b/portable/config-server/server.js @@ -246,7 +246,13 @@ async function handleWeChatStatus(sessionKey) { return { status: 'expired', message: 'Session expired' }; } - const result = await pollWeChatQrStatus(login.apiBaseUrl, login.qrcode); + // 状态轮询用 pollBaseUrl(IDC 重定向后会指向新主机);二维码获取/刷新始终用原始 + // apiBaseUrl(与官方插件一致:refresh 回到固定主机,只有 status 轮询跟随重定向)。 + const result = await pollWeChatQrStatus(login.pollBaseUrl || login.apiBaseUrl, login.qrcode); + // 微信登录状态流转日志(跳过高频的 wait,便于排查"扫码卡死"类问题)。 + if (result.status && result.status !== 'wait') { + console.log(`[wechat] status=${result.status}` + (result.redirect_host ? ` redirect_host=${result.redirect_host}` : '')); + } if (result.status === 'expired') { // Try to refresh QR code @@ -261,6 +267,8 @@ async function handleWeChatStatus(sessionKey) { login.qrcode = refreshed.qrcode; login.qrcodeUrl = newQr; login.startedAt = Date.now(); + // 新二维码来自原始主机,重置轮询主机,避免拿新码去轮询旧的重定向主机。 + login.pollBaseUrl = null; return { status: 'refreshed', qrcodeUrl: newQr }; } @@ -302,6 +310,18 @@ async function handleWeChatStatus(sessionKey) { }; } + // IDC 重定向:用户扫码后,ilink 服务端可能要求把后续轮询切换到另一个数据中心主机 + // (status=scaned_but_redirect + redirect_host)。必须跟着切,否则一直轮询旧主机, + // 扫码后永远等不到 confirmed——表现为「扫了码却卡死不前进」。 + // 同款逻辑见官方插件 openclaw-weixin/src/auth/login-qr.ts 的 scaned_but_redirect 分支。 + if (result.status === 'scaned_but_redirect') { + if (result.redirect_host) { + login.pollBaseUrl = 'https://' + result.redirect_host; + } + // 对前端按「已扫码」处理:显示提示并继续轮询,下一轮已指向新主机。 + return { status: 'scaned' }; + } + return { status: result.status }; } diff --git a/portable/lib/wait-gateway.bat b/portable/lib/wait-gateway.bat index d65b02b..f2589a1 100644 --- a/portable/lib/wait-gateway.bat +++ b/portable/lib/wait-gateway.bat @@ -4,11 +4,13 @@ REM Slow USB drives can need tens of seconds before the gateway listens. REM REM Windows-Start opens Config Center immediately, so the ready path only REM exits quietly. On timeout, reopen Config Center as a recovery hint. -REM Usage (called in background by Windows-Start.bat): wait-gateway.bat PORT +REM Usage (called in background by Windows-Start.bat): wait-gateway.bat PORT CONFIG_PORT REM Polls every 2 seconds, up to about 5 minutes. set "PORT=%~1" if "%PORT%"=="" set "PORT=18789" +set "CONFIG_PORT=%~2" +if "%CONFIG_PORT%"=="" set "CONFIG_PORT=18788" set /a TRIES=0 :wait_loop @@ -16,12 +18,15 @@ netstat -an | findstr ":%PORT% " | findstr "LISTENING" >nul 2>&1 if %errorlevel%==0 goto :ready set /a TRIES+=1 if %TRIES% geq 150 goto :timeout -timeout /t 2 /nobreak >nul +REM Use ping (not "timeout /t") for the ~2s delay: if a GNU coreutils "timeout" +REM is on PATH (Git/MSYS bin), it rejects "/t" and this poll becomes a busy-loop. +REM ping for the delay is PATH-robust and matches Windows-Start.bat's wait loop. +ping -n 3 127.0.0.1 >nul 2>&1 goto :wait_loop :ready exit /b 0 :timeout -start "" http://127.0.0.1:18788/ +start "" http://127.0.0.1:%CONFIG_PORT%/ exit /b 1 diff --git a/tests/wechat-login-redirect.test.mjs b/tests/wechat-login-redirect.test.mjs new file mode 100644 index 0000000..246c8a6 --- /dev/null +++ b/tests/wechat-login-redirect.test.mjs @@ -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:// 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.). 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', + ); +}); diff --git a/tests/windows-launchers.test.mjs b/tests/windows-launchers.test.mjs index 2389e1e..cce2de3 100644 --- a/tests/windows-launchers.test.mjs +++ b/tests/windows-launchers.test.mjs @@ -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'); diff --git a/u-claw-app/package.json b/u-claw-app/package.json index f72e758..71c3f56 100644 --- a/u-claw-app/package.json +++ b/u-claw-app/package.json @@ -1,6 +1,6 @@ { "name": "u-claw", - "version": "2.1.10", + "version": "2.1.11", "description": "U-Claw - AI 助手桌面版,插上 U 盘就能用", "main": "src/main.js", "author": "U-Claw (https://u-claw.org)",