Download src/cli/update-cli/restart-helper.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 29 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/cli/update-cli/restart-helper.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/cli/update-cli/restart-helper.ts
-
curl -L -o restart-helper.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/cli/update-cli/restart-helper.ts
29 kB
| // Builds detached, platform-specific restart scripts for update handoff. | |
| import fs from "node:fs/promises"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; | |
| import { DEFAULT_GATEWAY_PORT } from "../../config/paths.js"; | |
| import { | |
| resolveGatewaySystemdServiceName, | |
| resolveGatewayWindowsTaskName, | |
| } from "../../daemon/constants.js"; | |
| import { resolveLaunchAgentLabel } from "../../daemon/launchd-label.js"; | |
| import { renderSystemLaunchDaemonOwnershipShellProbe } from "../../daemon/launchd-system.js"; | |
| import { resolveGatewayTaskScriptPath } from "../../daemon/paths.js"; | |
| import { | |
| renderPosixRestartLogSetup, | |
| resolveGatewayRestartLogPath, | |
| shellEscapeRestartLogValue, | |
| } from "../../daemon/restart-logs.js"; | |
| import { | |
| buildHiddenLauncherScript, | |
| encodeWindowsLauncherScript, | |
| } from "../../daemon/schtasks-layout.js"; | |
| import { getWindowsSystem32ExePath } from "../../infra/windows-install-roots.js"; | |
| import { COMMAND_PROCESS_TREE_KILL_GRACE_MS, spawnCommand } from "../../process/exec-spawn.js"; | |
| /** | |
| * Shell-escape a string for embedding in single-quoted shell arguments. | |
| * Replaces every `'` with `'\''` (end quote, escaped quote, resume quote). | |
| * For batch scripts, validates against special characters instead. | |
| */ | |
| function shellEscape(value: string): string { | |
| return value.replace(/'/g, "'\\''"); | |
| } | |
| /** Validates a task name is safe for embedding in Windows restart scripts. */ | |
| function isWindowsTaskNameSafe(value: string): boolean { | |
| return /^[A-Za-z0-9 _\-().]+$/.test(value); | |
| } | |
| function powerShellSingleQuote(value: string): string { | |
| // The standalone helper is read through stdin, whose code page varies by host. | |
| if (/[^\x20-\x7e]/u.test(value)) { | |
| return `([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(value, "utf8").toString("base64")}')))`; | |
| } | |
| return `'${value.replace(/'/g, "''")}'`; | |
| } | |
| function resolveSystemdUnit(env: NodeJS.ProcessEnv): string { | |
| const override = normalizeOptionalString(env.OPENCLAW_SYSTEMD_UNIT); | |
| if (override) { | |
| return override.endsWith(".service") ? override : `${override}.service`; | |
| } | |
| return `${resolveGatewaySystemdServiceName(env.OPENCLAW_PROFILE)}.service`; | |
| } | |
| function resolveWindowsTaskName(env: NodeJS.ProcessEnv): string { | |
| const override = env.OPENCLAW_WINDOWS_TASK_NAME?.trim(); | |
| if (override) { | |
| return override; | |
| } | |
| return resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE); | |
| } | |
| function resolveLinuxFilesystemBusUid(busAddress: string | undefined): string | undefined { | |
| // Classify a single filesystem transport before decoding so custom abstract | |
| // buses and semicolon-separated fallback addresses are never rewritten. | |
| const singleUnixAddress = busAddress?.match(/^unix:([^;]+)$/u)?.[1]; | |
| const encodedBusPath = singleUnixAddress | |
| ?.split(",") | |
| .find((parameter) => parameter.startsWith("path=")) | |
| ?.slice("path=".length); | |
| if (encodedBusPath === undefined) { | |
| return undefined; | |
| } | |
| try { | |
| return decodeURIComponent(encodedBusPath).match(/^\/run\/user\/(\d+)\/bus$/u)?.[1]; | |
| } catch { | |
| return undefined; | |
| } | |
| } | |
| async function renderLinuxUserBusRepair(env: NodeJS.ProcessEnv): Promise<string> { | |
| const uid = typeof process.geteuid === "function" ? process.geteuid() : 0; | |
| if (uid <= 0) { | |
| return ""; | |
| } | |
| const expectedRuntimeDir = `/run/user/${uid}`; | |
| const expectedBusAddress = `unix:path=${expectedRuntimeDir}/bus`; | |
| const runtimeDir = normalizeOptionalString(env.XDG_RUNTIME_DIR); | |
| const busAddress = normalizeOptionalString(env.DBUS_SESSION_BUS_ADDRESS); | |
| const normalizedRuntimeDir = runtimeDir ? path.posix.normalize(runtimeDir) : undefined; | |
| const runtimeUid = normalizedRuntimeDir?.match(/^\/run\/user\/(\d+)\/?$/)?.[1]; | |
| const busUid = resolveLinuxFilesystemBusUid(busAddress); | |
| const repairRuntimeDir = !runtimeDir || (runtimeUid !== undefined && runtimeUid !== String(uid)); | |
| // A custom runtime owns implicit bus discovery; inventing a standard bus | |
| // would silently redirect an isolated session to the host user manager. | |
| const preserveCustomRuntimeDir = Boolean(runtimeDir) && runtimeUid === undefined; | |
| const repairBusAddress = | |
| !preserveCustomRuntimeDir && (!busAddress || (busUid !== undefined && busUid !== String(uid))); | |
| const clearEmptyCustomBusAddress = | |
| preserveCustomRuntimeDir && env.DBUS_SESSION_BUS_ADDRESS !== undefined && !busAddress; | |
| if (!repairRuntimeDir && !repairBusAddress && !clearEmptyCustomBusAddress) { | |
| return ""; | |
| } | |
| try { | |
| const socketRuntimeDir = | |
| clearEmptyCustomBusAddress && runtimeDir ? runtimeDir : expectedRuntimeDir; | |
| const stat = await fs.stat(path.join(socketRuntimeDir, "bus")); | |
| if (!stat.isSocket()) { | |
| return ""; | |
| } | |
| } catch { | |
| return ""; | |
| } | |
| const exports = [ | |
| repairRuntimeDir ? `export XDG_RUNTIME_DIR='${shellEscape(expectedRuntimeDir)}'` : "", | |
| repairBusAddress ? `export DBUS_SESSION_BUS_ADDRESS='${shellEscape(expectedBusAddress)}'` : "", | |
| clearEmptyCustomBusAddress ? "unset DBUS_SESSION_BUS_ADDRESS" : "", | |
| ].filter(Boolean); | |
| return `# Repair missing or cross-user D-Bus values inherited by the updater. | |
| ${exports.join("\n")} | |
| `; | |
| } | |
| /** | |
| * Prepares a standalone script to restart the gateway service. | |
| * This script is written to a temporary directory and does not depend on | |
| * the installed package files, ensuring restart capability even if the | |
| * update process temporarily removes or corrupts installation files. | |
| */ | |
| export async function prepareRestartScript( | |
| env: NodeJS.ProcessEnv = process.env, | |
| gatewayPort: number = DEFAULT_GATEWAY_PORT, | |
| windowsGatewayArgv: readonly string[] = [], | |
| ): Promise<string | null> { | |
| const timestamp = Date.now(); | |
| const platform = process.platform; | |
| let scriptContent; | |
| let filename; | |
| let windowsWrapper: string | undefined; | |
| try { | |
| if (platform === "linux") { | |
| const unitName = resolveSystemdUnit(env); | |
| const escaped = shellEscape(unitName); | |
| const logSetup = renderPosixRestartLogSetup({ ...process.env, ...env }); | |
| const userBusRepair = await renderLinuxUserBusRepair({ ...process.env, ...env }); | |
| filename = `openclaw-restart-${timestamp}.sh`; | |
| scriptContent = `#!/bin/sh | |
| # Standalone restart script — survives parent process termination. | |
| # Wait briefly to ensure file locks are released after update. | |
| sleep 1 | |
| exec 3>&2 | |
| ${logSetup} | |
| ${userBusRepair} | |
| printf '[%s] openclaw restart attempt source=update target=%s\\n' "$(date -u +%FT%TZ)" '${escaped}' >&2 | |
| if systemctl --user is-active --quiet '${escaped}' || systemctl --user is-enabled --quiet '${escaped}'; then | |
| if systemctl --user restart '${escaped}'; then | |
| status=0 | |
| printf '[%s] openclaw restart done source=update\\n' "$(date -u +%FT%TZ)" >&2 | |
| else | |
| status=$? | |
| printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2 | |
| fi | |
| elif systemctl is-active --quiet '${escaped}' || systemctl is-enabled --quiet '${escaped}'; then | |
| status=78 | |
| printf '[%s] system-scoped openclaw gateway unit detected; update cannot restart it without sudo. Run: sudo systemctl restart %s\\n' "$(date -u +%FT%TZ)" '${escaped}' >&2 | |
| printf '[%s] system-scoped openclaw gateway unit detected; update cannot restart it without sudo. Run: sudo systemctl restart %s\\n' "$(date -u +%FT%TZ)" '${escaped}' >&3 2>/dev/null || true | |
| else | |
| if systemctl --user restart '${escaped}'; then | |
| status=0 | |
| printf '[%s] openclaw restart done source=update\\n' "$(date -u +%FT%TZ)" >&2 | |
| else | |
| status=$? | |
| printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2 | |
| fi | |
| fi | |
| # Self-cleanup | |
| script_dir=$(dirname "$0") | |
| exec 3>&- | |
| rm -f "$0" | |
| rmdir "$script_dir" 2>/dev/null || true | |
| exit "$status" | |
| `; | |
| } else if (platform === "darwin") { | |
| const label = resolveLaunchAgentLabel(env); | |
| const escaped = shellEscape(label); | |
| // Fallback to 501 if getuid is not available (though it should be on macOS) | |
| const uid = process.getuid ? process.getuid() : 501; | |
| // Resolve HOME at generation time via env/process.env to match launchd.ts, | |
| // and shell-escape the label in the plist filename to prevent injection. | |
| const home = normalizeOptionalString(env.HOME) || process.env.HOME || os.homedir(); | |
| const plistPath = path.join(home, "Library", "LaunchAgents", `${label}.plist`); | |
| const escapedPlistPath = shellEscape(plistPath); | |
| const logSetup = renderPosixRestartLogSetup({ ...process.env, ...env }); | |
| const systemOwnershipProbe = renderSystemLaunchDaemonOwnershipShellProbe(label); | |
| filename = `openclaw-restart-${timestamp}.sh`; | |
| scriptContent = `#!/bin/sh | |
| # Standalone restart script — survives parent process termination. | |
| # Wait briefly to ensure file locks are released after update. | |
| sleep 1 | |
| # Capture launchctl output so bootstrap/kickstart failures leave a durable | |
| # audit trail. Log setup is best-effort: restart must still run if the log path | |
| # is temporarily unavailable. | |
| ${logSetup} | |
| printf '[%s] openclaw restart attempt source=update target=%s\\n' "$(date -u +%FT%TZ)" '${shellEscapeRestartLogValue(label)}' >&2 | |
| ${systemOwnershipProbe} | |
| # Try kickstart first (works when the service is still registered). | |
| # If it fails (e.g. after bootout), clear any persisted disabled state, | |
| # then re-register via bootstrap. Bootstrap loads RunAtLoad agents, so the | |
| # fallback must not immediately kickstart -k the freshly spawned gateway. | |
| # The final status is captured | |
| # before self-cleanup so a genuine failure remains observable. | |
| status=0 | |
| if [ -n "$openclaw_system_launchd_conflict" ]; then | |
| status=78 | |
| printf '[%s] openclaw restart blocked source=update reason=%s\n' "$(date -u +%FT%TZ)" "$openclaw_system_launchd_detail" >&2 | |
| elif ! launchctl kickstart -k 'gui/${uid}/${escaped}'; then | |
| launchctl enable 'gui/${uid}/${escaped}' | |
| if launchctl bootstrap 'gui/${uid}' '${escapedPlistPath}'; then | |
| status=0 | |
| else | |
| launchctl kickstart -k 'gui/${uid}/${escaped}' | |
| status=$? | |
| fi | |
| fi | |
| if [ "$status" -eq 0 ]; then | |
| printf '[%s] openclaw restart done source=update\\n' "$(date -u +%FT%TZ)" >&2 | |
| else | |
| printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2 | |
| fi | |
| # Self-cleanup (log is retained under the OpenClaw state logs directory). | |
| script_dir=$(dirname "$0") | |
| rm -f "$0" | |
| rmdir "$script_dir" 2>/dev/null || true | |
| exit "$status" | |
| `; | |
| } else if (platform === "win32") { | |
| const taskName = resolveWindowsTaskName(env); | |
| if (!isWindowsTaskNameSafe(taskName)) { | |
| return null; | |
| } | |
| const port = | |
| Number.isFinite(gatewayPort) && gatewayPort > 0 ? gatewayPort : DEFAULT_GATEWAY_PORT; | |
| const restartLogPath = resolveGatewayRestartLogPath({ ...process.env, ...env }); | |
| const quotedLogPath = powerShellSingleQuote(restartLogPath); | |
| const quotedTaskName = powerShellSingleQuote(taskName); | |
| const gatewayScriptPath = resolveGatewayTaskScriptPath({ ...process.env, ...env }); | |
| const quotedGatewayScriptPath = powerShellSingleQuote(gatewayScriptPath); | |
| const expectedGatewayArgv = windowsGatewayArgv.map(powerShellSingleQuote).join(", "); | |
| filename = `openclaw-restart-${timestamp}.cmd`; | |
| windowsWrapper = `@echo off | |
| REM Standalone restart script - survives parent process termination. | |
| REM Read fixed commands from stdin so Group Policy file-signing restrictions | |
| REM do not prevent recovery. The companion contains ASCII-only script text. | |
| setlocal | |
| set "OPENCLAW_RESTART_SCRIPT_DIR=%~dp0." | |
| powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command - < "%~dpn0.ps1" > "%~dpn0.out" | |
| set "status=%ERRORLEVEL%" | |
| REM PowerShell can exit zero for malformed or incomplete stdin without running it. | |
| findstr /x /c:"OPENCLAW_RESTART_COMPLETE" "%~dpn0.out" >nul 2>&1 | |
| if errorlevel 1 set "status=1" | |
| REM This dedicated cmd process must exit instead of returning to a deleted batch file. | |
| ( | |
| del "%~dpn0.out" >nul 2>&1 | |
| del "%~dpn0.ps1" >nul 2>&1 | |
| del "%~f0.vbs" >nul 2>&1 | |
| del "%~f0" >nul 2>&1 | |
| rmdir "%OPENCLAW_RESTART_SCRIPT_DIR%" >nul 2>&1 | |
| exit %status% | |
| ) | |
| `; | |
| scriptContent = ` | |
| # Wait briefly to ensure file locks are released after update. | |
| $ErrorActionPreference = "Continue" | |
| Start-Sleep -Seconds 2 | |
| $logPath = ${quotedLogPath} | |
| try { | |
| $logDir = Split-Path -Parent $logPath | |
| New-Item -ItemType Directory -Path $logDir -Force | Out-Null | |
| Add-Content -LiteralPath $logPath -Value "[$(Get-Date -Format o)] openclaw restart log initialized" | |
| } catch { | |
| # Restart should still run if log setup is unavailable. | |
| } | |
| function Write-RestartLog { | |
| param([string]$Message) | |
| try { | |
| Add-Content -LiteralPath $logPath -Value "[$(Get-Date -Format o)] $Message" | |
| } catch { | |
| } | |
| } | |
| function Join-OpenClawProcessArguments { | |
| param([string[]]$Arguments) | |
| ($Arguments | ForEach-Object { | |
| if ($_ -match "\\s") { | |
| '"' + $_ + '"' | |
| } else { | |
| $_ | |
| } | |
| }) -join " " | |
| } | |
| function Invoke-OpenClawSchtasksWithTimeout { | |
| param( | |
| [string[]]$Arguments, | |
| [int]$TimeoutSeconds | |
| ) | |
| $process = $null | |
| try { | |
| $startInfo = [System.Diagnostics.ProcessStartInfo]::new() | |
| $startInfo.FileName = "schtasks.exe" | |
| $startInfo.Arguments = Join-OpenClawProcessArguments -Arguments $Arguments | |
| $startInfo.UseShellExecute = $false | |
| $startInfo.RedirectStandardOutput = $true | |
| $startInfo.RedirectStandardError = $true | |
| $process = [System.Diagnostics.Process]::Start($startInfo) | |
| if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { | |
| try { | |
| $process.Kill() | |
| } catch { | |
| } | |
| Write-RestartLog "openclaw restart schtasks timeout source=update args=$($Arguments -join ' ')" | |
| return 124 | |
| } | |
| $stdout = $process.StandardOutput.ReadToEnd() | |
| $stderr = $process.StandardError.ReadToEnd() | |
| if ($stdout) { | |
| Write-RestartLog $stdout.Trim() | |
| } | |
| if ($stderr) { | |
| Write-RestartLog $stderr.Trim() | |
| } | |
| return $process.ExitCode | |
| } catch { | |
| Write-RestartLog "openclaw restart schtasks failed source=update args=$($Arguments -join ' ') error=$($_.Exception.Message)" | |
| return 1 | |
| } | |
| } | |
| function Get-OpenClawScheduledTaskState { | |
| param([string]$TaskName) | |
| try { | |
| $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop | |
| if ($task -and $task.State) { | |
| return [string]$task.State | |
| } | |
| } catch { | |
| } | |
| try { | |
| $queryOutput = & schtasks.exe /Query /TN $TaskName /FO LIST 2>$null | |
| foreach ($line in $queryOutput) { | |
| if ($line -match "^\\s*Status:\\s*(.+?)\\s*$") { | |
| return $Matches[1] | |
| } | |
| } | |
| } catch { | |
| } | |
| return "Unknown" | |
| } | |
| # OPENCLAW_RESTART_KILL_POLICY_BEGIN | |
| function Split-OpenClawWindowsCommandLine { | |
| param([string]$CommandLine) | |
| if (-not $CommandLine) { return @() } | |
| $arguments = [Collections.Generic.List[string]]::new() | |
| $index = 0 | |
| # Shell32 treats argv[0] as a path, without backslash/quote escapes. | |
| if ($CommandLine[0] -eq '"') { | |
| $end = $CommandLine.IndexOf('"', 1) | |
| if ($end -lt 0) { $end = $CommandLine.Length } | |
| $arguments.Add($CommandLine.Substring(1, $end - 1)) | |
| $index = [Math]::Min($end + 1, $CommandLine.Length) | |
| } else { | |
| while ($index -lt $CommandLine.Length -and $CommandLine[$index] -ne ' ' -and [int]$CommandLine[$index] -ne 9) { $index++ } | |
| $arguments.Add($CommandLine.Substring(0, $index)) | |
| } | |
| while ($index -lt $CommandLine.Length) { | |
| while ($index -lt $CommandLine.Length -and ($CommandLine[$index] -eq ' ' -or [int]$CommandLine[$index] -eq 9)) { $index++ } | |
| if ($index -eq $CommandLine.Length) { break } | |
| $argument = [Text.StringBuilder]::new() | |
| $quoted = 0 | |
| while ($index -lt $CommandLine.Length) { | |
| $character = $CommandLine[$index] | |
| if ($quoted -eq 0 -and ($character -eq ' ' -or [int]$character -eq 9)) { break } | |
| $slashes = 0 | |
| while ($index -lt $CommandLine.Length -and $CommandLine[$index] -eq '\\') { $slashes++; $index++ } | |
| if ($index -lt $CommandLine.Length -and $CommandLine[$index] -eq '"') { | |
| [void]$argument.Append(('\\' * [int][Math]::Floor($slashes / 2))) | |
| if ($slashes % 2 -eq 1) { | |
| [void]$argument.Append('"') | |
| $index++ | |
| } | |
| # Consecutive unescaped quotes produce one literal quote per three; | |
| # only a remainder of one leaves the argument quoted. | |
| $quotes = $quoted | |
| while ($index -lt $CommandLine.Length -and $CommandLine[$index] -eq '"') { $quotes++; $index++ } | |
| [void]$argument.Append(('"' * [int][Math]::Floor($quotes / 3))) | |
| $quoted = [int]($quotes % 3 -eq 1) | |
| } else { | |
| [void]$argument.Append(('\\' * $slashes)) | |
| if ($index -eq $CommandLine.Length) { break } | |
| $character = $CommandLine[$index] | |
| if ($quoted -eq 0 -and ($character -eq ' ' -or [int]$character -eq 9)) { break } | |
| [void]$argument.Append($character) | |
| $index++ | |
| } | |
| } | |
| $arguments.Add($argument.ToString()) | |
| } | |
| return $arguments.ToArray() | |
| } | |
| function Get-OpenClawListenerSnapshot { | |
| param([int]$Port) | |
| try { | |
| if (Get-Command Get-NetTCPConnection -ErrorAction SilentlyContinue) { | |
| $listenerPids = @( | |
| Get-NetTCPConnection -State Listen -ErrorAction Stop | | |
| Where-Object { [int]$_.LocalPort -eq $Port } | | |
| ForEach-Object { [int]$_.OwningProcess } | | |
| Sort-Object -Unique | |
| ) | |
| return [pscustomobject]@{ Known = $true; Pids = $listenerPids } | |
| } | |
| } catch { | |
| Write-RestartLog "openclaw restart Get-NetTCPConnection query failed source=update error=$($_.Exception.Message)" | |
| } | |
| try { | |
| $netstatOutput = @(& netstat.exe -ano -p tcp 2>$null) | |
| if ($LASTEXITCODE -ne 0) { | |
| return [pscustomobject]@{ Known = $false; Pids = @() } | |
| } | |
| $listenerPids = @() | |
| $localPortPattern = ":" + [regex]::Escape([string]$Port) + '$' | |
| foreach ($line in $netstatOutput) { | |
| $tokens = @($line.Trim() -split '\\s+' | Where-Object { $_ }) | |
| if ($tokens.Count -lt 5 -or $tokens[0] -ine "TCP") { | |
| continue | |
| } | |
| # Listening rows use a wildcard foreign endpoint with port zero. Avoid the | |
| # localized state column entirely; protocol/endpoints/PID stay numeric. | |
| if ($tokens[1] -notmatch $localPortPattern -or $tokens[2] -notmatch ':0$') { | |
| continue | |
| } | |
| $listenerPid = 0 | |
| if ([int]::TryParse($tokens[-1], [ref]$listenerPid) -and $listenerPid -gt 0) { | |
| $listenerPids += $listenerPid | |
| } | |
| } | |
| return [pscustomobject]@{ | |
| Known = $true | |
| Pids = @($listenerPids | Sort-Object -Unique) | |
| } | |
| } catch { | |
| Write-RestartLog "openclaw restart netstat query failed source=update error=$($_.Exception.Message)" | |
| return [pscustomobject]@{ Known = $false; Pids = @() } | |
| } | |
| } | |
| function Get-OpenClawProcessFacts { | |
| param([int]$ProcessId) | |
| try { | |
| $process = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction Stop | |
| if (-not $process -or -not $process.CommandLine -or -not $process.CreationDate) { | |
| return $null | |
| } | |
| $creationDate = if ($process.CreationDate -is [datetime]) { | |
| [datetime]$process.CreationDate | |
| } else { | |
| [System.Management.ManagementDateTimeConverter]::ToDateTime([string]$process.CreationDate) | |
| } | |
| $creationTimeFileTime = [long]$creationDate.ToUniversalTime().ToFileTimeUtc() | |
| $creationTimeFileTime -= $creationTimeFileTime % 10 | |
| return [pscustomobject]@{ | |
| ProcessId = [int]$process.ProcessId | |
| CreationTimeFileTime = [string]$creationTimeFileTime | |
| Argv = @(Split-OpenClawWindowsCommandLine -CommandLine ([string]$process.CommandLine)) | |
| } | |
| } catch { | |
| Write-RestartLog "openclaw restart process query failed source=update pid=$ProcessId error=$($_.Exception.Message)" | |
| return $null | |
| } | |
| } | |
| function Test-OpenClawArgvEqual { | |
| param([string[]]$Actual, [string[]]$Expected) | |
| if ($Actual.Count -ne $Expected.Count) { | |
| return $false | |
| } | |
| for ($index = 0; $index -lt $Actual.Count; $index++) { | |
| $actualArg = $Actual[$index] | |
| $expectedArg = $Expected[$index] | |
| # Windows may expand a bare launcher executable in the process command line. | |
| # Qualified paths and every non-executable argument remain exact. | |
| if ($index -eq 0 -and $expectedArg -notmatch '[\\\\/]') { | |
| $actualArg = [IO.Path]::GetFileName($actualArg) | |
| if (-not $actualArg.EndsWith('.exe', [StringComparison]::OrdinalIgnoreCase)) { | |
| $actualArg += '.exe' | |
| } | |
| if (-not $expectedArg.EndsWith('.exe', [StringComparison]::OrdinalIgnoreCase)) { | |
| $expectedArg += '.exe' | |
| } | |
| } | |
| if (-not [string]::Equals($actualArg, $expectedArg, [StringComparison]::OrdinalIgnoreCase)) { | |
| return $false | |
| } | |
| } | |
| return $true | |
| } | |
| function Test-OpenClawSameProcess { | |
| param($Expected, $Actual) | |
| return ( | |
| $null -ne $Actual -and | |
| $Actual.ProcessId -eq $Expected.ProcessId -and | |
| $Actual.CreationTimeFileTime -eq $Expected.CreationTimeFileTime -and | |
| (Test-OpenClawArgvEqual -Actual $Actual.Argv -Expected $Expected.Argv) | |
| ) | |
| } | |
| function Get-OpenClawListenerKillDecision { | |
| param( | |
| [int]$CandidatePid, | |
| [string[]]$ExpectedArgv, | |
| $ObservedProcess, | |
| [string]$HeldProcessCreationTimeFileTime, | |
| $RecheckedListeners, | |
| $RecheckedProcess | |
| ) | |
| if ($ExpectedArgv.Count -eq 0) { | |
| return "expected-command-unavailable" | |
| } | |
| if ($null -eq $ObservedProcess -or $ObservedProcess.ProcessId -ne $CandidatePid) { | |
| return "process-unavailable" | |
| } | |
| if (-not (Test-OpenClawArgvEqual -Actual $ObservedProcess.Argv -Expected $ExpectedArgv)) { | |
| return "command-mismatch" | |
| } | |
| if ($HeldProcessCreationTimeFileTime -ne $ObservedProcess.CreationTimeFileTime) { | |
| return "process-replaced" | |
| } | |
| if (-not $RecheckedListeners.Known) { | |
| return "listener-query-unavailable" | |
| } | |
| if ($RecheckedListeners.Pids -notcontains $CandidatePid) { | |
| return "no-longer-listening" | |
| } | |
| if (-not (Test-OpenClawSameProcess -Expected $ObservedProcess -Actual $RecheckedProcess)) { | |
| return "process-replaced" | |
| } | |
| return "kill" | |
| } | |
| function Invoke-OpenClawVerifiedListenerKill { | |
| param( | |
| [int]$ProcessId, | |
| [int]$Port, | |
| [string[]]$ExpectedArgv, | |
| [scriptblock]$ProcessQuery = { param([int]$QueryPid) Get-OpenClawProcessFacts -ProcessId $QueryPid }, | |
| [scriptblock]$ListenerQuery = { param([int]$QueryPort) Get-OpenClawListenerSnapshot -Port $QueryPort }, | |
| [scriptblock]$ProcessOpen = { param([int]$QueryPid) [Diagnostics.Process]::GetProcessById($QueryPid) } | |
| ) | |
| $observedProcess = & $ProcessQuery $ProcessId | |
| if ($null -eq $observedProcess) { | |
| Write-RestartLog "openclaw restart skipped listener source=update pid=$ProcessId decision=process-unavailable" | |
| return | |
| } | |
| if ($ExpectedArgv.Count -eq 0) { | |
| Write-RestartLog "openclaw restart skipped listener source=update pid=$ProcessId decision=expected-command-unavailable" | |
| return | |
| } | |
| if (-not (Test-OpenClawArgvEqual -Actual $observedProcess.Argv -Expected $ExpectedArgv)) { | |
| Write-RestartLog "openclaw restart skipped listener source=update pid=$ProcessId decision=command-mismatch" | |
| return | |
| } | |
| $lease = $null | |
| try { | |
| $lease = & $ProcessOpen $ProcessId | |
| if ($null -eq $lease) { | |
| Write-RestartLog "openclaw restart skipped listener source=update pid=$ProcessId decision=process-handle-unavailable" | |
| return | |
| } | |
| # Force the Process object to retain its handle before reading identity. | |
| # StartTime and Kill then use that handle, including if the PID is recycled. | |
| [void]$lease.Handle | |
| $heldCreationTime = [long]$lease.StartTime.ToUniversalTime().ToFileTimeUtc() | |
| $heldCreationTime -= $heldCreationTime % 10 | |
| $recheckedListeners = & $ListenerQuery $Port | |
| $recheckedProcess = & $ProcessQuery $ProcessId | |
| $decisionParams = @{ | |
| CandidatePid = $ProcessId | |
| ExpectedArgv = $ExpectedArgv | |
| ObservedProcess = $observedProcess | |
| HeldProcessCreationTimeFileTime = [string]$heldCreationTime | |
| RecheckedListeners = $recheckedListeners | |
| RecheckedProcess = $recheckedProcess | |
| } | |
| $decision = Get-OpenClawListenerKillDecision @decisionParams | |
| if ($decision -ne "kill") { | |
| Write-RestartLog "openclaw restart skipped listener source=update pid=$ProcessId decision=$decision" | |
| return | |
| } | |
| $lease.Kill() | |
| Write-RestartLog "openclaw restart killed stale listener source=update pid=$ProcessId" | |
| } catch { | |
| Write-RestartLog "openclaw restart ownership verification failed source=update pid=$ProcessId error=$($_.Exception.Message)" | |
| } finally { | |
| if ($null -ne $lease) { | |
| $lease.Dispose() | |
| } | |
| } | |
| } | |
| # OPENCLAW_RESTART_KILL_POLICY_END | |
| function Invoke-OpenClawStartupLauncher { | |
| param([string]$LauncherPath) | |
| $launcherPath = $LauncherPath | |
| if (-not (Test-Path -LiteralPath $launcherPath)) { | |
| Write-RestartLog "openclaw restart startup launcher missing source=update path=$launcherPath" | |
| return 1 | |
| } | |
| try { | |
| Start-Process -FilePath $launcherPath -WindowStyle Hidden | Out-Null | |
| Write-RestartLog "openclaw restart launched startup fallback source=update path=$launcherPath" | |
| return 0 | |
| } catch { | |
| Write-RestartLog "openclaw restart startup fallback failed source=update error=$($_.Exception.Message)" | |
| return 1 | |
| } | |
| } | |
| $taskName = ${quotedTaskName} | |
| $port = ${port} | |
| $gatewayScriptPath = ${quotedGatewayScriptPath} | |
| $expectedGatewayArgv = @(${expectedGatewayArgv}) | |
| Write-RestartLog "openclaw restart attempt source=update target=$taskName" | |
| $taskState = Get-OpenClawScheduledTaskState -TaskName $taskName | |
| if ($taskState -eq "Running") { | |
| $endStatus = Invoke-OpenClawSchtasksWithTimeout -Arguments @("/End", "/TN", $taskName) -TimeoutSeconds 10 | |
| if ($endStatus -ne 0) { | |
| Write-RestartLog "openclaw restart schtasks end did not complete cleanly source=update status=$endStatus" | |
| } | |
| } else { | |
| Write-RestartLog "openclaw restart skipped schtasks end source=update state=$taskState" | |
| } | |
| for ($attempt = 1; $attempt -le 10; $attempt++) { | |
| $listenerSnapshot = Get-OpenClawListenerSnapshot -Port $port | |
| if (-not $listenerSnapshot.Known) { | |
| if ($attempt -eq 10) { | |
| Write-RestartLog "openclaw restart listener ownership unavailable source=update; refusing force-kill" | |
| break | |
| } | |
| Start-Sleep -Seconds 1 | |
| continue | |
| } | |
| $listeners = @($listenerSnapshot.Pids) | |
| if ($listeners.Count -eq 0) { | |
| break | |
| } | |
| if ($attempt -eq 10) { | |
| foreach ($listenerPid in $listeners) { | |
| Invoke-OpenClawVerifiedListenerKill -ProcessId $listenerPid -Port $port -ExpectedArgv $expectedGatewayArgv | |
| } | |
| break | |
| } | |
| Start-Sleep -Seconds 1 | |
| } | |
| $status = Invoke-OpenClawSchtasksWithTimeout -Arguments @("/Run", "/TN", $taskName) -TimeoutSeconds 30 | |
| if ($status -ne 0) { | |
| $status = Invoke-OpenClawStartupLauncher -LauncherPath $gatewayScriptPath | |
| } | |
| if ($status -eq 0) { | |
| Write-RestartLog "openclaw restart done source=update" | |
| } else { | |
| Write-RestartLog "openclaw restart failed source=update status=$status" | |
| } | |
| [Console]::Out.WriteLine("OPENCLAW_RESTART_COMPLETE") | |
| exit $status | |
| `; | |
| } else { | |
| return null; | |
| } | |
| const scriptDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-restart-")); | |
| const scriptPath = path.join(scriptDir, filename); | |
| try { | |
| if (windowsWrapper) { | |
| // Stdin executes one statement at a time. One literal block requires the | |
| // entire helper to parse successfully before any restart action runs. | |
| await fs.writeFile(scriptPath.replace(/\.cmd$/u, ".ps1"), `& {\n${scriptContent}\n}\n\n`, { | |
| mode: 0o700, | |
| flag: "wx", | |
| }); | |
| await fs.writeFile( | |
| `${scriptPath}.vbs`, | |
| encodeWindowsLauncherScript({ | |
| format: "vbs", | |
| content: buildHiddenLauncherScript({ scriptPath }), | |
| }), | |
| { mode: 0o700, flag: "wx" }, | |
| ); | |
| } | |
| await fs.writeFile(scriptPath, windowsWrapper ?? scriptContent, { mode: 0o755, flag: "wx" }); | |
| } catch (error) { | |
| await fs.rm(scriptDir, { recursive: true, force: true }).catch(() => {}); | |
| throw error; | |
| } | |
| return scriptPath; | |
| } catch { | |
| // If we can't write the script, we'll fall back to the standard restart method | |
| return null; | |
| } | |
| } | |
| /** Observe native acceptance separately from the caller's subsequent health check. */ | |
| export async function runRestartScript(scriptPath: string, timeoutMs: number): Promise<boolean> { | |
| const isWindows = process.platform === "win32"; | |
| // WScript supplies the hidden console that PowerShell stdin needs while the | |
| // outer helper remains detached from the updater's lifetime. | |
| const file = isWindows ? getWindowsSystem32ExePath("wscript.exe") : "/bin/sh"; | |
| const args = isWindows ? ["//B", "//Nologo", `${scriptPath}.vbs`] : [scriptPath]; | |
| try { | |
| await spawnCommand([file, ...args], { | |
| // Keep the detached, stream-independent handoff on every platform, but | |
| // observe its result before classifying failed health as an activation refusal. | |
| detached: true, | |
| stdio: "ignore", | |
| timeout: timeoutMs, | |
| forceKillAfterDelay: COMMAND_PROCESS_TREE_KILL_GRACE_MS, | |
| }); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |