From 5e9f72997be2dd98b4fb07c90fd870451a58202c Mon Sep 17 00:00:00 2001 From: "Angnuo Li (from Dev Box)" Date: Thu, 12 Nov 2026 15:32:24 +0800 Subject: [PATCH] fix(startup): reuse bundled node during pairing reconnect --- deployer/windows_setup.py | 46 ++----- desktop/src/bundled-windows-node-host.test.ts | 110 ++++++++++++++-- desktop/src/bundled-windows-node-host.ts | 121 ++++++++++++++++-- .../startup-optimization-2026-11-12.md | 29 +++++ .../reset-microclaw-for-first-install.ps1 | 3 +- 5 files changed, 247 insertions(+), 62 deletions(-) create mode 100644 docs/reference/startup-optimization-2026-11-12.md diff --git a/deployer/windows_setup.py b/deployer/windows_setup.py index d9c03a3..1e58a5f 100644 --- a/deployer/windows_setup.py +++ b/deployer/windows_setup.py @@ -4,7 +4,6 @@ configures npm to use the taobao registry, and installs openclaw. """ -import base64 import hashlib import json import ntpath @@ -6471,7 +6470,6 @@ def ensure_defender_exclusions(self) -> bool: dirs = [ self.node_dir, # ~/.openclaw-node (node + node_modules) appdata / "npm" / "node_modules" / "openclaw", - appdata / "microclaw" / "runtime", # bundled runtime and sibling staging directories Path.home() / ".openclaw", # config, skills, plugins, compile-cache Path.home() / ".openclaw-git", # PortableGit Path.home() / ".microclaw", # Electron desktop client @@ -6482,7 +6480,7 @@ def ensure_defender_exclusions(self) -> bool: marker = DEFAULT_DESKTOP_DIR / "install-state" / "defender-exclusions.json" try: payload = json.loads(marker.read_text(encoding="utf-8")) - if payload == {"schema": 2, "paths": normalized}: + if payload == {"schema": 1, "paths": normalized}: self.log.info(" Windows Defender exclusions already configured") return True except (OSError, json.JSONDecodeError, TypeError): @@ -6494,7 +6492,7 @@ def ensure_defender_exclusions(self) -> bool: marker.parent.mkdir(parents=True, exist_ok=True) temporary = marker.with_suffix(".tmp") temporary.write_text( - json.dumps({"schema": 2, "paths": normalized}, indent=2) + "\n", + json.dumps({"schema": 1, "paths": normalized}, indent=2) + "\n", encoding="utf-8", ) os.replace(temporary, marker) @@ -6545,52 +6543,26 @@ def _add_defender_exclusions(self, dirs: list[Path]) -> bool: self.log.info(" Windows Defender exclusions already configured") return True - # Preserve literal paths across both PowerShell processes. The elevated - # child must confirm the settings and return its own exit code; launching - # it successfully does not mean Defender accepted the exclusions. - safe_paths = ",".join("'" + str(d).replace("'", "''") + "'" for d in missing) - elevated_script = ( - "$ErrorActionPreference = 'Stop'; " - "try { " - f"$requestedPaths = @({safe_paths}); " - "Add-MpPreference -ExclusionPath $requestedPaths -ErrorAction Stop; " - "$configuredPaths = @((Get-MpPreference -ErrorAction Stop).ExclusionPath | " - "Where-Object { $_ } | ForEach-Object { " - "[IO.Path]::GetFullPath($_).TrimEnd([char[]]'\\/') }); " - "foreach ($requestedPath in $requestedPaths) { " - "$normalizedPath = [IO.Path]::GetFullPath($requestedPath).TrimEnd([char[]]'\\/'); " - "if ($configuredPaths -notcontains $normalizedPath) { " - "throw 'Defender did not retain a requested exclusion' } }; " - "exit 0 " - "} catch { exit 1 }" - ) - encoded = base64.b64encode(elevated_script.encode("utf-16le")).decode("ascii") - launch_script = ( - "$ErrorActionPreference = 'Stop'; try { " - "$defenderProcess = Start-Process powershell.exe -Verb RunAs -Wait " - "-WindowStyle Hidden -PassThru " - f"-ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','{encoded}'); " - "exit $defenderProcess.ExitCode } catch { exit 1 }" - ) + # Add-MpPreference always requires admin — elevate directly + safe_paths = ",".join(f"''{str(d).replace(chr(39), chr(39) * 2)}''" for d in missing) try: result = self._run( [ "powershell", "-NoProfile", "-Command", - launch_script, + f"Start-Process powershell -Verb RunAs -Wait -WindowStyle Hidden " + f"-ArgumentList '-NoProfile','-Command'," + f"'Add-MpPreference -ExclusionPath {safe_paths}'", ], capture_output=True, timeout=30, ) if result.returncode != 0: - self.log.warning( - " Defender exclusions were not confirmed. Administrator approval or " - "organization policy may prevent changes; installation will continue." - ) + self.log.warning(" Defender exclusion command did not complete successfully") return False for d in missing: - self.log.info(f" Defender exclusion added and confirmed: {d}") + self.log.info(f" Defender exclusion added: {d}") return True except Exception as e: self.log.warning(f" Defender 排除项添加失败(需要管理员权限): {e}") diff --git a/desktop/src/bundled-windows-node-host.test.ts b/desktop/src/bundled-windows-node-host.test.ts index bf5da63..4b97da8 100644 --- a/desktop/src/bundled-windows-node-host.test.ts +++ b/desktop/src/bundled-windows-node-host.test.ts @@ -20,6 +20,7 @@ import { findBundledDevicePairRequest, findBundledNodePairRequest, inspectBundledNodeList, + waitForBundledNodeReconnect, replaceFileWithRetry, revokeActivationLeaseFile, sanitizeBundledHostDiagnostic, @@ -825,26 +826,23 @@ describe("bundled Windows node host", () => { ).toBe("owned"); }); - it("waits for the old connection to close and reconnects after node reapproval", async () => { + it("keeps the node process while its client reconnects after node reapproval", async () => { const nodeId = "d".repeat(64); const commands = [...WINDOWS_NODE_MXC_NODE_COMMANDS]; const startOptions = { requestedStrength: "off" }; const initialProcess = { exitCode: null, signalCode: null }; - const restartedProcess = { exitCode: null, signalCode: null }; const host = new BundledWindowsNodeHost(); Object.assign(host, { process: initialProcess, startOptions }); vi.spyOn(host, "ensureIdentityNodeId").mockReturnValue(nodeId); const order: string[] = []; const stop = vi.spyOn(host, "stop"); - const start = vi.spyOn(host, "start").mockImplementation(async (options) => { - order.push("start"); - Object.assign(host, { process: restartedProcess, startOptions: options }); - }); + const start = vi.spyOn(host, "start").mockResolvedValue(); let approved = false; let nodeListCalls = 0; const node = { nodeId, connected: true, + connectedAtMs: 1000, commands, displayName: "MicroClaw Bundled Windows Node", }; @@ -864,15 +862,19 @@ describe("bundled Windows node host", () => { case "node.list": nodeListCalls += 1; if (nodeListCalls === 1) { - order.push("previous connected"); + order.push("before approval"); return { nodes: [node] }; } if (nodeListCalls === 2) { + order.push("previous connected"); + return { nodes: [node] }; + } + if (nodeListCalls === 3) { order.push("previous closed"); return { nodes: [] }; } - order.push("new connected"); - return { nodes: [node] }; + if (nodeListCalls === 4) order.push("new connected"); + return { nodes: [{ ...node, connectedAtMs: 2000 }] }; default: throw new Error(`Unexpected request: ${method}`); } @@ -881,18 +883,19 @@ describe("bundled Windows node host", () => { await host.ensurePaired(gateway); - expect(stop).toHaveBeenCalledOnce(); - expect(start).toHaveBeenCalledOnce(); + expect(stop).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + expect(Reflect.get(host, "process")).toBe(initialProcess); expect(order).toEqual([ + "before approval", "approve", "previous connected", "previous closed", - "start", "new connected", ]); }); - it("does not restart a stopped node while waiting for pairing reconnection", async () => { + it("does not approve or restart a stopped node after a pairing snapshot returns", async () => { const nodeId = "d".repeat(64); const commands = [...WINDOWS_NODE_MXC_NODE_COMMANDS]; const host = new BundledWindowsNodeHost(); @@ -929,8 +932,87 @@ describe("bundled Windows node host", () => { host.stop(true); release({ nodes: [] }); - await expect(pairing).rejects.toThrow("changed while waiting for pairing reconnection"); + await expect(pairing).rejects.toThrow("process was replaced during pairing"); expect(start).not.toHaveBeenCalled(); + expect(gateway.request).not.toHaveBeenCalledWith("node.pair.approve", expect.anything()); + }); + + it("recognizes a newer connection when polling did not observe the brief disconnect", async () => { + const nodeId = "d".repeat(64); + const gateway = { + request: vi.fn().mockResolvedValue({ + nodes: [ + { + nodeId, + connected: true, + connectedAtMs: 2000, + commands: [...WINDOWS_NODE_MXC_NODE_COMMANDS], + }, + ], + }), + } as unknown as BundledWindowsNodeGateway; + await expect( + waitForBundledNodeReconnect({ + gateway, + nodeId, + previousConnection: { connected: true, connectedAtMs: 1000 }, + assertCurrent: () => undefined, + }), + ).resolves.toBe(true); + }); + + it("does not treat the old connection as proof of pairing reconnection", async () => { + const nodeId = "d".repeat(64); + let elapsed = 0; + const gateway = { + request: vi.fn().mockResolvedValue({ + nodes: [ + { + nodeId, + connected: true, + connectedAtMs: 1000, + commands: [...WINDOWS_NODE_MXC_NODE_COMMANDS], + }, + ], + }), + } as unknown as BundledWindowsNodeGateway; + await expect( + waitForBundledNodeReconnect({ + gateway, + nodeId, + previousConnection: { connected: true, connectedAtMs: 1000 }, + assertCurrent: () => undefined, + now: () => elapsed, + wait: async (milliseconds) => { + elapsed += milliseconds; + }, + }), + ).resolves.toBe(false); + expect(elapsed).toBe(5000); + }); + + it("still rejects unexpected commands on a reconnected node", async () => { + const nodeId = "d".repeat(64); + const gateway = { + request: vi.fn().mockResolvedValue({ + nodes: [ + { + nodeId, + connected: true, + connectedAtMs: 2000, + commands: ["device.info"], + }, + ], + }), + } as unknown as BundledWindowsNodeGateway; + await expect( + waitForBundledNodeReconnect({ + gateway, + nodeId, + previousConnection: { connected: true, connectedAtMs: 1000 }, + assertCurrent: () => undefined, + }), + ).rejects.toThrow("unexpected command surface"); }); it("selects only the exact bundled identity and ignores foreign same-name records", () => { diff --git a/desktop/src/bundled-windows-node-host.ts b/desktop/src/bundled-windows-node-host.ts index b34b53c..77fadf7 100644 --- a/desktop/src/bundled-windows-node-host.ts +++ b/desktop/src/bundled-windows-node-host.ts @@ -129,7 +129,7 @@ export interface BundledWindowsNodeAmbientAccess extends WindowsNodeMxcAmbientAc } export interface BundledWindowsNodeGateway { - request(method: string, params?: unknown): Promise; + request(method: string, params?: unknown, timeoutMs?: number): Promise; } export class BundledWindowsNodeHost { @@ -752,6 +752,11 @@ export class BundledWindowsNodeHost { let approved = false; let lastForeignNodeCount = -1; const deadline = Date.now() + 5 * 60_000; + const assertPairingCurrent = () => { + if (!expectedProcess) throw new Error("Bundled Windows node process is not running"); + const error = this.describePairingProcessChange(expectedProcess); + if (error) throw new Error(error); + }; const restartAfterApproval = async () => { const startOptions = this.startOptions; if (!startOptions || this.process !== expectedProcess) { @@ -801,9 +806,30 @@ export class BundledWindowsNodeHost { const nodePairing = await gateway.request("node.pair.list", {}); const nodePairRequestId = findBundledNodePairRequest(nodePairing, nodeId); if (nodePairRequestId) { + const previousConnection = observeBundledNodeConnection( + await gateway.request("node.list", {}, 2_000), + nodeId, + ); + assertPairingCurrent(); await gateway.request("node.pair.approve", { requestId: nodePairRequestId }); + assertPairingCurrent(); approved = true; - await restartAfterApproval(); + const reconnectStartedAt = Date.now(); + // The pinned client handles node.pair.resolved by reconnecting its + // socket. Preserve the host process, policy and approval pipe while it + // does so. Do not accept the pre-approval connection as readiness. + const reconnected = await waitForBundledNodeReconnect({ + gateway, + nodeId, + previousConnection, + assertCurrent: assertPairingCurrent, + }); + this.startOptions?.onDiagnostic?.("pairing-reconnect", { + processId: expectedProcess?.pid ?? null, + mode: reconnected ? "same-process" : "restart-fallback", + elapsedMs: Date.now() - reconnectStartedAt, + }); + if (!reconnected) await restartAfterApproval(); continue; } @@ -1194,11 +1220,11 @@ export function assertApprovalResponseMatches(pendingRequestId: string, response } } -export function inspectBundledNodeList( +function findBundledNodeRecord( payload: unknown, nodeId: string, ): { - node: { connected: boolean; displayName: string; commands: string[] } | null; + match: Record | undefined; foreignNodeCount: number; } { const nodes = Array.isArray(payload) @@ -1215,13 +1241,90 @@ export function inspectBundledNodeList( const id = stringField(node, "nodeId") || stringField(node, "id"); return id.length > 0 && id.toLowerCase() !== nodeId.toLowerCase(); }).length; - const match = matches[0]; + return { match: matches[0], foreignNodeCount }; +} + +interface BundledNodeConnection { + connected: boolean; + connectedAtMs: number | null; +} + +function isNodeRecordConnected(node: Record): boolean { + return ( + node.connected === true || + node.isConnected === true || + stringField(node, "status").toLowerCase() === "connected" + ); +} + +export function observeBundledNodeConnection( + payload: unknown, + nodeId: string, +): BundledNodeConnection { + const { match } = findBundledNodeRecord(payload, nodeId); + return { + connected: match !== undefined && isNodeRecordConnected(match), + connectedAtMs: + match && + typeof match.connectedAtMs === "number" && + Number.isSafeInteger(match.connectedAtMs) && + match.connectedAtMs > 0 + ? match.connectedAtMs + : null, + }; +} + +export async function waitForBundledNodeReconnect(params: { + gateway: BundledWindowsNodeGateway; + nodeId: string; + previousConnection: BundledNodeConnection; + assertCurrent: () => void; + now?: () => number; + wait?: (milliseconds: number) => Promise; +}): Promise { + const now = params.now ?? Date.now; + const wait = params.wait ?? delay; + const deadline = now() + 5_000; + let observedDisconnect = false; + while (now() < deadline) { + params.assertCurrent(); + try { + const remaining = deadline - now(); + if (remaining <= 0) break; + const payload = await params.gateway.request("node.list", {}, Math.min(1_000, remaining)); + params.assertCurrent(); + const connection = observeBundledNodeConnection(payload, params.nodeId); + if (!connection.connected) observedDisconnect = true; + const newerConnection = + params.previousConnection.connectedAtMs !== null && + connection.connectedAtMs !== null && + connection.connectedAtMs > params.previousConnection.connectedAtMs; + if (connection.connected && (observedDisconnect || newerConnection)) { + // A fresh connection must still advertise exactly the allowed command + // surface. Ownership/pairing/activation checks are not bypassed. + return inspectBundledNodeList(payload, params.nodeId).node?.connected === true; + } + } catch (error) { + if (!isGatewayRequestTimeout(error)) throw error; + } + params.assertCurrent(); + await wait(Math.max(0, Math.min(250, deadline - now()))); + } + params.assertCurrent(); + return false; +} + +export function inspectBundledNodeList( + payload: unknown, + nodeId: string, +): { + node: { connected: boolean; displayName: string; commands: string[] } | null; + foreignNodeCount: number; +} { + const { match, foreignNodeCount } = findBundledNodeRecord(payload, nodeId); if (!match) return { node: null, foreignNodeCount }; const node = { - connected: - match.connected === true || - match.isConnected === true || - stringField(match, "status").toLowerCase() === "connected", + connected: isNodeRecordConnected(match), displayName: stringField(match, "displayName") || stringField(match, "name"), commands: stringArrayField(match, "commands"), }; diff --git a/docs/reference/startup-optimization-2026-11-12.md b/docs/reference/startup-optimization-2026-11-12.md new file mode 100644 index 0000000..e564dce --- /dev/null +++ b/docs/reference/startup-optimization-2026-11-12.md @@ -0,0 +1,29 @@ +# 2026-11-12 首次启动优化 + +## 用户实际启动基线 + +2026-11-12 14:55 首次打开,桌面进程 PID 30264,Gateway PID 10320。 +主进程计时:窗口显示 2.275 秒、HTTP 就绪 26.836 秒、WebSocket 连接 +35.842 秒、应用服务就绪 43.907 秒、首次历史响应 46.680 秒。 +ASAR 缓存命中,运行时准备 0.285 秒,没有重复展开。 + +WebSocket 连通到应用就绪的 8.065 秒包含内置 Windows 节点初始化、设备配对、 +节点授权及执行环境就绪。日志有 3 次节点启动;代码在设备配对和节点授权通过后 +分别调用了完整进程重启。这些时间是改动前的用户实际日志,不是新实现的测试结果。 + +## 本次修改 + +- 设备配对因鉴权拒绝导致连接断开时,保留原来的进程重启。 +- 节点授权后,优先使用客户端已有的 `node.pair.resolved` 自动重连机制。 + 以同一节点的断开/重新连接或 `connectedAtMs` 增长确认连接更新,并检查新连接的 + 命令集合;不会把授权前的旧连接直接算作就绪。 +- 保留原进程身份检查和执行环境准入。5 秒内没有确认自动重连时,才使用原来的 + 完整进程重启;进程被停止、替换或命令集合异常时仍拒绝继续。 +- 新增 `bundled-node-pairing-reconnect` 日志,记录 `same-process` 或 + `restart-fallback`、进程 ID 和等待耗时。 + +## 验证状态 + +按用户要求,只修改实现及相应回归用例,没有运行测试、构建、安装或启动验证。 +正常节点授权预计减少一次完整进程启动,实际启动次数和提速须以重建安装包后的 +下一次用户运行日志为准。 diff --git a/scripts/windows/reset-microclaw-for-first-install.ps1 b/scripts/windows/reset-microclaw-for-first-install.ps1 index e52ffa5..2d49496 100644 --- a/scripts/windows/reset-microclaw-for-first-install.ps1 +++ b/scripts/windows/reset-microclaw-for-first-install.ps1 @@ -291,11 +291,10 @@ foreach ($target in $cleanupRoots) { if (Get-Command Get-MpPreference -ErrorAction SilentlyContinue) { try { $preferences = Get-MpPreference - $productExclusionRoots = @($cleanupRoots) + @((Join-Path $roamingRoot 'microclaw\runtime')) foreach ($excluded in @($preferences.ExclusionPath)) { if (-not $excluded) { continue } $absolute = ConvertTo-CleanupPath $excluded - if ($absolute -in $productExclusionRoots -and $PSCmdlet.ShouldProcess($excluded, 'Remove product-specific Defender exclusion')) { + if ($absolute -in $cleanupRoots -and $PSCmdlet.ShouldProcess($excluded, 'Remove product-specific Defender exclusion')) { Remove-MpPreference -ExclusionPath $excluded } }