From dd483da485dab893456c10a4a211f480be71f9fe Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 31 Jul 2026 14:41:39 -0600 Subject: [PATCH] fix(cras-install): sonda sin .NET y registrar el LanguageMode del destino MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La sonda que decide si el instalador del artefacto soporta -UpdateInPlace usaba [regex]::IsMatch, que es una llamada estatica y por tanto de lo primero que bloquea Constrained Language Mode — el mismo motivo por el que fallo antes (Get-Command …).Parameters en ese servidor y no en desarrollo. Habria degradado a 'desconocido' en vez de responder: no rompe, pero tampoco sirve. Pasa a Select-String, un cmdlet puro sin resolucion de tipos. `-ErrorAction Stop` NO es opcional, y esto lo destapo probar la ruta de FALLO con PowerShell real, no las pruebas unitarias: Select-String emite un error NO TERMINANTE cuando no puede leer el archivo, asi que sin el, el catch no se dispara, -Quiet devuelve falso y un fallo de lectura se reporta como 'no' — exactamente el defecto que esta funcion existe para eliminar. Verificado contra PowerShell real los cuatro casos: instalador nuevo -> si, instalador del zip 1.1.2 publicado -> no, archivo inexistente -> desconocido, fallo de lectura -> desconocido. Se anade probeWindowsLanguageMode y se registra SIEMPRE en el paso precondiciones, con un aviso aparte cuando no es FullLanguage. Es el dato que faltaba para dejar de diagnosticar a ciegas: install.ps1 todavia usa cinco construcciones .NET que en modo restringido no funcionarian, y sin verlo en la bitacora cada fallo raro en Windows empieza con una ronda de suposiciones. La prueba nueva prohibe cualquier `[Tipo]::` en el script de la sonda. Se ha caido dos veces en lo mismo; que lo impida una prueba y no la memoria. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/server/cras-install.test.ts | 12 ++++--- src/lib/server/cras-install.ts | 56 ++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/lib/server/cras-install.test.ts b/src/lib/server/cras-install.test.ts index e787c8b..35916fd 100644 --- a/src/lib/server/cras-install.test.ts +++ b/src/lib/server/cras-install.test.ts @@ -542,7 +542,7 @@ describe('checkWindowsArtifactUpdateSupport', () => { describe('probeInstallerUpdateSupport', () => { const RUTA = 'C:\\Temp\\cras\\CloudRestoreAS\\install.ps1'; - it('lee el texto del script en vez de compilarlo', async () => { + it('lee el texto del script sin resolver un solo tipo .NET', async () => { const { sftp, commands } = fakeSftp(() => ({ code: 0, stdout: 'si' })); const r = await probeInstallerUpdateSupport(sftp as never, RUTA); expect(r.answer).toBe('si'); @@ -551,11 +551,15 @@ describe('probeInstallerUpdateSupport', () => { commands[0].split('-EncodedCommand ')[1] ?? '', 'base64' ).toString('utf16le'); - // Nada de reflexión: en Constrained Language Mode el acceso a .Parameters puede fallar - // mientras `& install.ps1` sigue funcionando, que es lo que se observó en el servidor. + // Nada de reflexión ni de .NET: en Constrained Language Mode se prohíbe resolver tipos y + // llamar métodos estáticos, mientras los cmdlets siguen funcionando. Esta prueba es la que + // impide volver a caer ahí — primero con (Get-Command …).Parameters, después con + // [regex]::IsMatch, las dos veces por el mismo motivo. expect(script).not.toContain('Get-Command'); expect(script).not.toContain('.Parameters'); - expect(script).toContain('Get-Content'); + expect(script).not.toContain('[regex]'); + expect(script).not.toMatch(/\[[A-Za-z][A-Za-z0-9.]*\]::/); + expect(script).toContain('Select-String'); // Anclado a la DECLARACIÓN, para que un comentario que nombre el parámetro no cuente. expect(script).toContain('switch'); }); diff --git a/src/lib/server/cras-install.ts b/src/lib/server/cras-install.ts index 5cfb342..acf79d1 100644 --- a/src/lib/server/cras-install.ts +++ b/src/lib/server/cras-install.ts @@ -1050,6 +1050,28 @@ export interface WindowsPrivilege { detail: string; } +/** + * Modo de lenguaje de PowerShell en el destino. + * + * Existe porque en `ConstrainedLanguage` —lo que imponen AppLocker o WDAC, habituales en servidores + * endurecidos— PowerShell prohíbe resolver tipos .NET y llamar a métodos estáticos, mientras los + * cmdlets y `& script.ps1` siguen funcionando. Eso convierte fallos en sitios sorprendentes: fue la + * hipótesis principal cuando `(Get-Command …).Parameters` respondía en una máquina de desarrollo y + * no en el servidor real, y `install.ps1` todavía usa varias construcciones que ahí no funcionarían + * (`[System.IO.Path]::GetFullPath`, `New-Object System.Text.UTF8Encoding`, `[TimeSpan]::Zero`…). + * + * Se registra en la bitácora del run SIEMPRE, cueste lo que cueste averiguarlo, para dejar de + * diagnosticar a ciegas: sin este dato cada fallo raro en Windows arranca con una ronda de + * suposiciones. Devuelve cadena vacía si ni siquiera esto se pudo consultar. + */ +export async function probeWindowsLanguageMode(sftp: SftpClient): Promise { + const probe = await execRemote( + sftp, + psEncoded('Write-Output $ExecutionContext.SessionState.LanguageMode') + ); + return probe.code === 0 ? probe.stdout.trim() : ''; +} + /** * Sonda de elevación en Windows. * @@ -1301,10 +1323,19 @@ export async function probeInstallerUpdateSupport( `$p = '${installerPath}'; ` + "if (-not (Test-Path -LiteralPath $p)) { Write-Output 'desconocido|no existe el archivo'; exit 0 }; " + 'try { ' + - '$t = Get-Content -LiteralPath $p -Raw -ErrorAction Stop; ' + - // El [regex]::IsMatch evita depender de $matches y del operador -match, que en - // modo restringido puede comportarse distinto. - 'if ([regex]::IsMatch($t, \'\\[switch\\]\\s*\\$UpdateInPlace\')) ' + + // Select-String y nada más: un cmdlet puro, sin resolución de tipos .NET. + // La versión anterior usaba [regex]::IsMatch, que es una llamada estática y por + // tanto de lo primero que bloquea Constrained Language Mode — el modo que se + // sospecha en el servidor donde falló `(Get-Command …).Parameters`. Habría + // degradado a 'desconocido' en vez de responder, que no rompe nada pero tampoco + // sirve de nada. + // `-ErrorAction Stop` NO es opcional: Select-String emite un error NO TERMINANTE + // cuando no puede leer el archivo, así que sin él el catch no se dispara, -Quiet + // devuelve falso y el fallo de lectura se reporta como 'no'. Es decir, exactamente + // el defecto que esta función existe para eliminar. Lo destapó probar la ruta de + // fallo con PowerShell real, no las pruebas unitarias. + "if (Select-String -LiteralPath $p -Pattern '\\[switch\\]\\s*\\$UpdateInPlace' " + + '-Quiet -ErrorAction Stop) ' + "{ Write-Output 'si' } else { Write-Output 'no' } } " + "catch { Write-Output ('desconocido|' + $_.Exception.Message) }" ) @@ -1607,12 +1638,27 @@ async function installWindows( if (needsElevation && privileged.elevation === 'token-filtrado') { await appendInstallStep(runId, 'aviso-elevacion', true, privileged.detail); } + // El modo de lenguaje se registra SIEMPRE, aunque todo vaya bien. Es barato y es el dato que + // faltaba: en ConstrainedLanguage varias construcciones de install.ps1 no funcionan, y sin + // verlo en la bitácora cada fallo raro en Windows empieza con una ronda de suposiciones. + const languageMode = await probeWindowsLanguageMode(sftp); await appendInstallStep( runId, 'precondiciones', true, - `${systemEvidence}, privilegios=${privileged.label}` + `${systemEvidence}, privilegios=${privileged.label}` + + `, PowerShell=${languageMode || 'modo no determinado'}` ); + if (languageMode && languageMode !== 'FullLanguage') { + await appendInstallStep( + runId, + 'aviso-modo-restringido', + true, + `PowerShell corre en ${languageMode}. Ahí se prohíbe resolver tipos .NET, y install.ps1 ` + + 'usa varias construcciones que dependen de eso, así que puede fallar en sitios ' + + 'inesperados aunque el artefacto sea correcto.' + ); + } if (request.mode === 'update') { await assertExistingInstall(sftp, 'windows', targetPath, reportedInstallPath);