From 0ac899f53163edf4a6243d31478dac314a109d30 Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 31 Jul 2026 10:28:05 -0600 Subject: [PATCH] fix(install): la actualizacion de Windows decia que funciono y no cambiaba nada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actualizar un servidor con el agente en una carpeta NO estandar terminaba en verde y lo dejaba con la version anterior. Todo el camino de Windows identificaba al agente por NOMBRE, mientras que lo unico que se actualiza se identifica por RUTA; en cuanto las dos no coincidian, nada fallaba y nada cambiaba. - Se alinea la tarea programada con el binario instalado. `Start-ScheduledTask` ejecuta la ruta registrada en su accion, no el -Prefix: si difieren, se copiaba el binario nuevo en un sitio y se arrancaba el viejo del otro. Ahora se reapunta conservando disparador, principal, ajustes y argumentos; si no se puede corregir, FALLA — arrancar a sabiendas el binario anterior es peor. - La confirmacion de arranque mira la RUTA del proceso. Un agente viejo que nunca se detuvo satisfacia igual de bien un `Get-Process -Name`. Si la ruta no es legible (un proceso de SYSTEM no la expone sin elevacion) se acepta por nombre y se avisa, en vez de revertir una actualizacion correcta por falta de informacion. - Corregido Merge-EnvFile con un config\.env de UNA linea: al asignar la salida de un `if`, PowerShell desenrolla un array de un elemento a escalar, asi que $lines.Count reventaba con Set-StrictMode y la siembra abortaba la instalacion. Nuevo scripts/emular-actualizacion-windows.ps1: monta un agente falso (un .exe real que se queda vivo), una instalacion en una carpeta y una tarea apuntando a otra, corre el instalador y dice si la actualizacion surtio efecto. Sin elevacion y sin tocar la instalacion real de la maquina. Es lo que destapo los dos defectos: contra el instalador anterior reproduce el sintoma exacto —codigo de salida 0 y "El agente esta corriendo con el binario nuevo" sobre un servidor intacto— y contra este confirma que ya surte efecto, sin tocar la tarea cuando ya estaba bien. Co-Authored-By: Claude Opus 5 (1M context) --- install.ps1 | 176 ++++++++++++++++++- scripts/emular-actualizacion-windows.ps1 | 213 +++++++++++++++++++++++ tests/test_release_metadata.py | 32 ++++ 3 files changed, 416 insertions(+), 5 deletions(-) create mode 100644 scripts/emular-actualizacion-windows.ps1 diff --git a/install.ps1 b/install.ps1 index 771d5ed..ec4bb23 100644 --- a/install.ps1 +++ b/install.ps1 @@ -94,6 +94,107 @@ function Get-AgentTask { Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } +function Get-NormalizedPath([string]$Path) { + <# + Normaliza para comparar rutas: la tarea puede guardarlas entrecomilladas, con otra + capitalización o con separador final. Comparar las cadenas en crudo daba falsos negativos. + #> + if (-not $Path) { return '' } + $clean = $Path.Trim().Trim('"') + try { return [System.IO.Path]::GetFullPath($clean).TrimEnd('\') } catch { return $clean.TrimEnd('\') } +} + +function Test-SamePath([string]$A, [string]$B) { + if (-not $A -or -not $B) { return $false } + # NTFS no distingue mayúsculas: comparar sensible a caso reportaría dos rutas distintas donde + # el SO ve una sola. + return (Get-NormalizedPath $A) -ieq (Get-NormalizedPath $B) +} + +function Get-AgentTaskExecute { + <# + Ruta del ejecutable que la tarea programada tiene registrada en su acción. + + Este es el dato que faltaba. Reemplazar el binario es una operación por RUTA, pero + arrancarlo con `Start-ScheduledTask` es una operación por NOMBRE. Si la tarea apunta a otra + carpeta se actualiza un binario y se arranca otro: el servidor se queda en la versión vieja + y la actualización parece haber funcionado. + + Se lee la propiedad por reflexión porque no todas las acciones de una tarea son de tipo + Exec (una ComHandler no tiene `Execute`), y con Set-StrictMode tocar una propiedad que no + existe es un error. + #> + $task = Get-AgentTask + if (-not $task) { return '' } + foreach ($action in @($task.Actions)) { + $prop = $action.PSObject.Properties['Execute'] + if ($prop -and $prop.Value) { return ([string]$prop.Value).Trim().Trim('"') } + } + return '' +} + +function Repair-AgentTaskPath { + <# + Reapunta la acción de la tarea al binario recién instalado, conservando el resto de su + definición (disparador, principal, ajustes) y los argumentos que tuviera: volver a + registrarla desde cero perdería cualquier ajuste que el operador le haya hecho. + #> + param($Task) + + $actions = @() + foreach ($action in @($Task.Actions)) { + $execProp = $action.PSObject.Properties['Execute'] + if (-not $execProp) { $actions += $action; continue } + + $argProp = $action.PSObject.Properties['Arguments'] + $arguments = if ($argProp -and $argProp.Value) { [string]$argProp.Value } else { '' } + $actions += if ($arguments) { + New-ScheduledTaskAction -Execute $dest -Argument $arguments -WorkingDirectory $Prefix + } else { + New-ScheduledTaskAction -Execute $dest -WorkingDirectory $Prefix + } + } + + try { + Set-ScheduledTask -TaskName $TaskName -Action $actions -ErrorAction Stop | Out-Null + Write-Ok "Tarea '$TaskName' reapuntada a $dest." + } catch { + throw ("La tarea '$TaskName' ejecuta un binario distinto del que se acaba de instalar y no " + + "se pudo corregir ($($_.Exception.Message)). Arrancarla levantaría la versión anterior " + + 'y la actualización quedaría sin efecto aparentando haber funcionado, que es peor que ' + + 'fallar aquí.') + } +} + +function Get-AgentState { + <# + Estado del agente en una sola consulta: si hay algo vivo, si alguno corre el binario de + ESTA instalación, y desde dónde corren los que no. + + `PathsReadable` separa "no corre desde aquí" de "no pude ver desde dónde corre": un proceso + de SYSTEM no expone `.Path` a una cuenta sin elevación, y tratar ese caso como "es de otra + instalación" revertiría actualizaciones correctas. + + Vive en un solo sitio a propósito. Esta lógica repetida en dos funciones es exactamente + cómo se colaron las divergencias que estamos arreglando. + #> + $procs = @(Get-AgentProcess) + $paths = @() + foreach ($proc in $procs) { + try { if ($proc.Path) { $paths += $proc.Path } } catch { } + } + $fromPrefix = $false + foreach ($path in $paths) { + if (Test-SamePath $path $dest) { $fromPrefix = $true; break } + } + return [pscustomobject]@{ + Running = ($procs.Count -gt 0) + PathsReadable = (($procs.Count -eq 0) -or ($paths.Count -gt 0)) + FromPrefix = $fromPrefix + Paths = $paths + } +} + function Test-RestoreInProgress { <# Una restauración en curso no se interrumpe. El agente no atiende señales de terminación, @@ -147,6 +248,9 @@ function Start-Agent { No se fija QT_QPA_PLATFORM: `--headless` hace que el propio binario elija el plugin 'offscreen' en cualquier plataforma. Es lo que permite que el agente corra como SYSTEM en la sesión 0, donde no hay escritorio interactivo al que asomar una ventana. + + Que la acción de la tarea apunte a `$dest` lo garantiza `Sync-AgentTaskPath`, que corre + justo después de copiar el binario. Aquí ya se puede arrancar sin volver a comprobarlo. #> if (Get-AgentTask) { Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue @@ -158,15 +262,59 @@ function Start-Agent { } function Wait-AgentAlive { + <# + Espera a que el agente esté vivo, y que sea el binario de ESTA instalación. + + La identidad importa: `Get-Process -Name` responde "hay un proceso con ese nombre", no + "corre el binario que instalé". Con la tarea apuntando a otra carpeta, el agente viejo + —que nunca se fue— satisfacía la comprobación por nombre y la actualización pasaba por + buena sin haber cambiado nada. + + Si la ruta del proceso no es legible NO se concluye que sea de otra instalación: un proceso + de SYSTEM no expone `.Path` a una cuenta sin elevación. Se acepta por nombre y se avisa, + porque revertir una actualización correcta por falta de información es peor. + #> param([int]$TimeoutSeconds = 60) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $foreign = @() while ((Get-Date) -lt $deadline) { - if (Get-AgentProcess) { return $true } + $state = Get-AgentState + if ($state.Running) { + if (-not $state.PathsReadable) { + Write-Warn ("Hay $ProcName corriendo pero no se pudo leer su ruta; se acepta por " + + 'nombre. La versión efectiva la confirma el panel con config\.version.') + return $true + } + if ($state.FromPrefix) { return $true } + $foreign = $state.Paths + } Start-Sleep -Seconds 2 } + # Se avisa una sola vez y al final: dentro del bucle serían treinta líneas iguales. + if ($foreign.Count -gt 0) { + Write-Warn ("Hay $ProcName corriendo desde $($foreign -join ', '), pero no desde $dest.") + } return $false } +function Sync-AgentTaskPath { + <# + Alinea la acción de la tarea programada con el binario recién instalado. + + Es la raíz del fallo silencioso: reemplazar el binario es una operación por RUTA, pero + `Start-ScheduledTask` es por NOMBRE y ejecuta la ruta que la tarea lleva registrada. En una + instalación fuera de la carpeta por omisión, eso significaba copiar el binario nuevo en un + sitio y arrancar el viejo desde otro: el run terminaba en verde y el servidor seguía igual. + #> + $registered = Get-AgentTaskExecute + if (-not $registered) { return } + if (Test-SamePath $registered $dest) { return } + + Write-Warn "La tarea '$TaskName' ejecutaba $registered, no $dest." + Repair-AgentTaskPath -Task (Get-AgentTask) +} + function Copy-Binary { <# Copia con reintentos. Tras terminar un proceso, el antivirus y el propio SO pueden @@ -269,6 +417,11 @@ if (Test-Path -LiteralPath $dest) { Copy-Binary -From $src -To $dest Write-Ok "Binario instalado en $dest" +# El binario nuevo ya está en su sitio; falta que el arranque automático apunte AHÍ. Va aquí y no +# dentro de Start-Agent para que se corrija aunque en este momento no haya que arrancar nada: una +# tarea desalineada seguiría levantando la versión vieja en el próximo reinicio del servidor. +Sync-AgentTaskPath + # --- Bootstrap de config\ ----------------------------------------------------------- # El propio binario crea config\, .env y las carpetas de trabajo al arrancar. Se corre # una vez acotado por timeout para que el operador ya pueda editar config\.env. @@ -304,9 +457,13 @@ if (Test-Path -LiteralPath $envPath) { function Merge-EnvFile { param([string]$Target, [hashtable]$Values) - $lines = if (Test-Path -LiteralPath $Target) { - @(Get-Content -LiteralPath $Target -Encoding UTF8) - } else { @() } + # El @() envuelve el `if` COMPLETO, no solo el Get-Content. Al asignar la salida de un `if`, + # PowerShell desenrolla un array de un solo elemento a escalar: con un config\.env de UNA línea + # $lines quedaba como String y `$lines.Count` reventaba con Set-StrictMode. Lo destapó la + # emulación de una actualización sobre una instalación con .env mínimo. + $lines = @( + if (Test-Path -LiteralPath $Target) { Get-Content -LiteralPath $Target -Encoding UTF8 } + ) foreach ($key in $Values.Keys) { $line = "$key=$($Values[$key])" @@ -417,7 +574,16 @@ switch ($Mode) { $shouldBeRunning = ($Mode -eq 'service') -or $wasRunning -or $taskExisted if ($shouldBeRunning) { - if (-not (Get-AgentProcess)) { + # La condición mira la RUTA, no solo el nombre. Mientras se copiaba, la tarea pudo relanzar el + # agente desde su ruta anterior (trae RestartCount): con una comprobación por nombre eso pasaba + # por "ya está corriendo", no se arrancaba el nuevo, y la actualización quedaba sin efecto. + $state = Get-AgentState + if (-not ($state.Running -and ($state.FromPrefix -or -not $state.PathsReadable))) { + if ($state.Running) { + Write-Warn ("Hay $ProcName corriendo desde $($state.Paths -join ', '); se termina para " + + 'que no compita con el binario recién instalado.') + Stop-Agent -TimeoutSeconds 15 | Out-Null + } $how = Start-Agent Write-Step "Rearrancando el agente ($how)" } diff --git a/scripts/emular-actualizacion-windows.ps1 b/scripts/emular-actualizacion-windows.ps1 new file mode 100644 index 0000000..24bc014 --- /dev/null +++ b/scripts/emular-actualizacion-windows.ps1 @@ -0,0 +1,213 @@ +<# + Emulación end-to-end en Windows de la actualización que "completa y no hace nada". + + Reproduce el caso del servidor real: el agente vive en la carpeta que el panel tiene declarada, + pero la tarea programada ejecuta el binario de OTRA carpeta. Reemplazar el binario de la primera + y arrancar la tarea levanta el de la segunda. + + Qué es REAL aquí: el agente es un .exe compilado que se queda vivo, los archivos y carpetas, la + copia del binario, la identidad del proceso por ruta, el sello config\.version y la reversión. + El "viejo" NO escribe el sello (igual que 1.1.0) y el "nuevo" sí (igual que 1.1.3) — esa + diferencia es la que hacía que la verificación del panel tolerara el sello vacío. + + Qué está simulado y por qué: los cmdlets *-ScheduledTask. Registrar una tarea exige elevación, y + la gracia de esta emulación es poder correrla sin ella. Los sustitutos respetan la semántica que + provoca el fallo: Start-ScheduledTask lanza LA RUTA REGISTRADA EN LA TAREA, no la que se acaba de + instalar. Como install.ps1 se invoca con &, sus llamadas resuelven a estas funciones del ámbito + padre. Si quieres la prueba con una tarea de verdad, corre esto en PowerShell elevado tras + quitar los sustitutos. + + Uso: + .\emular-actualizacion-windows.ps1 -Installer ..\install.ps1 + .\emular-actualizacion-windows.ps1 -Installer ..\install.ps1 -Escenario alineada + + Encontró dos defectos que las pruebas de unidad no veían: el rearranque del binario equivocado, + y que Merge-EnvFile reventaba con un config\.env de una sola línea. +#> +param( + # install.ps1 a poner a prueba. Para comparar contra una versión anterior: + # git show :install.ps1 > install-antes.ps1 + [Parameter(Mandatory = $true)][string]$Installer, + [string]$Etiqueta = 'instalador', + # 'desalineada' reproduce el servidor que falla; 'alineada' es el caso normal, para comprobar + # que el arreglo no lo rompe (una tarea que ya apunta bien no debe tocarse). + [ValidateSet('desalineada', 'alineada')][string]$Escenario = 'desalineada', + [string]$Carpeta = 'C:\Users\Public\cras-e2e' +) + +$ErrorActionPreference = 'Stop' +$base = $Carpeta +$declarada = Join-Path $base 'declarada' # donde el panel cree que está (y está) +$otra = Join-Path $base 'otra-carpeta' # a donde apunta la tarea +$pkg = Join-Path $base 'pkg' + +function Nuevo-AgenteFalso { + param([string]$Destino, [string]$Version, [switch]$EscribeSello) + + # Here-strings LITERALES: el C# lleva comillas dobles y en una cadena interpolada de PowerShell + # habría que escaparlas con backtick, no con barra invertida. + $plantilla = @' +using System; using System.IO; using System.Threading; +class P { static void Main() { + string d = AppDomain.CurrentDomain.BaseDirectory; +__CUERPO__ + Thread.Sleep(Timeout.Infinite); } } +'@ + $cuerpo = if ($EscribeSello) { + @' + Directory.CreateDirectory(Path.Combine(d, "config")); + File.WriteAllText(Path.Combine(d, "config", ".version"), "__VER__"); +'@ + } else { + ' // 1.1.0 no escribia el sello de version' + } + $src = $plantilla.Replace('__CUERPO__', $cuerpo).Replace('__VER__', $Version) + New-Item -ItemType Directory -Path (Split-Path -Parent $Destino) -Force | Out-Null + Add-Type -TypeDefinition $src -OutputAssembly $Destino -OutputType ConsoleApplication +} + +function Matar-Agentes { + Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + Start-Sleep -Milliseconds 800 +} + +# ------------------------------------------------ tarea programada simulada (estado + semántica) +$global:tareaExiste = $true +$global:tareaExecute = if ($Escenario -eq 'alineada') { + Join-Path $declarada 'CloudRestoreAS.exe' +} else { + Join-Path $otra 'CloudRestoreAS.exe' +} +$global:tareaArgs = '--start-engine --headless' +$global:reapuntadaVeces = 0 + +function Get-ScheduledTask { + [CmdletBinding()] param([string]$TaskName) + if (-not $global:tareaExiste) { return $null } + [pscustomobject]@{ + TaskName = 'CloudRestoreAS' + State = 'Running' + Actions = @([pscustomobject]@{ + Execute = $global:tareaExecute + Arguments = $global:tareaArgs + }) + } +} + +function Set-ScheduledTask { + [CmdletBinding()] param([string]$TaskName, $Action) + $global:tareaExecute = $Action[0].Execute + $global:tareaArgs = $Action[0].Arguments + $global:reapuntadaVeces++ + return $true +} + +function New-ScheduledTaskAction { + [CmdletBinding()] param([string]$Execute, [string]$Argument, [string]$WorkingDirectory) + [pscustomobject]@{ Execute = $Execute; Arguments = $Argument } +} + +function Start-ScheduledTask { + # AQUÍ está el corazón del fallo: se lanza la ruta REGISTRADA EN LA TAREA. + [CmdletBinding()] param([string]$TaskName) + Start-Process -FilePath $global:tareaExecute ` + -WorkingDirectory (Split-Path -Parent $global:tareaExecute) -WindowStyle Hidden | Out-Null +} + +function Stop-ScheduledTask { + [CmdletBinding()] param([string]$TaskName) + Matar-Agentes +} + +# ---------------------------------------------------------------- montaje del escenario +Matar-Agentes +Remove-Item $base -Recurse -Force -ErrorAction SilentlyContinue +Write-Host "=== Montando escenario: $Etiqueta ===" -ForegroundColor Cyan + +Nuevo-AgenteFalso -Destino (Join-Path $declarada 'CloudRestoreAS.exe') -Version '1.1.0' +New-Item -ItemType Directory -Path (Join-Path $declarada 'config') -Force | Out-Null +# Multilínea, como el que escribe el bootstrap del agente. Con UNA sola línea se destapa un bug +# aparte de Merge-EnvFile (el array se desenrolla a escalar y $lines.Count revienta con StrictMode), +# que el instalador viejo todavía tiene y aquí taparía el fallo que se quiere medir. +Set-Content -Path (Join-Path $declarada 'config\.env') -Value @( + '# Configuracion de CloudRestoreAS', + 'CLOUDRESTORE_PANEL_API_URL=http://ejemplo.local:3000', + 'CLOUDRESTORE_AUTO_START=true' +) + +Nuevo-AgenteFalso -Destino (Join-Path $otra 'CloudRestoreAS.exe') -Version '1.1.0' + +New-Item -ItemType Directory -Path $pkg -Force | Out-Null +Copy-Item $Installer (Join-Path $pkg 'install.ps1') -Force +Nuevo-AgenteFalso -Destino (Join-Path $pkg 'dist\CloudRestoreAS.exe') -Version '1.1.3' -EscribeSello + +$panelEnv = Join-Path $base 'panel.env' +Set-Content -Path $panelEnv -Value @( + 'CLOUDRESTORE_PANEL_API_URL=http://ejemplo.local:3000', + 'CLOUDRESTORE_PANEL_API_TOKEN=token-de-prueba-no-real', + 'CLOUDRESTORE_PANEL_INSTANCE_KEY=servidor-de-prueba' +) + +# El agente viejo corriendo, lanzado por la tarea desde su carpeta. +Start-ScheduledTask -TaskName 'CloudRestoreAS' +Start-Sleep -Seconds 2 + +Write-Host " instalacion declarada : $declarada" +Write-Host " tarea ejecuta : $global:tareaExecute" +$antes = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | + ForEach-Object { $_.Path } | Select-Object -Unique) +Write-Host " proceso corriendo de : $($antes -join ', ')" + +# ---------------------------------------------------------------- la actualización +Write-Host '' +Write-Host '=== Actualizacion, tal como la lanza el panel ===' -ForegroundColor Cyan +$codigo = 0 +try { + & (Join-Path $pkg 'install.ps1') -UpdateInPlace -Prefix $declarada -PanelEnvFile $panelEnv + $codigo = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE } +} catch { + $codigo = 1 + Write-Host " [el instalador lanzo] $($_.Exception.Message)" -ForegroundColor Yellow +} +Write-Host "--- codigo de salida: $codigo" + +# ---------------------------------------------------------------- veredicto +Start-Sleep -Seconds 3 +Write-Host '' +Write-Host '=== RESULTADO ===' -ForegroundColor Cyan +$esperado = Join-Path $declarada 'CloudRestoreAS.exe' +$procAhora = @(Get-Process -Name 'CloudRestoreAS' -ErrorAction SilentlyContinue | + ForEach-Object { try { $_.Path } catch { '(ilegible)' } } | Select-Object -Unique) +$selloPath = Join-Path $declarada 'config\.version' +$sello = if (Test-Path $selloPath) { (Get-Content $selloPath -Raw).Trim() } else { '(no existe)' } + +Write-Host " tarea ejecuta : $global:tareaExecute (reapuntada $global:reapuntadaVeces vez/veces)" +Write-Host " proceso corriendo de : $($procAhora -join ', ')" +Write-Host " config\.version : $sello" + +$fallos = @() +if ($global:tareaExecute -ne $esperado) { $fallos += "la tarea sigue apuntando a $global:tareaExecute" } +if ($procAhora -notcontains $esperado) { $fallos += 'el proceso vivo no es el de la instalacion declarada' } +if ($sello -ne '1.1.3') { $fallos += "el sello dice '$sello', se esperaba 1.1.3" } +# Una tarea que ya apuntaba bien no debe reescribirse: hacerlo sin necesidad arriesga perder +# ajustes que el operador le haya hecho. +if ($Escenario -eq 'alineada' -and $global:reapuntadaVeces -ne 0) { + $fallos += "se reescribio una tarea que ya estaba bien ($global:reapuntadaVeces vez/veces)" +} + +Write-Host '' +if ($fallos.Count -eq 0 -and $codigo -eq 0) { + Write-Host 'VEREDICTO: la actualizacion SI surtio efecto' -ForegroundColor Green +} elseif ($fallos.Count -eq 0) { + Write-Host "VEREDICTO: surtio efecto pero el instalador salio con $codigo" -ForegroundColor Yellow +} else { + Write-Host 'VEREDICTO: la actualizacion NO surtio efecto' -ForegroundColor Red + $fallos | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } + if ($codigo -eq 0) { + Write-Host ' *** y el instalador dijo que TODO BIEN (codigo 0) ***' -ForegroundColor Red + } +} + +Matar-Agentes +Remove-Item $base -Recurse -Force -ErrorAction SilentlyContinue diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index 15f87f5..813c744 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -138,6 +138,38 @@ def test_install_ps1_respalda_y_revierte(): assert "Remove-Item -LiteralPath $backup" in ps1 +def test_install_ps1_alinea_la_tarea_con_el_binario_instalado(): + """ + El fallo silencioso: reemplazar el binario es una operación por RUTA, pero + `Start-ScheduledTask` es por NOMBRE y ejecuta la ruta que la tarea lleva registrada. Con una + instalación fuera de la carpeta por omisión, eso copiaba el binario nuevo en un sitio y + arrancaba el viejo desde otro: el run terminaba en verde y el servidor seguía igual. + + Se comprueba con `scripts/emular-actualizacion-windows.ps1`, que reproduce el escenario + completo; esto solo protege de que las piezas desaparezcan en un refactor. + """ + ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") + assert "Sync-AgentTaskPath" in ps1 + assert "Get-AgentTaskExecute" in ps1 + # La comparación tiene que normalizar: la tarea guarda la ruta entrecomillada y NTFS no + # distingue mayúsculas, así que comparar las cadenas en crudo da falsos negativos. + assert "Test-SamePath" in ps1 + # Y la confirmación de arranque tiene que mirar la RUTA del proceso, no solo su nombre. + assert "FromPrefix" in ps1 + + +def test_merge_env_file_no_se_desenrolla_con_una_sola_linea(): + """ + `$lines = if (...) { @(Get-Content ...) } else { @() }` desenrolla un array de UN elemento a + escalar al asignarlo, así que con un config\\.env de una sola línea `$lines.Count` reventaba + bajo Set-StrictMode y la siembra de credenciales abortaba la instalación. El `@()` tiene que + envolver el `if` COMPLETO. + """ + ps1 = (ROOT / "install.ps1").read_text(encoding="utf-8") + assert "$lines = @(\n" in ps1, "el @() debe envolver el if completo, no solo el Get-Content" + assert "$lines = if (" not in ps1 + + def test_el_arranque_automatico_de_windows_pide_headless(): """ La tarea ONSTART corre como SYSTEM, en la sesión 0, donde no hay escritorio interactivo. Es