From 0a4d0daa8cc99e9b2c6bc78c2e67357272a9fd53 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 28 Jun 2026 11:47:06 -0700 Subject: [PATCH] fix(parallels): stabilize Windows beta smoke transport --- scripts/e2e/parallels/guest-transports.ts | 228 +++++++++++------- scripts/e2e/parallels/npm-update-smoke.ts | 1 + scripts/e2e/parallels/windows-smoke.ts | 44 ++-- .../parallels-npm-update-smoke.test.ts | 85 +++---- test/scripts/parallels-smoke-model.test.ts | 27 ++- 5 files changed, 220 insertions(+), 165 deletions(-) diff --git a/scripts/e2e/parallels/guest-transports.ts b/scripts/e2e/parallels/guest-transports.ts index ddc5e305729..eae03e17998 100644 --- a/scripts/e2e/parallels/guest-transports.ts +++ b/scripts/e2e/parallels/guest-transports.ts @@ -16,7 +16,6 @@ export interface WindowsBackgroundPowerShellOptions { beforeLaunchAttempt?: () => void; completedLogDrainGraceMs?: number; label: string; - logChunkBytes?: number; onLaunchRetry?: (message: string) => void; pollIntervalMs?: number; runCommand?: typeof run; @@ -59,6 +58,7 @@ function throwIfFailed(label: string, result: CommandResult, check: boolean | un } const POSIX_GUEST_SCRIPT_CLEANUP_TIMEOUT_MS = 30_000; +const WINDOWS_BACKGROUND_LOG_MAX_BYTES = 8 * 1024 * 1024; function appendCommandResult(phases: PhaseRunner, result: CommandResult): void { phases.append(result.stdout); @@ -88,36 +88,54 @@ export async function runWindowsBackgroundPowerShell( 1, Math.floor(options.completedLogDrainGraceMs ?? 30_000), ); - const logChunkBytes = Math.max(1, Math.floor(options.logChunkBytes ?? 1024 * 1024)); const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 5_000)); const runCommand = options.runCommand ?? run; const safeLabel = options.label.replaceAll(/[^A-Za-z0-9_-]/g, "-"); const nonce = `${safeLabel}-${randomUUID()}`; - const fileBase = `openclaw-parallels-${nonce}`; - const logLengthPrefix = `__OPENCLAW_LOG_LENGTH__:${nonce}:`; - const logOffsetPrefix = `__OPENCLAW_LOG_OFFSET__:${nonce}:`; + const guestRunDir = `openclaw-parallels\\${nonce}`; + const windowsDonePath = `%WINDIR%\\Temp\\${guestRunDir}\\done`; + const windowsLogPath = `%WINDIR%\\Temp\\${guestRunDir}\\run.log`; const backgroundExitPrefix = `__OPENCLAW_BACKGROUND_EXIT__:${nonce}:`; const backgroundDoneMarker = `__OPENCLAW_BACKGROUND_DONE__:${nonce}`; - const pathsScript = `$base = Join-Path $env:TEMP ${psSingleQuote(fileBase)} -$scriptPath = "$base.ps1" -$logPath = "$base.log" -$donePath = "$base.done" -$exitPath = "$base.exit" -$pidPath = "$base.pid" + const deadline = Date.now() + options.timeoutMs; + const pathsScript = `$runDir = Join-Path (Join-Path $env:WINDIR 'Temp\\openclaw-parallels') ${psSingleQuote(nonce)} +$scriptPath = Join-Path $runDir 'run.ps1' +$logPath = Join-Path $runDir 'run.log' +$donePath = Join-Path $runDir 'done' +$exitPath = Join-Path $runDir 'exit' +$pidPath = Join-Path $runDir 'pid' function Write-OpenClawUtf8File([string]$Path, [string]$Value) { [System.IO.File]::WriteAllText($Path, $Value, [System.Text.UTF8Encoding]::new($false)) }`; const payload = `$ErrorActionPreference = 'Stop' $PSNativeCommandUseErrorActionPreference = $false ${pathsScript} +Write-OpenClawUtf8File $pidPath ([string]$PID) +$script:OpenClawBackgroundLogBytes = 0 function Add-OpenClawBackgroundLog { param([Parameter(ValueFromPipeline=$true)]$InputObject) process { $text = $InputObject | Out-String $bytes = [System.Text.Encoding]::UTF8.GetBytes($text) + $remaining = [int64]${WINDOWS_BACKGROUND_LOG_MAX_BYTES} - $script:OpenClawBackgroundLogBytes + if ($remaining -le 0) { + return + } + $count = [int][Math]::Min($remaining, $bytes.Length) + $needsBoundaryNewline = $count -eq $remaining -and $count -gt 0 -and $bytes[$count - 1] -ne 10 + if ($needsBoundaryNewline) { + $count-- + } $stream = [System.IO.File]::Open($logPath, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::ReadWrite) try { - $stream.Write($bytes, 0, $bytes.Length) + if ($count -gt 0) { + $stream.Write($bytes, 0, $count) + $script:OpenClawBackgroundLogBytes += $count + } + if ($needsBoundaryNewline) { + $stream.WriteByte(10) + $script:OpenClawBackgroundLogBytes++ + } } finally { $stream.Dispose() } @@ -134,25 +152,41 @@ ${options.script} } finally { Write-OpenClawUtf8File $donePath 'done' }`; - const writeScript = runCommand( - "prlctl", - [ - "exec", - options.vmName, - "--current-user", - "powershell.exe", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - encodePowerShell(`${pathsScript} + const writeArgs = [ + "exec", + options.vmName, + "--current-user", + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodePowerShell(`${pathsScript} +New-Item -ItemType Directory -Path $runDir -Force | Out-Null +& icacls.exe $runDir /inheritance:r /grant:r "\${env:USERNAME}:(OI)(CI)(F)" "SYSTEM:(OI)(CI)(F)" | Out-Null +if ($LASTEXITCODE -ne 0) { throw "${safeLabel} background directory ACL setup failed" } Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue [System.IO.File]::WriteAllText($scriptPath, [Console]::In.ReadToEnd(), [System.Text.UTF8Encoding]::new($false)) if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not written" }`), - ], - { check: false, input: payload, timeoutMs: Math.min(options.timeoutMs, 120_000) }, - ); + ]; + let writeScript = runCommand("prlctl", writeArgs, { + check: false, + input: payload, + timeoutMs: timeoutBefore(deadline, 120_000), + }); appendOutput(append, writeScript); + if (writeScript.status === 255) { + options.onLaunchRetry?.( + `${options.label} background script write retry after guest transport rc255`, + ); + options.beforeLaunchAttempt?.(); + writeScript = runCommand("prlctl", writeArgs, { + check: false, + input: payload, + timeoutMs: timeoutBefore(deadline, 120_000), + }); + appendOutput(append, writeScript); + } if (writeScript.status !== 0) { throw new Error( `${options.label} background script write failed with exit code ${writeScript.status}`, @@ -161,7 +195,6 @@ if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not wr let doneSeen = false; try { - const deadline = Date.now() + options.timeoutMs; let launched = false; let lastLaunchStatus = 0; for (let attempt = 1; attempt <= 5 && Date.now() < deadline; attempt++) { @@ -178,11 +211,13 @@ if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not wr "Bypass", "-EncodedCommand", encodePowerShell(`${pathsScript} -$process = Start-Process -FilePath powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath) -PassThru -Write-OpenClawUtf8File $pidPath ([string]$process.Id) +cmd.exe /d /s /c start "" /b powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$scriptPath" | Out-Null 'started'`), ], - { check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) }, + // A busy Windows guest can leave one Parallels Tools session wedged. + // Keep polls short so a single transport cancellation cannot consume + // the entire install timeout while the detached process continues. + { check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 8_000) }, ); appendOutput(append, launch); if (launch.status === 0 && launch.stdout.includes("started")) { @@ -221,81 +256,64 @@ Write-OpenClawUtf8File $pidPath ([string]$process.Id) ); } - let lastLogOffset = 0; let completedLogDrainDeadline = 0; - const activeDeadline = () => (doneSeen ? completedLogDrainDeadline : deadline); + let doneFileSeen = false; + const activeDeadline = () => (doneFileSeen ? completedLogDrainDeadline : deadline); while (Date.now() < activeDeadline()) { + const doneProbe = runCommand( + "prlctl", + [ + "exec", + options.vmName, + "cmd.exe", + "/d", + "/s", + "/c", + `if exist "${windowsDonePath}" (echo done) else (echo wait)`, + ], + { check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 5_000) }, + ); + appendOutput(append, doneProbe); + if (doneProbe.stdout.split(/\r?\n/u).some((line) => line.trim() === "done")) { + doneFileSeen = true; + completedLogDrainDeadline ||= Date.now() + completedLogDrainGraceMs; + } else { + await sleep(pollIntervalMs); + continue; + } + const poll = runCommand( "prlctl", [ "exec", options.vmName, - "--current-user", - "powershell.exe", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-EncodedCommand", - encodePowerShell(`${pathsScript} -$offset = ${lastLogOffset} -if (Test-Path $logPath) { - $stream = [System.IO.File]::Open($logPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) - try { - $length = $stream.Length - ${psSingleQuote(logLengthPrefix)} + $length - if ($length -gt $offset) { - [void]$stream.Seek($offset, [System.IO.SeekOrigin]::Begin) - $count = [int][Math]::Min($length - $offset, ${logChunkBytes}) - $buffer = New-Object byte[] $count - $read = $stream.Read($buffer, 0, $count) - if ($read -gt 0) { - $nextOffset = $offset + $read - ${psSingleQuote(logOffsetPrefix)} + $nextOffset - [System.Text.Encoding]::UTF8.GetString($buffer, 0, $read) - } - } - } finally { - $stream.Dispose() - } -} -if (Test-Path $donePath) { - $backgroundExit = if (Test-Path $exitPath) { (Get-Content -Path $exitPath -Raw).Trim() } else { '0' } - ${psSingleQuote(backgroundExitPrefix)} + $backgroundExit - ${psSingleQuote(backgroundDoneMarker)} - if ($backgroundExit -ne '0') { exit 23 } - exit 0 -}`), + "cmd.exe", + "/d", + "/s", + "/c", + `if exist "${windowsDonePath}" (type "%WINDIR%\\Temp\\${guestRunDir}\\run.log" & for /f "usebackq delims=" %A in ("%WINDIR%\\Temp\\${guestRunDir}\\exit") do @echo ${backgroundExitPrefix}%A & echo ${backgroundDoneMarker}) else (echo wait)`, ], - { check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) }, + { check: false, quiet: true, timeoutMs: timeoutBefore(activeDeadline(), 30_000) }, ); appendOutput(append, poll); - const offsetRaw = findControlValue(poll.stdout, logOffsetPrefix); - if (offsetRaw) { - lastLogOffset = Number(offsetRaw); - } - const lengthRaw = findControlValue(poll.stdout, logLengthPrefix); - const logLength = lengthRaw ? Number(lengthRaw) : lastLogOffset; if (hasControlLine(poll.stdout, backgroundDoneMarker)) { doneSeen = true; - completedLogDrainDeadline ||= Date.now() + completedLogDrainGraceMs; - if (lastLogOffset < logLength) { - await sleep(Math.min(pollIntervalMs, 100)); - continue; - } const backgroundExit = findControlValue(poll.stdout, backgroundExitPrefix) ?? "0"; if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) { throw new Error(`${options.label} failed`); } return; } - await sleep(pollIntervalMs); + await sleep(Math.min(pollIntervalMs, 100)); } if (doneSeen) { throw new Error(`${options.label} completed but log drain timed out`); } throw new Error(`${options.label} timed out`); } finally { - cleanupWindowsBackground(options.vmName, pathsScript, runCommand, { + cleanupWindowsBackground(options.vmName, pathsScript, windowsLogPath, runCommand, { + append, + captureLog: !doneSeen, stopProcessTree: !doneSeen, }); } @@ -325,14 +343,13 @@ async function waitForWindowsBackgroundMaterialized(params: { [ "exec", params.vmName, - "--current-user", "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encodePowerShell(`${params.pathsScript} -if ((Test-Path $logPath) -or (Test-Path $donePath)) { +if ((Test-Path $pidPath) -or (Test-Path $donePath)) { 'materialized' }`), ], @@ -350,8 +367,13 @@ if ((Test-Path $logPath) -or (Test-Path $donePath)) { function cleanupWindowsBackground( vmName: string, pathsScript: string, + windowsLogPath: string, runCommand: typeof run, - options: { stopProcessTree: boolean }, + options: { + append?: (chunk: string | Uint8Array) => void; + captureLog: boolean; + stopProcessTree: boolean; + }, ): void { const stopProcessTree = options.stopProcessTree ? `function Stop-OpenClawBackgroundProcessTree([int]$ProcessId) { @@ -373,15 +395,45 @@ if (Test-Path $pidPath) { [ "exec", vmName, - "--current-user", "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encodePowerShell(`${pathsScript} -${stopProcessTree} -Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue`), +${stopProcessTree}`), + ], + { check: false, quiet: true, timeoutMs: 30_000 }, + ); + if (options.captureLog) { + const log = runCommand( + "prlctl", + [ + "exec", + vmName, + "cmd.exe", + "/d", + "/s", + "/c", + `if exist "${windowsLogPath}" type "${windowsLogPath}"`, + ], + { check: false, quiet: true, timeoutMs: 30_000 }, + ); + appendOutput(options.append, log); + } + runCommand( + "prlctl", + [ + "exec", + vmName, + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encodePowerShell(`${pathsScript} +Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue +Remove-Item -Path $runDir -Recurse -Force -ErrorAction SilentlyContinue`), ], { check: false, quiet: true, timeoutMs: 30_000 }, ); diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index 20453eaf30e..1b443e914b7 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -1163,6 +1163,7 @@ export class NpmUpdateSmoke { ): Promise { await runWindowsBackgroundPowerShell({ append: (chunk) => ctx.append(chunk), + beforeLaunchAttempt: () => ensureVmRunning(windowsVm), label: "Windows update", script, timeoutMs, diff --git a/scripts/e2e/parallels/windows-smoke.ts b/scripts/e2e/parallels/windows-smoke.ts index 33c51ec7703..6994f82fddc 100755 --- a/scripts/e2e/parallels/windows-smoke.ts +++ b/scripts/e2e/parallels/windows-smoke.ts @@ -94,6 +94,9 @@ interface WindowsSummary { }; } +const WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS = 900; +const WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS = WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS * 1000; + const defaultOptions = (): WindowsOptions => ({ hostIp: undefined, hostPort: 18426, @@ -362,7 +365,9 @@ class WindowsSmoke extends SmokeRunController { ensureGuestGit({ guest: this.guest, minGitZipPath: this.minGitZipPath, server: this.server }), ); await this.phase("fresh.preflight", 120, () => this.logGuestPreflight(true)); - await this.phase("fresh.install-main", 420, () => this.installMain("openclaw-main-fresh.tgz")); + await this.phase("fresh.install-main", WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, () => + this.installMain("openclaw-main-fresh.tgz"), + ); this.status.freshVersion = await this.extractLastVersion("fresh.install-main"); await this.phase("fresh.verify-main-version", 120, () => this.verifyTargetVersion()); await this.phase("fresh.onboard-ref", 720, () => this.runRefOnboard()); @@ -381,8 +386,10 @@ class WindowsSmoke extends SmokeRunController { ); await this.phase("upgrade.preflight", 120, () => this.logGuestPreflight(false)); if (this.options.targetPackageSpec || this.options.upgradeFromPackedMain) { - await this.phase("upgrade.install-baseline-package", 420, () => - this.installMain("openclaw-main-upgrade.tgz"), + await this.phase( + "upgrade.install-baseline-package", + WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, + () => this.installMain("openclaw-main-upgrade.tgz"), ); this.status.latestInstalledVersion = await this.extractLastVersion( "upgrade.install-baseline-package", @@ -391,7 +398,9 @@ class WindowsSmoke extends SmokeRunController { this.verifyTargetVersion(), ); } else { - await this.phase("upgrade.install-baseline", 420, () => this.installLatestRelease()); + await this.phase("upgrade.install-baseline", WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, () => + this.installLatestRelease(), + ); this.status.latestInstalledVersion = await this.extractLastVersion( "upgrade.install-baseline", ); @@ -538,33 +547,37 @@ ${cleanScript}`, ); } - private installLatestRelease(): void { + private installLatestRelease(): Promise { const versionArg = this.installVersion ? ` -Tag ${psSingleQuote(this.installVersion)}` : ""; - this.guestPowerShell( + return this.guestPowerShellBackground( + "install-latest", `$ErrorActionPreference = 'Stop' $script = Invoke-RestMethod -Uri ${psSingleQuote(this.options.installUrl)} -TimeoutSec 120 & ([scriptblock]::Create($script))${versionArg} -NoOnboard if ($LASTEXITCODE -ne 0) { throw "installer failed with exit code $LASTEXITCODE" } Invoke-OpenClaw --version -if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`, - { timeoutMs: 420_000 }, + if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`, + this.remainingPhaseTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ?? + WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS, ); } - private installMain(tempName: string): void { + private installMain(tempName: string): Promise { if (!this.artifact || !this.server) { die("package artifact/server missing"); } const tgzUrl = this.server.urlFor(this.artifact.path); - this.guestPowerShell( + return this.guestPowerShellBackground( + `install-main-${tempName.replaceAll(/[^A-Za-z0-9_-]/g, "-")}`, `$ErrorActionPreference = 'Stop' $tgz = Join-Path $env:TEMP ${psSingleQuote(tempName)} curl.exe -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${psSingleQuote(tgzUrl)} -o $tgz npm.cmd install -g $tgz --no-fund --no-audit --loglevel=error if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE" } Invoke-OpenClaw --version -if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`, - { timeoutMs: 420_000 }, + if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`, + this.remainingPhaseTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ?? + WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS, ); } @@ -622,11 +635,14 @@ ${this.windowsPluginIsolationScript()}`, await runWindowsBackgroundPowerShell({ append: (chunk) => this.log(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")), - beforeLaunchAttempt: () => this.waitForGuestReady(120), + beforeLaunchAttempt: () => { + ensureVmRunning(this.options.vmName, 120); + this.waitForGuestReady(120); + }, label, onLaunchRetry: warn, script: `${windowsOpenClawResolver}\n${script}`, - timeoutMs, + timeoutMs: this.remainingPhaseTimeoutMs(timeoutMs) ?? timeoutMs, vmName: this.options.vmName, }); } diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index effa714b818..83e7e494c28 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -82,8 +82,6 @@ function decodePowerShellFromArgs(args: string[]): string { function extractWindowsBackgroundControlMarkers(decoded: string): { done: string; exitPrefix: string; - lengthPrefix: string; - offsetPrefix: string; } { const marker = (name: string, trailingColon: boolean): string => { const suffix = trailingColon ? ":" : ""; @@ -96,8 +94,6 @@ function extractWindowsBackgroundControlMarkers(decoded: string): { return { done: marker("__OPENCLAW_BACKGROUND_DONE__", false), exitPrefix: marker("__OPENCLAW_BACKGROUND_EXIT__", true), - lengthPrefix: marker("__OPENCLAW_LOG_LENGTH__", true), - offsetPrefix: marker("__OPENCLAW_LOG_OFFSET__", true), }; } @@ -620,7 +616,7 @@ exit 1 expect(transports).toContain("${options.label} timed out"); }); - it("cleans timed-out Windows background work and reads bounded log chunks", async () => { + it("cleans timed-out Windows background work", async () => { const decodedCommands: string[] = []; const inputs: string[] = []; const fakeRun: typeof hostCommandRun = (_command, args, options) => { @@ -629,16 +625,18 @@ exit 1 if (options?.input) { inputs.push(String(options.input)); } - if (decoded.includes("Start-Process")) { + if (decoded.includes('cmd.exe /d /s /c start "" /b powershell.exe')) { return { status: 0, stderr: "", stdout: "started\n" }; } + if (args.includes("cmd.exe")) { + return { status: 0, stderr: "", stdout: "wait\n" }; + } return { status: 0, stderr: "", stdout: "" }; }; await expect( runWindowsBackgroundPowerShell({ label: "windows background timeout", - logChunkBytes: 64, pollIntervalMs: 1, runCommand: fakeRun, script: "Start-Sleep -Seconds 60", @@ -654,11 +652,9 @@ exit 1 expect(commands).toContain("[System.Text.UTF8Encoding]::new($false)"); expect(payloads).toContain("Write-OpenClawUtf8File $exitPath '0'"); expect(payloads).toContain("Write-OpenClawUtf8File $donePath 'done'"); - expect(commands).toContain("Write-OpenClawUtf8File $pidPath ([string]$process.Id)"); - expect(commands).toContain("Start-Process -FilePath powershell.exe"); - expect(commands).toContain("-PassThru"); - expect(commands).toContain("[System.IO.File]::Open($logPath"); - expect(commands).toContain("[Math]::Min($length - $offset, 64)"); + expect(payloads).toContain("Write-OpenClawUtf8File $pidPath ([string]$PID)"); + expect(commands).toContain('cmd.exe /d /s /c start "" /b powershell.exe'); + expect(commands).toContain("icacls.exe $runDir /inheritance:r"); expect(commands).toContain("Stop-OpenClawBackgroundProcessTree ([int]$backgroundPid)"); expect(commands).toContain( 'Get-CimInstance Win32_Process -Filter "ParentProcessId=$ProcessId"', @@ -677,22 +673,11 @@ exit 1 const fakeRun: typeof hostCommandRun = (_command, args) => { const decoded = decodePowerShellFromArgs(args); decodedCommands.push(decoded); - if (decoded.includes("Start-Process")) { + if (decoded.includes('cmd.exe /d /s /c start "" /b powershell.exe')) { return { status: 0, stderr: "", stdout: "started\n" }; } - if (decoded.includes("__OPENCLAW_LOG_LENGTH__")) { - const markers = extractWindowsBackgroundControlMarkers(decoded); - return { - status: 0, - stderr: "", - stdout: [ - `${markers.lengthPrefix}128`, - `${markers.offsetPrefix}128`, - "__OPENCLAW_BACKGROUND_EXIT__:0", - "__OPENCLAW_BACKGROUND_DONE__", - "", - ].join("\n"), - }; + if (args.includes("cmd.exe")) { + return { status: 0, stderr: "", stdout: "done\n" }; } return { status: 0, stderr: "", stdout: "" }; }; @@ -700,11 +685,11 @@ exit 1 await expect( runWindowsBackgroundPowerShell({ label: "windows background marker smuggle", - logChunkBytes: 128, pollIntervalMs: 1, runCommand: fakeRun, script: "Write-Output done", timeoutMs: 5, + completedLogDrainGraceMs: 5, vmName: "Windows Test", }), ).rejects.toThrow("windows background marker smuggle timed out"); @@ -721,34 +706,24 @@ exit 1 const fakeRun: typeof hostCommandRun = (_command, args) => { const decoded = decodePowerShellFromArgs(args); decodedCommands.push(decoded); - if (decoded.includes("Start-Process")) { + if (decoded.includes('cmd.exe /d /s /c start "" /b powershell.exe')) { return { status: 0, stderr: "", stdout: "started\n" }; } - if (decoded.includes("__OPENCLAW_LOG_LENGTH__")) { - const markers = extractWindowsBackgroundControlMarkers(decoded); - pollCount += 1; - return { - status: 0, - stderr: "", - stdout: - pollCount === 1 - ? [ - `${markers.lengthPrefix}128`, - `${markers.offsetPrefix}64`, - "first chunk", - `${markers.exitPrefix}0`, - markers.done, - "", - ].join("\n") - : [ - `${markers.lengthPrefix}128`, - `${markers.offsetPrefix}128`, - "second chunk", - `${markers.exitPrefix}0`, - markers.done, - "", - ].join("\n"), - }; + if (args.includes("cmd.exe")) { + const command = args.at(-1) ?? ""; + if (command.includes("type")) { + pollCount += 1; + const markers = extractWindowsBackgroundControlMarkers(command); + return { + status: 0, + stderr: "", + stdout: ["first chunk", `${markers.exitPrefix}0`, markers.done, ""].join("\n"), + }; + } + if (command.includes("if exist")) { + return { status: 0, stderr: "", stdout: "done\n" }; + } + return { status: 0, stderr: "", stdout: "" }; } return { status: 0, stderr: "", stdout: "" }; }; @@ -758,7 +733,6 @@ exit 1 append: (chunk) => output.push(String(chunk)), completedLogDrainGraceMs: 1000, label: "windows background drain", - logChunkBytes: 64, pollIntervalMs: 5000, runCommand: fakeRun, script: "Write-Output done", @@ -767,9 +741,8 @@ exit 1 }), ).resolves.toBeUndefined(); - expect(pollCount).toBe(2); + expect(pollCount).toBe(1); expect(output.join("")).toContain("first chunk"); - expect(output.join("")).toContain("second chunk"); expect(decodedCommands.join("\n")).not.toContain("Stop-OpenClawBackgroundProcessTree"); expect(decodedCommands.join("\n")).toContain( "Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath", diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 69fe5f4dfc8..00ed889bd82 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -424,7 +424,9 @@ describe("Parallels smoke model selection", () => { expect(script).not.toContain("'openclaw-parallels-plugin-isolation.cjs'"); expect(script).toContain("try {"); expect(script).toContain("} finally {"); - expect(script).toContain("Remove-Item $isolationScriptPath -Force -ErrorAction SilentlyContinue"); + expect(script).toContain( + "Remove-Item $isolationScriptPath -Force -ErrorAction SilentlyContinue", + ); expect(script).toContain("Remove-Item Env:OPENCLAW_PARALLELS_PLUGIN_ISOLATION"); }); @@ -1463,16 +1465,29 @@ if (isPrlctl) { expect(script).toContain("guestPowerShellBackground"); expect(script).toContain("runWindowsBackgroundPowerShell"); - expect(transports).toContain("Join-Path $env:TEMP"); + expect(transports).toContain("Join-Path (Join-Path $env:WINDIR 'Temp\\\\openclaw-parallels')"); + expect(transports).toContain("icacls.exe $runDir /inheritance:r"); expect(transports).toContain("__OPENCLAW_BACKGROUND_DONE__"); expect(transports).toContain("__OPENCLAW_BACKGROUND_EXIT__"); - expect(transports).toContain("__OPENCLAW_LOG_OFFSET__"); expect(transports).toContain("poll.status !== 0 && poll.status !== 124"); - expect(transports).toContain("Start-Process -FilePath powershell.exe"); + expect(transports).toContain('cmd.exe /d /s /c start "" /b powershell.exe'); + expect(transports).toContain('if exist "${windowsDonePath}"'); + expect(transports).toContain('type "%WINDIR%\\\\Temp\\\\${guestRunDir}\\\\run.log"'); + expect(transports).toContain("WINDOWS_BACKGROUND_LOG_MAX_BYTES"); + expect(transports).toContain("Write-OpenClawUtf8File $pidPath ([string]$PID)"); expect(transports).toContain('launch.stdout.includes("started")'); expect(transports).toContain("waitForWindowsBackgroundMaterialized"); }); + it("runs Windows package installs through the detached done-file runner", () => { + const script = readFileSync(TS_PATHS.windows, "utf8"); + + expect(script).toContain('guestPowerShellBackground(\n "install-latest"'); + expect(script).toContain("guestPowerShellBackground(\n `install-main-${"); + expect(script).not.toMatch(/private installMain\(tempName: string\): void/u); + expect(script).not.toMatch(/private installLatestRelease\(\): void/u); + }); + it("paces ambiguous Windows background launch materialization probes", async () => { let calls = 0; const runCommand = vi.fn(() => { @@ -2096,9 +2111,7 @@ setInterval(() => {}, 1000); ); expect(duplicateNpmUpdatePlatformResult.status).toBe(1); - expect(duplicateNpmUpdatePlatformResult.stderr).toContain( - "duplicate --platform entry: macos", - ); + expect(duplicateNpmUpdatePlatformResult.stderr).toContain("duplicate --platform entry: macos"); expect(readFileSync(TS_PATHS.macos, "utf8")).toContain( 'this.updateDevTimeoutSeconds = readPositiveIntEnv(\n "OPENCLAW_PARALLELS_MACOS_UPDATE_DEV_TIMEOUT_S"',