File size: 12,367 Bytes
eb3f11e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 | import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { prepareRestartScript, runRestartScript } from "./restart-helper.js";
const windowsKillPolicyStartMarker = "# OPENCLAW_RESTART_KILL_POLICY_BEGIN";
const windowsKillPolicyEndMarker = "# OPENCLAW_RESTART_KILL_POLICY_END";
function findPowerShell(): string | null {
const executables = process.platform === "win32" ? ["powershell.exe", "pwsh.exe"] : ["pwsh"];
const candidates = [
process.env.OPENCLAW_TEST_PWSH,
...executables.flatMap((executable) =>
(process.env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean)
.map((dir) => path.join(dir, executable)),
),
];
return (
candidates.find((candidate): candidate is string =>
Boolean(candidate && existsSync(candidate)),
) ?? null
);
}
const powerShellPath = findPowerShell();
const itWithPowerShell = powerShellPath ? it : it.skip;
async function prepareWindowsScript(gatewayArgv: readonly string[] = []): Promise<string> {
const originalPlatform = process.platform;
try {
Object.defineProperty(process, "platform", { value: "win32" });
const scriptPath = await prepareRestartScript({}, 18789, gatewayArgv);
if (!scriptPath) {
throw new Error("expected a standalone restart script");
}
return scriptPath;
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform });
}
}
function extractWindowsKillPolicy(content: string): string {
const start = content.indexOf(windowsKillPolicyStartMarker);
const end = content.indexOf(windowsKillPolicyEndMarker, start);
if (start < 0 || end < 0) {
throw new Error("Windows restart kill-policy markers missing");
}
return content.slice(start + windowsKillPolicyStartMarker.length, end);
}
async function executeWindowsKillPolicy(content: string, testBody: string) {
if (!powerShellPath) {
throw new Error("PowerShell is unavailable");
}
const policy = extractWindowsKillPolicy(content);
const input = [
"& {",
'$ErrorActionPreference = "Stop"',
"$script:RestartLogs = [Collections.Generic.List[string]]::new()",
"function Write-RestartLog { param([string]$Message) $script:RestartLogs.Add($Message) | Out-Null }",
policy,
testBody,
"}\n\n",
].join("\n");
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
const child = execFile(
powerShellPath,
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "-"],
{ env: process.env },
(error, stdout, stderr) => {
if (error) {
reject(new Error(`PowerShell policy test failed: ${stderr || stdout}`, { cause: error }));
return;
}
resolve({ stdout, stderr });
},
);
child.stdin?.end(input);
});
}
itWithPowerShell(
"executes the shipped PowerShell kill policy with mocked Windows facts",
async () => {
const scriptPath = await prepareWindowsScript([
"node",
"C:\\openclaw\\dist\\entry.js",
"gateway",
"--port",
"18789",
]);
try {
const content = await fs.readFile(scriptPath.replace(/\.cmd$/u, ".ps1"), "utf8");
const result = await executeWindowsKillPolicy(
content,
String.raw`
function Assert-True {
param([bool]$Condition, [string]$Message)
if (-not $Condition) { throw $Message }
}
function Assert-DecisionLog {
param([string]$Decision)
$match = @($script:RestartLogs | Where-Object { $_ -like "*decision=$Decision*" })
Assert-True ($match.Count -gt 0) "missing decision log: $Decision"
}
$commandLines = @(
@{ Line = '"C:\Program Files\node.exe" "C:\openclaw\entry.js" gateway'; Expected = @('C:\Program Files\node.exe', 'C:\openclaw\entry.js', 'gateway') },
@{ Line = 'node "" "a b" C:\plain\path'; Expected = @('node', '', 'a b', 'C:\plain\path') },
@{ Line = 'node a\\\b d"e f"g h'; Expected = @('node', 'a\\\b', 'de fg', 'h') },
@{ Line = 'node a\\\"b c d'; Expected = @('node', 'a\"b', 'c', 'd') },
@{ Line = 'node a\\\\"b c" d e'; Expected = @('node', 'a\\b c', 'd', 'e') },
@{ Line = 'node "a""b"'; Expected = @('node', 'a"b') },
@{ Line = 'node a"""b'; Expected = @('node', 'a"b') },
@{ Line = 'node a""""b'; Expected = @('node', 'a"b') },
@{ Line = 'node "C:\trailing\\"'; Expected = @('node', 'C:\trailing\') },
@{ Line = ('node' + [char]9 + 'first' + [char]9 + 'second'); Expected = @('node', 'first', 'second') }
)
foreach ($case in $commandLines) {
$actual = @(Split-OpenClawWindowsCommandLine -CommandLine $case.Line)
Assert-True ($actual.Count -eq $case.Expected.Count) "argument count mismatch: $($case.Line)"
for ($index = 0; $index -lt $actual.Count; $index++) {
Assert-True ([string]::Equals($actual[$index], $case.Expected[$index], [StringComparison]::Ordinal)) "argument mismatch: $($case.Line) at $index"
}
}
function New-ProcessFacts {
param([int]$ProcessId, [string]$CreationTime, [string[]]$Argv)
return [pscustomobject]@{
ProcessId = $ProcessId
CreationTimeFileTime = $CreationTime
Argv = $Argv
}
}
function New-TestLease {
param([string]$CreationTime)
$lease = [pscustomobject]@{
CreatedAt = [datetime]::FromFileTimeUtc([long]$CreationTime)
Held = $false
Terminated = $false
Disposed = $false
}
$lease | Add-Member -MemberType ScriptProperty -Name Handle -Value {
$this.Held = $true
return 1
}
$lease | Add-Member -MemberType ScriptProperty -Name StartTime -Value {
if (-not $this.Held) { throw "identity read without a retained process handle" }
return $this.CreatedAt
}
$lease | Add-Member -MemberType ScriptMethod -Name Kill -Value {
if (-not $this.Held) { throw "kill without a retained process handle" }
$this.Terminated = $true
}
$lease | Add-Member -MemberType ScriptMethod -Name Dispose -Value {
$this.Disposed = $true
}
return $lease
}
function Invoke-MockedKill {
param($Observed, $Rechecked, $Listeners, $Lease, [string[]]$ExpectedArgv)
$script:MockObserved = $Observed
$script:MockRechecked = $Rechecked
$script:MockListeners = $Listeners
$script:MockLease = $Lease
$script:ProcessQueryCalls = 0
$script:ListenerQueryCalls = 0
$script:ProcessOpenCalls = 0
$processQuery = {
param([int]$IgnoredPid)
$script:ProcessQueryCalls += 1
if ($script:ProcessQueryCalls -eq 1) { return $script:MockObserved }
return $script:MockRechecked
}
$listenerQuery = {
param([int]$IgnoredPort)
$script:ListenerQueryCalls += 1
return $script:MockListeners
}
$processOpen = {
param([int]$IgnoredPid)
$script:ProcessOpenCalls += 1
return $script:MockLease
}
Invoke-OpenClawVerifiedListenerKill -ProcessId 4242 -Port 18789 -ExpectedArgv $ExpectedArgv -ProcessQuery $processQuery -ListenerQuery $listenerQuery -ProcessOpen $processOpen
}
# Get-NetTCPConnection exposes object properties, including duplicate IPv4/IPv6 rows.
function Get-NetTCPConnection {
param($State, $ErrorAction)
@(
[pscustomobject]@{ LocalPort = 18789; OwningProcess = 4242 },
[pscustomobject]@{ LocalPort = 18789; OwningProcess = 4242 },
[pscustomobject]@{ LocalPort = 443; OwningProcess = 5252 }
)
}
$snapshot = Get-OpenClawListenerSnapshot -Port 18789
Assert-True $snapshot.Known "Get-NetTCPConnection snapshot should be known"
Assert-True (@($snapshot.Pids).Count -eq 1) "duplicate listener PIDs should collapse"
Assert-True (@($snapshot.Pids)[0] -eq 4242) "wrong Get-NetTCPConnection PID"
# Force the locale-independent netstat fallback. Localized state text is ignored.
function Get-NetTCPConnection { param($State, $ErrorAction) throw "unavailable" }
function netstat.exe {
$script:LASTEXITCODE = 0
@(
" TCP 0.0.0.0:18789 0.0.0.0:0 LISTENING 4242",
" TCP [::]:18789 [::]:0 ABHÖREN 4242",
" TCP 127.0.0.1:18789 127.0.0.1:61234 HERGESTELLT 5252"
)
}
$snapshot = Get-OpenClawListenerSnapshot -Port 18789
Assert-True $snapshot.Known "netstat snapshot should be known"
Assert-True (@($snapshot.Pids).Count -eq 1) "netstat IPv4/IPv6 PIDs should collapse"
Assert-True (@($snapshot.Pids)[0] -eq 4242) "wrong netstat PID"
function netstat.exe { $script:LASTEXITCODE = 1 }
$snapshot = Get-OpenClawListenerSnapshot -Port 18789
Assert-True (-not $snapshot.Known) "failed listener queries must remain unknown"
$creation = "133987654321000000"
$expected = @("node", "C:\openclaw\dist\entry.js", "gateway", "--port", "18789")
$managed = New-ProcessFacts 4242 $creation @("node.exe", "C:\openclaw\dist\entry.js", "gateway", "--port", "18789")
$knownListener = [pscustomobject]@{ Known = $true; Pids = @(4242) }
$script:RestartLogs.Clear()
$lease = New-TestLease $creation
Invoke-MockedKill $managed $managed $knownListener $lease $expected
Assert-True $lease.Terminated "managed stale listener was not killed"
Assert-True $lease.Disposed "managed listener handle was not disposed"
Assert-True ($script:ProcessQueryCalls -eq 2) "managed process was not rechecked"
Assert-True ($script:ListenerQueryCalls -eq 1) "managed listener was not rechecked"
$script:RestartLogs.Clear()
$foreign = New-ProcessFacts 4242 $creation @("python.exe", "foreign-listener.py")
$lease = New-TestLease $creation
Invoke-MockedKill $foreign $foreign $knownListener $lease $expected
Assert-True (-not $lease.Terminated) "foreign listener was killed"
Assert-True ($script:ProcessOpenCalls -eq 0) "foreign listener opened a terminate handle"
Assert-DecisionLog "command-mismatch"
$script:RestartLogs.Clear()
$lease = New-TestLease $creation
Invoke-MockedKill $null $null $knownListener $lease $expected
Assert-True (-not $lease.Terminated) "unknown owner was killed"
Assert-True ($script:ProcessOpenCalls -eq 0) "unknown owner opened a terminate handle"
Assert-DecisionLog "process-unavailable"
$script:RestartLogs.Clear()
$lease = New-TestLease $creation
$unknownListeners = [pscustomobject]@{ Known = $false; Pids = @() }
Invoke-MockedKill $managed $managed $unknownListeners $lease $expected
Assert-True (-not $lease.Terminated) "unknown listener state was killed"
Assert-True $lease.Disposed "unknown listener handle was not disposed"
Assert-DecisionLog "listener-query-unavailable"
$script:RestartLogs.Clear()
$recycledLease = New-TestLease "133987654399000000"
Invoke-MockedKill $managed $managed $knownListener $recycledLease $expected
Assert-True (-not $recycledLease.Terminated) "recycled PID target was killed"
Assert-True $recycledLease.Disposed "recycled PID handle was not disposed"
Assert-DecisionLog "process-replaced"
Write-Output "OPENCLAW_RESTART_POLICY_OK"
`,
);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("OPENCLAW_RESTART_POLICY_OK");
} finally {
await fs.rm(path.dirname(scriptPath), { recursive: true, force: true });
}
},
);
describe.runIf(process.platform === "win32")("Windows restart wrapper", () => {
it.each([
{
name: "completed restart",
body: '& { [Console]::Out.WriteLine("OPENCLAW_RESTART_COMPLETE"); exit 0 }\n\n',
accepted: true,
},
{
name: "failed restart with a completion marker",
body: '& { [Console]::Out.WriteLine("OPENCLAW_RESTART_COMPLETE"); exit 7 }\n\n',
accepted: false,
},
{
name: "malformed input",
body: '& { [Console]::Out.WriteLine("OPENCLAW_RESTART_COMPLETE"); this is ( }\n\n',
accepted: false,
},
{
name: "incomplete input",
body: '& { [Console]::Out.WriteLine("OPENCLAW_RESTART_COMPLETE"); exit 0\n',
accepted: false,
},
])("reports the native outcome for $name", async ({ body, accepted }) => {
const scriptPath = await prepareWindowsScript();
try {
await fs.writeFile(scriptPath.replace(/\.cmd$/u, ".ps1"), body);
// A leftover outcome cannot authorize a new invocation that never ran.
await fs.writeFile(scriptPath.replace(/\.cmd$/u, ".out"), "OPENCLAW_RESTART_COMPLETE\r\n");
await expect(runRestartScript(scriptPath, 10_000)).resolves.toBe(accepted);
expect(existsSync(path.dirname(scriptPath))).toBe(false);
} finally {
await fs.rm(path.dirname(scriptPath), { recursive: true, force: true });
}
});
});
|