mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge remote-tracking branch 'origin/master' into perf/desktop-packaging-integration
# Conflicts: # .github/workflows/ci-windows.yml # apps/desktop/windows-signing/README.i18n.yaml
This commit is contained in:
commit
0c3d6a1368
18 files changed
+541
-15
No files matched your search
@@ -76,7 +76,7 @@ jobs:
|
||||
- name: Parse Windows runner supervision scripts
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1')) {
|
||||
foreach ($path in @('apps/desktop/scripts/windows-runner-task.ps1', 'apps/desktop/scripts/run-windows-runner-task.ps1', 'apps/desktop/scripts/start-windows-packaging-runner.ps1', 'apps/desktop/scripts/windows-runner-network.ps1', 'apps/desktop/scripts/run-windows-runner-network-process.ps1', 'apps/desktop/tests/windows-runner-task.ps1', 'apps/desktop/tests/windows-runner-launcher.ps1', 'apps/desktop/tests/windows-runner-network.ps1', 'apps/desktop/tests/windows-runner-network-process.ps1')) {
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $env:GITHUB_WORKSPACE $path), [ref]$tokens, [ref]$parseErrors)
|
||||
@@ -88,6 +88,12 @@ jobs:
|
||||
- name: Verify Windows packaging priority inheritance
|
||||
shell: pwsh
|
||||
run: ./apps/desktop/tests/windows-packaging-priority.ps1
|
||||
- name: Verify Windows runner network supervision
|
||||
shell: pwsh
|
||||
run: |
|
||||
./apps/desktop/tests/windows-runner-network.ps1
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
./apps/desktop/tests/windows-runner-network-process.ps1
|
||||
- name: Run Windows observational gates on built artifacts
|
||||
id: observational
|
||||
continue-on-error: true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Release configuration only; local --unsigned builds ignore this file.
|
||||
# Copy to .env.macos (Git ignored). Use UTF-8 and quote secrets containing # or spaces.
|
||||
# Paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DOWNLOAD_TEST_ORIGIN=
|
||||
# Required for test releases: 32 lowercase hexadecimal characters from a secure random generator.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copy to .env.windows (Git ignored). Use UTF-8 and quote secrets containing # or spaces.
|
||||
# Credential file paths may be absolute or relative to apps/desktop. Values are not shell-expanded.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DOWNLOAD_TEST_ORIGIN=
|
||||
# Required for test releases: 32 lowercase hexadecimal characters from a secure random generator.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Copy to production.env and configure the production service before selecting this preset.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=production
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
|
||||
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Copy to test.env; paths in secret configuration resolve beside macos-signing.env.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Copy to production.env in the same machine configuration directory and fill in the values.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=production
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copy to test.env in the same machine configuration directory and fill in the values.
|
||||
# Public test configuration. No PIN or upload credentials belong here.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
|
||||
# Replace login.example.com with the real login origin before packaging.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Own one operator-configured network process until the registered runner exits.
|
||||
param([Parameter(Mandatory=$true)][string]$RunnerRoot, [Parameter(Mandatory=$true)][string]$ConfigRoot, [Parameter(Mandatory=$true)][string]$NetworkRoot, [Parameter(Mandatory=$true)][string]$ReadinessScript, [Parameter(Mandatory=$true)][string]$StartScript, [switch]$CheckOnly)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$network = $NetworkRoot
|
||||
$lock = $null
|
||||
$process = $null
|
||||
$started = $false
|
||||
$runner = $null
|
||||
$runnerStarted = $false
|
||||
$restarts = 0
|
||||
$recordPath = Join-Path $network 'process.json'
|
||||
$code = 1
|
||||
try {
|
||||
foreach ($directory in @($RunnerRoot,$ConfigRoot,$NetworkRoot)) {
|
||||
if ($directory -notmatch '^[A-Za-z]:[\\/]' -or -not (Test-Path -LiteralPath $directory -PathType Container)) { throw 'An existing absolute local directory is required.' }
|
||||
}
|
||||
foreach ($script in @($ReadinessScript,$StartScript)) {
|
||||
if ($script -notmatch '^[A-Za-z]:[\\/]' -or -not (Test-Path -LiteralPath $script -PathType Leaf)) { throw 'An existing absolute local script is required.' }
|
||||
}
|
||||
$lock = [IO.File]::Open((Join-Path $network 'supervisor.lock'), 'OpenOrCreate', 'ReadWrite', 'None')
|
||||
$launch = Get-Content (Join-Path $network 'launch.json') -Raw | ConvertFrom-Json -AsHashtable
|
||||
if ($launch -isnot [Collections.IDictionary] -or $launch.Count -ne 3 -or @($launch.Keys | Where-Object { $_ -cnotin @('executable','arguments','restartDelaySeconds') }).Count) { throw 'Invalid network launch configuration.' }
|
||||
if (($launch.restartDelaySeconds -isnot [long] -and $launch.restartDelaySeconds -isnot [int]) -or $launch.restartDelaySeconds -lt 1 -or $launch.restartDelaySeconds -gt 3600) { throw 'Invalid network restart interval.' }
|
||||
if ($launch.executable -isnot [string] -or $launch.arguments -isnot [array] -or @($launch.arguments | Where-Object { $_ -isnot [string] -or $_.Contains([char]0) }).Count) { throw 'Invalid network executable or arguments.' }
|
||||
if (-not [IO.Path]::IsPathFullyQualified($launch.executable) -or -not (Test-Path -LiteralPath $launch.executable -PathType Leaf)) { throw 'Network executable unavailable.' }
|
||||
$launch.executable = [IO.Path]::GetFullPath($launch.executable)
|
||||
if (Test-Path -LiteralPath $recordPath) {
|
||||
$record = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json
|
||||
if ($record.pid -isnot [long] -and $record.pid -isnot [int]) { throw 'Invalid network process record.' }
|
||||
if ($record.pid -le 0 -or $record.started -isnot [long] -or $record.started -le 0) { throw 'Invalid network process record.' }
|
||||
$stale = Get-Process -Id $record.pid -ErrorAction SilentlyContinue
|
||||
if ($stale) {
|
||||
try {
|
||||
if (-not $stale.StartTime) { throw 'Owned network process identity unavailable.' }
|
||||
if ($stale.StartTime.ToUniversalTime().Ticks -eq $record.started) {
|
||||
if ($stale.Path -ne $launch.executable) { throw 'Owned network process identity unavailable.' }
|
||||
$stale.Kill($true)
|
||||
if (-not $stale.WaitForExit(10000)) { throw 'Owned network process did not stop.' }
|
||||
}
|
||||
} finally { $stale.Dispose() }
|
||||
}
|
||||
Remove-Item -LiteralPath $recordPath
|
||||
}
|
||||
$info = [Diagnostics.ProcessStartInfo]::new($launch.executable)
|
||||
$info.UseShellExecute = $false
|
||||
$info.CreateNoWindow = $true
|
||||
$info.WorkingDirectory = $network
|
||||
foreach ($name in @('HTTP_PROXY','HTTPS_PROXY','ALL_PROXY','http_proxy','https_proxy','all_proxy')) { $null = $info.Environment.Remove($name) }
|
||||
# Provider output can include private routes; it must never enter runner logs.
|
||||
$info.RedirectStandardOutput = $true
|
||||
$info.RedirectStandardError = $true
|
||||
foreach ($argument in $launch.arguments) { $info.ArgumentList.Add($argument) }
|
||||
$process = [Diagnostics.Process]::new()
|
||||
$process.StartInfo = $info
|
||||
if (-not $process.Start()) { throw 'Network process did not start.' }
|
||||
$started = $true
|
||||
$stdout = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null)
|
||||
$stderr = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null)
|
||||
@{pid=$process.Id;started=$process.StartTime.ToUniversalTime().Ticks} | ConvertTo-Json | Set-Content -LiteralPath $recordPath
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File $ReadinessScript -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly
|
||||
if ($LASTEXITCODE -ne 0 -or $process.HasExited) { throw 'Network readiness failed; listener was not started.' }
|
||||
if ($CheckOnly) { $code = 0 } else {
|
||||
$runnerInfo = [Diagnostics.ProcessStartInfo]::new((Join-Path $PSHOME 'pwsh.exe'))
|
||||
$runnerInfo.UseShellExecute = $false
|
||||
$runnerInfo.CreateNoWindow = $true
|
||||
$runnerInfo.RedirectStandardOutput = $true
|
||||
$runnerInfo.RedirectStandardError = $true
|
||||
foreach ($argument in @('-NoProfile','-NonInteractive','-File',$StartScript,'-RunnerRoot',$RunnerRoot,'-ConfigRoot',$ConfigRoot)) { $runnerInfo.ArgumentList.Add($argument) }
|
||||
$runner = [Diagnostics.Process]::new()
|
||||
$runner.StartInfo = $runnerInfo
|
||||
if (-not $runner.Start()) { throw 'Runner process did not start.' }
|
||||
$runnerStarted = $true
|
||||
$runnerOut = $runner.StandardOutput.BaseStream.CopyToAsync([Console]::OpenStandardOutput())
|
||||
$runnerErr = $runner.StandardError.BaseStream.CopyToAsync([Console]::OpenStandardError())
|
||||
while (-not $runner.WaitForExit(1000)) {
|
||||
if ($started -and -not $process.HasExited) { continue }
|
||||
if ($started) {
|
||||
Write-Output "Network process exited (code $($process.ExitCode)); runner retained. Inspect local network configuration if recovery repeats."
|
||||
$started = $false
|
||||
Remove-Item -LiteralPath $recordPath
|
||||
$process.Dispose()
|
||||
$process = $null
|
||||
}
|
||||
# The runner's lifetime owns recovery; waiting on it also cancels the retry delay.
|
||||
if ($runner.WaitForExit($launch.restartDelaySeconds * 1000)) { break }
|
||||
$process = [Diagnostics.Process]::new()
|
||||
$process.StartInfo = $info
|
||||
try { $started = $process.Start() }
|
||||
catch {
|
||||
# Launch errors can contain private arguments or paths.
|
||||
$started = $false
|
||||
}
|
||||
if (-not $started) {
|
||||
$process.Dispose()
|
||||
$process = $null
|
||||
Write-Output 'Network process restart failed; runner retained. Inspect the local executable and configuration; recovery will retry.'
|
||||
continue
|
||||
}
|
||||
$stdout = $process.StandardOutput.BaseStream.CopyToAsync([IO.Stream]::Null)
|
||||
$stderr = $process.StandardError.BaseStream.CopyToAsync([IO.Stream]::Null)
|
||||
@{pid=$process.Id;started=$process.StartTime.ToUniversalTime().Ticks} | ConvertTo-Json | Set-Content -LiteralPath $recordPath
|
||||
$restarts++
|
||||
Write-Output "Network process restarted (attempt $restarts); runner retained. Connectivity recovery is handled by the runner."
|
||||
}
|
||||
$code = $runner.ExitCode
|
||||
}
|
||||
} catch {
|
||||
# Do not expose provider paths, arguments, configuration or raw error messages.
|
||||
[Console]::Error.WriteLine('Private network supervision failed; inspect local state. No job was retried.')
|
||||
if ($runnerStarted -and -not $runner.HasExited) {
|
||||
[Console]::Error.WriteLine('Network recovery stopped; the existing runner remains running until it exits. Inspect local supervision state before maintenance.')
|
||||
$runner.WaitForExit()
|
||||
}
|
||||
} finally {
|
||||
if ($runnerStarted) {
|
||||
try {
|
||||
if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($runnerOut,$runnerErr), 5000)) { throw 'Output drain timed out.' }
|
||||
} catch {
|
||||
$code = 1
|
||||
[Console]::Error.WriteLine('Runner output drain failed or timed out; output may be incomplete.')
|
||||
}
|
||||
}
|
||||
if ($runner) { $runner.Dispose() }
|
||||
try {
|
||||
if ($started) {
|
||||
if (-not $process.HasExited) { $process.Kill($true) }
|
||||
if (-not $process.WaitForExit(10000)) { throw 'Network cleanup did not complete.' }
|
||||
if (Test-Path -LiteralPath $recordPath) { Remove-Item -LiteralPath $recordPath }
|
||||
}
|
||||
} catch {
|
||||
$code = 1
|
||||
[Console]::Error.WriteLine('Owned network cleanup failed; inspect local process state before restarting.')
|
||||
} finally {
|
||||
if ($process) { $process.Dispose() }
|
||||
if ($lock) { $lock.Dispose() }
|
||||
}
|
||||
}
|
||||
exit $code
|
||||
@@ -15,7 +15,13 @@ try {
|
||||
exit 1
|
||||
}
|
||||
Set-Content -LiteralPath $attemptFile -Value ($attempts + 1)
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'start.ps1') -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot *> $log
|
||||
$startup = Join-Path $PSScriptRoot 'start.ps1'
|
||||
if ($config.NetworkRoot) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $config.ConfigRoot 'runner-network.json') -PathType Leaf)) { throw 'Owned network supervision requires runner-network.json.' }
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File (Join-Path $PSScriptRoot 'run-windows-runner-network-process.ps1') -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot -NetworkRoot $config.NetworkRoot -ReadinessScript $startup -StartScript $startup *> $log
|
||||
} else {
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -NonInteractive -File $startup -RunnerRoot $config.RunnerRoot -ConfigRoot $config.ConfigRoot *> $log
|
||||
}
|
||||
$code = $LASTEXITCODE
|
||||
Add-Content -LiteralPath $log -Value "Runner launcher exited with code $code."
|
||||
# An unexpected exit is a task failure even when run.cmd hides the listener's nonzero status; the log retains its code.
|
||||
|
||||
@@ -16,6 +16,9 @@ if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'ConfigRoot requires a f
|
||||
$env:DSH_PACKAGING_CONFIG_DIR=[IO.Path]::GetFullPath($ConfigRoot)
|
||||
$env:DSH_TELEMETRY_DISABLED='1'
|
||||
$env:POWERSHELL_TELEMETRY_OPTOUT='1'
|
||||
. (Join-Path $PSScriptRoot 'windows-runner-network.ps1')
|
||||
$network = Read-RunnerNetwork $ConfigRoot
|
||||
Set-RunnerNetwork $network
|
||||
foreach($tool in @('pwsh','node','git','tar','python')) {
|
||||
$resolved=Get-Command $tool -CommandType Application -ErrorAction Stop | Select-Object -First 1
|
||||
Write-Output ('Tool: '+$tool+' -> '+$resolved.Source)
|
||||
@@ -31,6 +34,7 @@ $vswhere=Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/v
|
||||
$visualStudio=& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
if($LASTEXITCODE -ne 0 -or -not $visualStudio){throw 'Visual C++ tools unavailable'}
|
||||
Write-Output 'Visual C++ tools located.'
|
||||
Wait-RunnerNetwork $network
|
||||
if($CheckOnly){Write-Output 'Runner environment check passed.'; exit 0}
|
||||
if(Get-Process -Name Runner.Listener -ErrorAction SilentlyContinue){throw 'A runner listener is already running; inspect before starting another.'}
|
||||
Set-Location $RunnerRoot
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Load operator-owned network settings without evaluating code or logging their values.
|
||||
function Read-RunnerNetwork([string]$ConfigRoot) {
|
||||
$path = Join-Path $ConfigRoot 'runner-network.json'
|
||||
if (-not (Test-Path -LiteralPath $path)) { return $null }
|
||||
try { $config = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop }
|
||||
catch { throw 'Cannot read runner-network.json; inspect its JSON locally.' }
|
||||
$keys = @('proxyUrl','noProxy','serviceName','probeUrl','attempts','timeoutSeconds','retrySeconds')
|
||||
if ($config -isnot [Collections.IDictionary] -or $config.Count -ne $keys.Count -or @($config.Keys | Where-Object { $_ -cnotin $keys }).Count) {
|
||||
throw 'runner-network.json requires exactly proxyUrl, noProxy, serviceName, probeUrl, attempts, timeoutSeconds and retrySeconds.'
|
||||
}
|
||||
foreach ($key in @('proxyUrl','noProxy','serviceName','probeUrl')) {
|
||||
if ($config[$key] -isnot [string] -or $config[$key] -match '[\r\n\x00]') { throw 'Invalid network string setting; inspect runner-network.json locally.' }
|
||||
}
|
||||
if ([Management.Automation.WildcardPattern]::ContainsWildcardCharacters($config.serviceName)) { throw 'Network service must be an exact name.' }
|
||||
foreach ($key in @('attempts','timeoutSeconds','retrySeconds')) {
|
||||
if ($config[$key] -isnot [long] -and $config[$key] -isnot [int]) { throw 'Network budgets must be integers.' }
|
||||
if ($config[$key] -lt 1 -or $config[$key] -gt 60) { throw 'Network budgets must be between 1 and 60.' }
|
||||
}
|
||||
foreach ($key in @('proxyUrl','probeUrl')) {
|
||||
if ($key -eq 'proxyUrl' -and $config[$key] -eq '') { continue }
|
||||
$uri = $null
|
||||
if (-not [Uri]::TryCreate($config[$key], [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -notin @('http','https') -or $uri.UserInfo -or $uri.Query -or $uri.Fragment) {
|
||||
throw 'Network URLs must be absolute HTTP(S) URLs without credentials, queries or fragments.'
|
||||
}
|
||||
if ($key -eq 'proxyUrl' -and $uri.AbsolutePath -ne '/') { throw 'Proxy URL must not contain a path.' }
|
||||
}
|
||||
return $config
|
||||
}
|
||||
|
||||
# Called after the runner environment is scrubbed; only declared network values reach child jobs.
|
||||
function Set-RunnerNetwork($Config) {
|
||||
if ($null -eq $Config) { return }
|
||||
[Environment]::SetEnvironmentVariable('HTTP_PROXY', $Config.proxyUrl, 'Process')
|
||||
[Environment]::SetEnvironmentVariable('HTTPS_PROXY', $Config.proxyUrl, 'Process')
|
||||
[Environment]::SetEnvironmentVariable('NO_PROXY', $Config.noProxy, 'Process')
|
||||
[Environment]::SetEnvironmentVariable('NODE_USE_ENV_PROXY', '1', 'Process')
|
||||
}
|
||||
|
||||
# Only probe retries are allowed here. Service recovery belongs to its installed Windows service.
|
||||
function Wait-RunnerNetwork($Config) {
|
||||
if ($null -eq $Config) { return }
|
||||
$info = [Diagnostics.ProcessStartInfo]::new((Get-Command node -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source)
|
||||
$info.UseShellExecute = $false
|
||||
$info.CreateNoWindow = $true
|
||||
$info.RedirectStandardOutput = $true
|
||||
$info.RedirectStandardError = $true
|
||||
$info.Environment['DSH_NETWORK_PROBE_URL'] = $Config.probeUrl
|
||||
$info.Environment['DSH_NETWORK_PROBE_TIMEOUT'] = [string]($Config.timeoutSeconds * 1000)
|
||||
$info.ArgumentList.Add('--input-type=module')
|
||||
$info.ArgumentList.Add('-e')
|
||||
$info.ArgumentList.Add('try { const r = await fetch(process.env.DSH_NETWORK_PROBE_URL, { method: "HEAD", redirect: "error", signal: AbortSignal.timeout(Number(process.env.DSH_NETWORK_PROBE_TIMEOUT)) }); await r.body?.cancel(); process.exit(r.ok ? 0 : 1); } catch { process.exit(1); }')
|
||||
for ($attempt = 1; $attempt -le $Config.attempts; $attempt++) {
|
||||
$ready = $true
|
||||
if ($Config.serviceName) {
|
||||
try { $service = Get-Service -Name $Config.serviceName -ErrorAction Stop }
|
||||
catch { throw 'Configured network service is unavailable; inspect its installation locally.' }
|
||||
$ready = $service.Status -eq 'Running'
|
||||
}
|
||||
if ($ready) {
|
||||
$process = [Diagnostics.Process]::new()
|
||||
$process.StartInfo = $info
|
||||
try {
|
||||
if (-not $process.Start()) { throw 'Cannot launch network probe.' }
|
||||
$out = $process.StandardOutput.ReadToEndAsync()
|
||||
$err = $process.StandardError.ReadToEndAsync()
|
||||
if (-not $process.WaitForExit(($Config.timeoutSeconds + 5) * 1000)) {
|
||||
$process.Kill($true)
|
||||
$process.WaitForExit()
|
||||
$ready = $false
|
||||
} else { $ready = $process.ExitCode -eq 0 }
|
||||
$null = $out.GetAwaiter().GetResult()
|
||||
$null = $err.GetAwaiter().GetResult()
|
||||
} finally { $process.Dispose() }
|
||||
}
|
||||
if ($ready) { Write-Output 'Runner network dependency ready (Node HEAD probe).'; return }
|
||||
Write-Output "Runner network dependency not ready (probe $attempt/$($Config.attempts))."
|
||||
if ($attempt -lt $Config.attempts) { Start-Sleep -Seconds $Config.retrySeconds }
|
||||
}
|
||||
throw 'Runner network dependency unavailable; runner was not started. Inspect local service and network configuration. No job or signing retry was requested.'
|
||||
}
|
||||
@@ -4,13 +4,14 @@ param(
|
||||
[Parameter(Mandatory=$true)][ValidateSet('install','start','stop','status','uninstall')][string]$Action,
|
||||
[Parameter(Mandatory=$true)][string]$RunnerRoot,
|
||||
[string]$ConfigRoot,
|
||||
[string]$NetworkRoot,
|
||||
[ValidateRange(1,1440)][int]$RestartMinutes = 1,
|
||||
[ValidateRange(1,999)][int]$RestartCount = 10
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $IsWindows) { throw 'This command requires PowerShell 7 on Windows.' }
|
||||
if ($Action -ne 'install' -and @('ConfigRoot','RestartMinutes','RestartCount').Where({ $PSBoundParameters.ContainsKey($_) }).Count) {
|
||||
throw 'ConfigRoot, RestartMinutes and RestartCount apply only to install; reinstall to change them.'
|
||||
if ($Action -ne 'install' -and @('ConfigRoot','NetworkRoot','RestartMinutes','RestartCount').Where({ $PSBoundParameters.ContainsKey($_) }).Count) {
|
||||
throw 'ConfigRoot, NetworkRoot, RestartMinutes and RestartCount apply only to install; reinstall to change them.'
|
||||
}
|
||||
if ($RunnerRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'RunnerRoot must be an absolute local directory.' }
|
||||
$RunnerRoot = [IO.Path]::GetFullPath($RunnerRoot).TrimEnd('\','/')
|
||||
@@ -30,7 +31,15 @@ if ($Action -eq 'install') {
|
||||
if (-not $ConfigRoot -or $ConfigRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'Install requires an absolute ConfigRoot.' }
|
||||
$ConfigRoot = [IO.Path]::GetFullPath($ConfigRoot)
|
||||
$pwsh = Join-Path $PSHOME 'pwsh.exe'
|
||||
& $pwsh -NoProfile -File (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly
|
||||
$startup = Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1'
|
||||
if ($PSBoundParameters.ContainsKey('NetworkRoot')) {
|
||||
if ($NetworkRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'NetworkRoot must identify an existing absolute local directory.' }
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $ConfigRoot 'runner-network.json') -PathType Leaf)) { throw 'Owned network supervision requires runner-network.json.' }
|
||||
$NetworkRoot = [IO.Path]::GetFullPath($NetworkRoot)
|
||||
& $pwsh -NoProfile -File (Join-Path $PSScriptRoot 'run-windows-runner-network-process.ps1') -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -NetworkRoot $NetworkRoot -ReadinessScript $startup -StartScript $startup -CheckOnly
|
||||
} else {
|
||||
& $pwsh -NoProfile -File $startup -RunnerRoot $RunnerRoot -ConfigRoot $ConfigRoot -CheckOnly
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Runner preflight failed; no task was installed.' }
|
||||
if (Test-Path -LiteralPath $serviceDir) {
|
||||
if ((Get-Item -LiteralPath $serviceDir).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Task directory cannot be a link.' }
|
||||
@@ -44,7 +53,10 @@ if ($Action -eq 'install') {
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Runner launcher compilation failed; verify .NET Framework 4.5+ is installed. No task was installed.' }
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'start-windows-packaging-runner.ps1') -Destination (Join-Path $serviceDir 'start.ps1')
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'run-windows-runner-task.ps1') -Destination (Join-Path $serviceDir 'run.ps1')
|
||||
@{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8
|
||||
foreach ($helper in @('windows-runner-network.ps1','run-windows-runner-network-process.ps1')) {
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $helper) -Destination (Join-Path $serviceDir $helper)
|
||||
}
|
||||
@{ RunnerRoot = $RunnerRoot; ConfigRoot = $ConfigRoot; NetworkRoot = $NetworkRoot; TaskName = $taskName; RestartCount = $RestartCount } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $serviceDir 'config.json') -Encoding utf8
|
||||
Set-Content -LiteralPath (Join-Path $serviceDir 'attempts.txt') -Value '0'
|
||||
$execution = New-ScheduledTaskAction -Execute $launcher -Argument ('"' + $pwsh + '"') -WorkingDirectory $RunnerRoot
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $sid -LogonType Interactive -RunLevel Limited
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# Fake local processes only; no network or signing hardware.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('demo-network-' + [guid]::NewGuid().ToString('N'))
|
||||
$null = New-Item -ItemType Directory $root
|
||||
try {
|
||||
foreach ($case in @('ready','not-ready','locked','missing-executable','stale','reused','invalid-config','invalid-delay','bad-record','provider-exit','runner-failure','check-only','recovery','task-route')) {
|
||||
$base = Join-Path $root $case
|
||||
$runner = Join-Path $base 'runner'
|
||||
$network = Join-Path $base 'network'
|
||||
$null = New-Item -ItemType Directory -Path $network,(Join-Path $runner 'dsh-task') -Force
|
||||
@'
|
||||
Set-Content (Join-Path $PSScriptRoot 'child-pid') $PID
|
||||
Write-Output 'PRIVATE-OUTPUT-MUST-NOT-LEAK'
|
||||
Start-Sleep -Seconds 60
|
||||
'@ | Set-Content (Join-Path $network 'fake.ps1')
|
||||
@'
|
||||
param([string]$RunnerRoot,[string]$ConfigRoot)
|
||||
$network = Join-Path (Split-Path $PSScriptRoot) 'network'
|
||||
$deadline = [datetime]::UtcNow.AddSeconds(10)
|
||||
while (-not (Test-Path (Join-Path $network 'child-pid'))) {
|
||||
if ([datetime]::UtcNow -ge $deadline) { exit 9 }
|
||||
Start-Sleep -Milliseconds 20
|
||||
}
|
||||
if (Test-Path (Join-Path $network 'fail')) { exit 1 }
|
||||
exit 0
|
||||
'@ | Set-Content (Join-Path $runner 'cold-boot-smoke.ps1')
|
||||
"Set-Content (Join-Path `$PSScriptRoot 'listened') yes; exit 0" | Set-Content (Join-Path $runner 'dsh-task/start-runner.ps1')
|
||||
if ($case -eq 'runner-failure') { Set-Content (Join-Path $runner 'dsh-task/start-runner.ps1') "Set-Content (Join-Path `$PSScriptRoot 'listened') yes; exit 7" }
|
||||
if ($case -in @('recovery','task-route')) {
|
||||
@'
|
||||
param([string]$RunnerRoot,[string]$ConfigRoot,[switch]$CheckOnly)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$network = Join-Path $ConfigRoot 'network'
|
||||
if ($CheckOnly) {
|
||||
$deadline = [datetime]::UtcNow.AddSeconds(30)
|
||||
while (-not (Test-Path (Join-Path $network 'child-pid'))) {
|
||||
if ([datetime]::UtcNow -ge $deadline) { exit 9 }
|
||||
Start-Sleep -Milliseconds 20
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
Set-Content (Join-Path $PSScriptRoot 'listened') $PID
|
||||
Write-Output 'RUNNER-STDOUT'
|
||||
[Console]::Error.WriteLine('RUNNER-STDERR')
|
||||
for ($iteration = 0; $iteration -lt 2; $iteration++) {
|
||||
$previous = [int](Get-Content (Join-Path $network 'child-pid'))
|
||||
$child = Get-Process -Id $previous
|
||||
try { $child.Kill(); $child.WaitForExit() } finally { $child.Dispose() }
|
||||
$deadline = [datetime]::UtcNow.AddSeconds(30)
|
||||
do {
|
||||
if ([datetime]::UtcNow -ge $deadline) { throw 'Network process was not recovered.' }
|
||||
Start-Sleep -Milliseconds 50
|
||||
$next = Get-Content (Join-Path $network 'child-pid') -Raw
|
||||
} while (-not $next -or [int]$next -eq $previous)
|
||||
if ([int](Get-Content (Join-Path $PSScriptRoot 'listened')) -ne $PID) { throw 'Runner was replaced during recovery.' }
|
||||
}
|
||||
Write-Output 'RUNNER-SURVIVED-RECOVERY'
|
||||
exit 0
|
||||
'@ | Set-Content (Join-Path $runner 'dsh-task/start-runner.ps1')
|
||||
}
|
||||
if ($case -eq 'provider-exit') {
|
||||
Set-Content (Join-Path $network 'fake.ps1') 'Set-Content (Join-Path $PSScriptRoot "child-pid") $PID; exit 0'
|
||||
@'
|
||||
$network = Join-Path (Split-Path $PSScriptRoot) 'network'
|
||||
$deadline = [datetime]::UtcNow.AddSeconds(10)
|
||||
do {
|
||||
if ([datetime]::UtcNow -ge $deadline) { throw 'Provider exit barrier timed out.' }
|
||||
Start-Sleep -Milliseconds 20
|
||||
$record = Join-Path $network 'child-pid'
|
||||
} while (-not (Test-Path $record) -or (Get-Process -Id ([int](Get-Content $record)) -ErrorAction SilentlyContinue))
|
||||
exit 0
|
||||
'@ | Set-Content (Join-Path $runner 'cold-boot-smoke.ps1')
|
||||
}
|
||||
$exe = if ($case -eq 'missing-executable') { Join-Path $network 'missing.exe' } else { Join-Path $PSHOME 'pwsh.exe' }
|
||||
@{executable=$exe;arguments=@('-NoProfile','-File',(Join-Path $network 'fake.ps1'));restartDelaySeconds=1} | ConvertTo-Json | Set-Content (Join-Path $network 'launch.json')
|
||||
if ($case -eq 'invalid-config') { Set-Content (Join-Path $network 'launch.json') '{"executable":"PRIVATE-OUTPUT","arguments":[],"unknown":true}' }
|
||||
if ($case -eq 'invalid-delay') { @{executable=$exe;arguments=@();restartDelaySeconds=0} | ConvertTo-Json | Set-Content (Join-Path $network 'launch.json') }
|
||||
if ($case -eq 'bad-record') { Set-Content (Join-Path $network 'process.json') '{"pid":0,"started":1}' }
|
||||
if ($case -eq 'not-ready') { Set-Content (Join-Path $network 'fail') yes }
|
||||
$held = if ($case -eq 'locked') { [IO.File]::Open((Join-Path $network 'supervisor.lock'),'OpenOrCreate','ReadWrite','None') } else { $null }
|
||||
$stale = $null
|
||||
try {
|
||||
if ($case -in @('stale','reused')) {
|
||||
$info = [Diagnostics.ProcessStartInfo]::new($exe)
|
||||
$info.UseShellExecute = $false
|
||||
$info.CreateNoWindow = $true
|
||||
foreach ($argument in @('-NoProfile','-Command','Start-Sleep -Seconds 60')) { $info.ArgumentList.Add($argument) }
|
||||
$stale = [Diagnostics.Process]::Start($info)
|
||||
@{pid=$stale.Id;started=($stale.StartTime.ToUniversalTime().Ticks + $(if($case -eq 'reused'){1}else{0}))} | ConvertTo-Json | Set-Content (Join-Path $network 'process.json')
|
||||
}
|
||||
[string[]]$options = @(if ($case -eq 'check-only') { '-CheckOnly' })
|
||||
if ($case -eq 'task-route') {
|
||||
$taskRoot = Join-Path $runner 'dsh-task'
|
||||
Copy-Item (Join-Path $PSScriptRoot '../scripts/run-windows-runner-task.ps1') (Join-Path $taskRoot 'run.ps1')
|
||||
Copy-Item (Join-Path $PSScriptRoot '../scripts/run-windows-runner-network-process.ps1') $taskRoot
|
||||
Copy-Item (Join-Path $taskRoot 'start-runner.ps1') (Join-Path $taskRoot 'start.ps1')
|
||||
@{RunnerRoot=$runner;ConfigRoot=$base;NetworkRoot=$network;RestartCount=1} | ConvertTo-Json | Set-Content (Join-Path $taskRoot 'config.json')
|
||||
Set-Content (Join-Path $taskRoot 'attempts.txt') 0
|
||||
Set-Content (Join-Path $base 'runner-network.json') '{}'
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File (Join-Path $taskRoot 'run.ps1')
|
||||
if ($LASTEXITCODE -ne 1) { throw 'Task wrapper must retain its unexpected-exit result.' }
|
||||
$output = Get-Content (Get-ChildItem $taskRoot -Filter 'run-*.log').FullName -Raw
|
||||
if ([int](Get-Content (Join-Path $taskRoot 'attempts.txt')) -ne 1) { throw 'Network recovery consumed task launch budget.' }
|
||||
} else {
|
||||
$output = & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File (Join-Path $PSScriptRoot '../scripts/run-windows-runner-network-process.ps1') -RunnerRoot $runner -ConfigRoot $base -NetworkRoot $network -ReadinessScript (Join-Path $runner 'cold-boot-smoke.ps1') -StartScript (Join-Path $runner 'dsh-task/start-runner.ps1') @options 2>&1 | Out-String
|
||||
}
|
||||
if (($LASTEXITCODE -eq 0) -ne ($case -in @('ready','stale','reused','check-only','recovery'))) { throw "Unexpected exit: ${case}: $output" }
|
||||
if ((Test-Path (Join-Path $runner 'dsh-task/listened')) -ne ($case -in @('ready','stale','reused','runner-failure','recovery','task-route'))) { throw "Listener admission failed: $case" }
|
||||
if ($case -in @('recovery','task-route')) {
|
||||
foreach ($expected in @('RUNNER-STDOUT','RUNNER-STDERR','RUNNER-SURVIVED-RECOVERY','Network process exited','Network process restarted (attempt 2)')) {
|
||||
if (-not $output.Contains($expected)) { throw "Missing recovery evidence: $expected" }
|
||||
}
|
||||
}
|
||||
if ($case -eq 'stale' -and -not $stale.HasExited) { throw 'Owned stale provider was not reaped' }
|
||||
if ($case -eq 'reused' -and $stale.HasExited) { throw 'A reused PID was killed' }
|
||||
if ($output.Contains('PRIVATE-OUTPUT')) { throw 'Provider output leaked' }
|
||||
if (Test-Path (Join-Path $network 'child-pid')) {
|
||||
$childId = [int](Get-Content (Join-Path $network 'child-pid'))
|
||||
if (Get-Process -Id $childId -ErrorAction SilentlyContinue) { throw 'Child leaked' }
|
||||
}
|
||||
if ($case -ne 'bad-record' -and (Test-Path (Join-Path $network 'process.json'))) { throw 'Ownership record leaked' }
|
||||
if ($case -eq 'bad-record' -and -not (Test-Path (Join-Path $network 'process.json'))) { throw 'Invalid identity evidence was removed' }
|
||||
if (-not $held) {
|
||||
$released = [IO.File]::Open((Join-Path $network 'supervisor.lock'),'OpenOrCreate','ReadWrite','None')
|
||||
$released.Dispose()
|
||||
}
|
||||
Write-Output "PASS: $case"
|
||||
} finally {
|
||||
if ($stale) {
|
||||
if (-not $stale.HasExited) { $stale.Kill(); $stale.WaitForExit() }
|
||||
$stale.Dispose()
|
||||
}
|
||||
if ($held) { $held.Dispose() }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if ([IO.Path]::GetDirectoryName($root).TrimEnd('\') -ne [IO.Path]::GetTempPath().TrimEnd('\')) { throw 'Unsafe cleanup path' }
|
||||
Remove-Item -LiteralPath $root -Recurse -Force
|
||||
}
|
||||
exit 0
|
||||
@@ -0,0 +1,117 @@
|
||||
# Isolated proxy fixture; no external requests, runner registration or signing hardware.
|
||||
param([switch]$CheckStartup)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. (Join-Path $PSScriptRoot '../scripts/windows-runner-network.ps1')
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('dsh-network-' + [guid]::NewGuid().ToString('N'))
|
||||
$null = New-Item -ItemType Directory -Path $root
|
||||
$null = New-Item -ItemType Directory -Path (Join-Path $root 'tools')
|
||||
$pwshLink = Join-Path $root 'tools/pwsh-7.6.6'
|
||||
if ($CheckStartup) { $null = New-Item -ItemType Junction -Path $pwshLink -Target $PSHOME }
|
||||
$server = $null
|
||||
$saved = @{}
|
||||
foreach ($name in @('HTTP_PROXY','HTTPS_PROXY','NO_PROXY','NODE_USE_ENV_PROXY')) { $saved[$name] = [Environment]::GetEnvironmentVariable($name,'Process') }
|
||||
try {
|
||||
if ($null -ne (Read-RunnerNetwork $root)) { throw 'Absent configuration changed behavior.' }
|
||||
$fixture = Join-Path $root 'proxy.cjs'
|
||||
@'
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const root = process.argv[2];
|
||||
const server = http.createServer((req, res) => {
|
||||
fs.appendFileSync(path.join(root, 'requests'), req.method + ' ' + req.url + '\n');
|
||||
if (req.url.includes('/hang')) return;
|
||||
res.writeHead(req.url.includes('/fail') ? 503 : 200);
|
||||
res.end();
|
||||
});
|
||||
server.on('connect', (req, socket) => {
|
||||
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
server.emit('connection', socket);
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => fs.writeFileSync(path.join(root, 'port'), String(server.address().port)));
|
||||
'@ | Set-Content -LiteralPath $fixture
|
||||
$info = [Diagnostics.ProcessStartInfo]::new((Get-Command node).Source)
|
||||
$info.UseShellExecute = $false
|
||||
$info.CreateNoWindow = $true
|
||||
$info.ArgumentList.Add($fixture)
|
||||
$info.ArgumentList.Add($root)
|
||||
$server = [Diagnostics.Process]::Start($info)
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(30)
|
||||
while (-not (Test-Path (Join-Path $root 'port'))) {
|
||||
if ($server.HasExited -or [DateTime]::UtcNow -gt $deadline) { throw 'Proxy fixture did not become ready.' }
|
||||
Start-Sleep -Milliseconds 50
|
||||
}
|
||||
$port = Get-Content (Join-Path $root 'port') -Raw
|
||||
$config = @{proxyUrl="http://127.0.0.1:$port";noProxy='';serviceName='';probeUrl='http://runner-probe.invalid/ready';attempts=2;timeoutSeconds=2;retrySeconds=1}
|
||||
function Write-Config { $config | ConvertTo-Json | Set-Content (Join-Path $root 'runner-network.json') }
|
||||
Write-Config
|
||||
$parsed = Read-RunnerNetwork $root
|
||||
Set-RunnerNetwork $parsed
|
||||
Wait-RunnerNetwork $parsed
|
||||
if ($env:NODE_USE_ENV_PROXY -ne '1' -or $env:HTTP_PROXY -ne $config.proxyUrl -or $env:HTTPS_PROXY -ne $config.proxyUrl) { throw 'Proxy environment was not applied.' }
|
||||
# Full startup requires the operator's installed packaging toolchain and persistent PATH.
|
||||
if ($CheckStartup) {
|
||||
$output = & (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File (Join-Path $PSScriptRoot '../scripts/start-windows-packaging-runner.ps1') -RunnerRoot $root -ConfigRoot $root -CheckOnly 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output.Contains('Runner network dependency ready')) { throw "Startup integration failed: $output" }
|
||||
if ((Get-Content (Join-Path $root 'requests')).Count -ne 2) { throw 'Startup did not reach the configured proxy.' }
|
||||
Clear-Content (Join-Path $root 'requests')
|
||||
Wait-RunnerNetwork $parsed
|
||||
}
|
||||
$config.probeUrl = 'http://runner-probe.invalid/fail'
|
||||
Write-Config
|
||||
$failed = $false
|
||||
try { Wait-RunnerNetwork (Read-RunnerNetwork $root) } catch { $failed = $_.Exception.Message.StartsWith('Runner network dependency unavailable;') }
|
||||
if (-not $failed -or (Get-Content (Join-Path $root 'requests')).Count -ne 3) { throw 'Failed readiness did not stop at its budget.' }
|
||||
$config.serviceName = 'fixture-service'
|
||||
$config.probeUrl = 'http://runner-probe.invalid/ready'
|
||||
$script:serviceChecks = 0
|
||||
function Get-Service {
|
||||
param($Name)
|
||||
$script:serviceChecks++
|
||||
[pscustomobject]@{ Status = if ($script:serviceChecks -eq 1) { 'Stopped' } else { 'Running' } }
|
||||
}
|
||||
try {
|
||||
Wait-RunnerNetwork $config
|
||||
if ($script:serviceChecks -ne 2 -or (Get-Content (Join-Path $root 'requests')).Count -ne 4) { throw 'Service readiness did not contain probes.' }
|
||||
function Get-Service { throw 'fake-secret' }
|
||||
$failure = $null
|
||||
try { Wait-RunnerNetwork $config } catch { $failure = $_ }
|
||||
if (-not $failure -or $failure.ToString().Contains('fake-secret') -or (Get-Content (Join-Path $root 'requests')).Count -ne 4) { throw 'Missing service was not contained.' }
|
||||
} finally { Remove-Item Function:Get-Service }
|
||||
$config.serviceName = ''
|
||||
$config.proxyUrl = 'http://127.0.0.1:1'
|
||||
$config.noProxy = '127.0.0.1'
|
||||
$config.probeUrl = "http://127.0.0.1:$port/ready"
|
||||
Set-RunnerNetwork $config
|
||||
Wait-RunnerNetwork $config
|
||||
if ((Get-Content (Join-Path $root 'requests')).Count -ne 5) { throw 'NO_PROXY did not bypass the unavailable proxy.' }
|
||||
$config.probeUrl = "http://127.0.0.1:$port/hang"
|
||||
$config.attempts = 1
|
||||
$config.timeoutSeconds = 1
|
||||
$failure = $null
|
||||
try { Wait-RunnerNetwork $config } catch { $failure = $_ }
|
||||
if (-not $failure -or (Get-Content (Join-Path $root 'requests')).Count -ne 6) { throw 'Unresponsive endpoint did not terminate its probe.' }
|
||||
foreach ($invalid in @('extra','credential','budget','type','wildcard','malformed')) {
|
||||
$bad = $config.Clone()
|
||||
switch ($invalid) {
|
||||
'extra' { $bad.unknown = 'invalid' }
|
||||
'credential' { $bad.proxyUrl = 'http://fake-secret:fake-secret@localhost:1' }
|
||||
'budget' { $bad.attempts = 0 }
|
||||
'type' { $bad.retrySeconds = '2' }
|
||||
'wildcard' { $bad.serviceName = '*' }
|
||||
}
|
||||
$json = if ($invalid -eq 'malformed') { '{fake-secret' } else { $bad | ConvertTo-Json }
|
||||
$json | Set-Content (Join-Path $root 'runner-network.json')
|
||||
$failure = $null
|
||||
try { $null = Read-RunnerNetwork $root } catch { $failure = $_ }
|
||||
if (-not $failure -or $failure.ToString().Contains('fake-secret')) { throw 'Invalid configuration accepted or disclosed.' }
|
||||
}
|
||||
Write-Output "PASS: absent config, proxy routing, Node proxy opt-in, service readiness, bounded failure and redacted invalid config; startup integration=$CheckStartup."
|
||||
} finally {
|
||||
foreach ($name in $saved.Keys) { [Environment]::SetEnvironmentVariable($name,$saved[$name],'Process') }
|
||||
if ($server) { if (-not $server.HasExited) { $server.Kill($true); $server.WaitForExit() }; $server.Dispose() }
|
||||
if ($CheckStartup) { [IO.Directory]::Delete($pwshLink) }
|
||||
if ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($root)).TrimEnd('\') -ne [IO.Path]::GetTempPath().TrimEnd('\')) { throw 'Fixture cleanup escaped the temporary directory.' }
|
||||
Remove-Item -LiteralPath $root -Recurse -Force
|
||||
}
|
||||
exit 0
|
||||
@@ -7,7 +7,7 @@ $source = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../scripts'))
|
||||
$runner = Join-Path $root 'runner with spaces'
|
||||
$null = New-Item -ItemType Directory -Path $runner
|
||||
Set-Content -LiteralPath (Join-Path $runner '.runner') -Value '{}'
|
||||
foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1','windows-runner-launcher.cs')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root }
|
||||
foreach ($file in @('windows-runner-task.ps1','run-windows-runner-task.ps1','windows-runner-launcher.cs','windows-runner-network.ps1','run-windows-runner-network-process.ps1')) { Copy-Item -LiteralPath (Join-Path $source $file) -Destination $root }
|
||||
$manager = Join-Path $root 'windows-runner-task.ps1'
|
||||
@'
|
||||
param([string]$RunnerRoot, [string]$ConfigRoot, [switch]$CheckOnly)
|
||||
|
||||
@@ -21,8 +21,11 @@
|
||||
en: bb889e9514678d1c
|
||||
zh: 48cbec1e907d6a2b
|
||||
/windows-signing-and-runner-operations/machine-setup-and-runner-startup/supervised-startup:
|
||||
en: 3e8ce94b8383163d
|
||||
zh: a545c6c0e86b75a1
|
||||
en: 317bd7cbd51a9717
|
||||
zh: fd880f204a39eb0f
|
||||
/windows-signing-and-runner-operations/machine-setup-and-runner-startup/optional-network-dependency:
|
||||
en: 60f34f8637b3c252
|
||||
zh: 805435964ab20263
|
||||
/windows-signing-and-runner-operations/signing-configuration:
|
||||
en: 4b5548818375eb9a
|
||||
zh: 8c975adbb1dca9c8
|
||||
|
||||
@@ -46,6 +46,16 @@ Before maintenance, pause new workflow dispatch and wait for the runner to becom
|
||||
|
||||
Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, console-free initial launch and automatic recovery, duplicate-start prevention, stop, child-process teardown, containment of startup/logging failures and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. `pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` checks the GUI executable, child console absence, exit status, diagnostic output and bounded draining with an inherited writer without Task Scheduler; Windows CI runs it. Real-machine acceptance additionally checks an idle runner coming online without a visible Terminal window, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification.
|
||||
|
||||
### Optional network dependency
|
||||
|
||||
Without `runner-network.json` in `ConfigRoot`, startup retains its scrubbed direct-network behavior. To configure a network dependency, create that operator-owned JSON file outside the checkout with exactly `proxyUrl`, `noProxy`, `serviceName`, `probeUrl`, `attempts`, `timeoutSeconds` and `retrySeconds`. The first four fields are strings; the three budgets are integers from 1 through 60. Use an empty `proxyUrl` for direct access and an empty `serviceName` when no Windows service owns the dependency. URLs must use HTTP(S) without credentials, queries or fragments; the proxy URL cannot include a path. Startup applies `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and `NODE_USE_ENV_PROXY` after environment scrubbing, then requires a successful Node HEAD probe before admitting the runner. Service readiness checks never start or stop a Windows service.
|
||||
|
||||
For a dedicated child process, add `-NetworkRoot <absolute-private-network-directory>` to task installation. That directory must contain `launch.json` with exactly an absolute `executable` filename, an `arguments` string array and an integer `restartDelaySeconds` from 1 through 3600. This mode requires `runner-network.json`; set its `serviceName` to empty. Give the child its own data directory, listener bindings and executable identity, distinct from applications managed by desktop software; separate ports alone do not prevent another manager from terminating a process by executable name. The scripts do not alter desktop network settings. Keep actual provider names, paths, addresses, ports and arguments in machine configuration, never in repository files or shared logs. Restrict access to both configuration directories to the runner operator.
|
||||
|
||||
Installation starts the dependency for readiness checking and stops it before registering the task. While the runner lives, the supervisor restarts only an exited owned network process after the configured delay, including repeated launch failures; it does not restart the runner or retry jobs or signing. Exit, restart and launch-failure messages appear in `run-*.log`; provider output is discarded. Restart messages establish process creation, not network availability: confirm reconnection in runner logs. A live but unresponsive dependency requires operator diagnosis. The supervisor exclusively locks `NetworkRoot`, verifies PID, start time and executable before reclaiming a stale child, and stops its child when the runner exits. Unrecoverable supervision errors leave the active runner alive until it exits and report that recovery has stopped. Preserve `NetworkRoot` when reinstalling; pulling changes alone does not replace installed helpers.
|
||||
|
||||
Windows CI runs `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network.ps1` and `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network-process.ps1` with isolated local fixtures. On a configured packaging machine, add `-CheckStartup` to the first command to verify network configuration after the actual startup environment scrub. These tests use no signing hardware or external network endpoints.
|
||||
|
||||
<a id="signing-configuration"></a>
|
||||
|
||||
## Signing configuration
|
||||
|
||||
@@ -46,6 +46,16 @@ description: "配置和维护 Windows 打包 runner、签名凭据、EV Token
|
||||
|
||||
操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、无控制台的首次启动与自动恢复、防止重复启动、停止、子进程退出、启动/日志失败时的禁用处理和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。`pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` 无需计划任务,检查 GUI 可执行文件、子进程无控制台、退出状态、诊断输出以及存在继承管道写端时的有界排空;Windows CI 会执行该测试。真实机器验收还需检查空闲 runner 上线且不出现 Terminal 窗口、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。
|
||||
|
||||
### 可选网络依赖
|
||||
|
||||
`ConfigRoot` 中没有 `runner-network.json` 时,启动仍使用清理继承环境后的直连行为。要配置网络依赖,请在 checkout 外创建操作者管理的该 JSON 文件,且只包含 `proxyUrl`、`noProxy`、`serviceName`、`probeUrl`、`attempts`、`timeoutSeconds` 和 `retrySeconds`。前四个字段为字符串,后三个预算为 1 至 60 的整数。直连时将 `proxyUrl` 留空,没有 Windows 服务管理依赖时将 `serviceName` 留空。URL 必须使用 HTTP(S),不能包含凭据、查询参数或片段;代理 URL 不能包含路径。启动脚本在清理环境后设置 `HTTP_PROXY`、`HTTPS_PROXY`、`NO_PROXY` 和 `NODE_USE_ENV_PROXY`,并要求 Node HEAD 探测成功才允许启动 runner。服务就绪检查不会启动或停止 Windows 服务。
|
||||
|
||||
若使用专用子进程,在安装任务时添加 `-NetworkRoot <absolute-private-network-directory>`。该目录必须包含 `launch.json`,且只包含绝对路径的 `executable` 文件名、字符串数组 `arguments`,以及 1 至 3600 的整数 `restartDelaySeconds`。此模式要求存在 `runner-network.json`,其中 `serviceName` 应留空。为子进程分配独立的数据目录、监听绑定和可执行文件标识,与桌面软件管理的应用分离;仅使用不同端口无法防止其他管理器按可执行文件名终止进程。脚本不会修改桌面网络设置。实际服务商名称、路径、地址、端口和参数只保存在机器配置中,不得写入仓库文件或共享日志。将两个配置目录的访问权限限制为 runner 操作者。
|
||||
|
||||
安装时会启动依赖进行就绪检查,并在注册任务前停止它。runner 存活期间,守卫仅在自己拥有的网络进程退出后,按配置间隔重新启动它;启动连续失败时仍会重试,但不会重启 runner、重跑任务或重试签名。退出、重启和启动失败消息写入 `run-*.log`,依赖进程的输出会被丢弃。重启消息只表明进程已创建,不代表网络可用;请在 runner 日志中确认重新连接。进程仍存活但无响应时需要操作者排查。守卫独占锁定 `NetworkRoot`,回收残留子进程前核对 PID、启动时间和可执行文件,并在 runner 退出时停止子进程。不可恢复的守卫错误会保留活动 runner 直到其退出,并报告恢复已停止。重新安装时保留 `NetworkRoot`;仅拉取代码不会替换已安装的辅助脚本。
|
||||
|
||||
Windows CI 使用隔离的本地 fixture 运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network.ps1` 和 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-network-process.ps1`。在已配置的打包机器上,给第一条命令添加 `-CheckStartup`,可验证实际启动环境清理后的网络配置。这些测试不会使用签名硬件或外部网络端点。
|
||||
|
||||
<a id="signing-configuration"></a>
|
||||
|
||||
## 签名配置
|
||||
|
||||
Reference in new issue
Block a user