Compare commits

7 Commits

Author SHA1 Message Date
d332b2d11a fix(cras-install): no confundir "no pude averiguarlo" con "no lo soporta"
El panel rechazaba actualizar con 1.1.4 diciendo que su install.ps1 no declara
-UpdateInPlace. Era falso: el zip trae UpdateInPlace 8 veces y Sync-AgentTaskPath
3. El bloqueo lo causaba la sonda.

    const supportsInPlace = supportsProbe.stdout.trim() === 'si';

Cualquier fallo —una excepcion, un codigo de salida distinto de cero, stdout
vacio— colapsaba a false. Mientras eso solo elegia entre -UpdateInPlace y
-Service, un falso negativo degradaba la instalacion; al convertirlo en un 409
duro paso a ser un candado permanente sobre un artefacto correcto.

Por que fallaba en ese servidor y no en la maquina donde se probo no se sabe con
certeza. El candidato mas probable es Constrained Language Mode (AppLocker/WDAC
son habituales en servidores endurecidos): restringe el acceso a propiedades de
objetos .NET como .Parameters mientras `& install.ps1` sigue funcionando, que es
justo lo que se observaba. Pero el arreglo no es adivinarlo:

- probeInstallerUpdateSupport lee el TEXTO del script y busca la declaracion
  `[switch]$UpdateInPlace` con [regex]::IsMatch. No compila nada, no toca
  reflexion y ninguna politica lo bloquea. Anclado a la declaracion y no a una
  mencion suelta, para que un comentario no de un falso positivo.
- Tres estados en vez de dos. Solo un "no" CONFIRMADO bloquea; "desconocido"
  sigue adelante con el comportamiento anterior y deja asentado POR QUE no se
  pudo determinar. Un fallo de diagnostico no puede impedir el trabajo.
- El estado se decide tambien con el codigo de salida y stderr, no solo con
  stdout: un execRemote que falla deja stdout vacio y eso se leia como respuesta.

Auditado el mismo patron en las otras sondas de Windows, porque dos gobiernan
fallos duros: probeWindowsAgentProcess (502 "no esta corriendo"),
probeWindowsElevation (409 por privilegios) y probeWindowsTask leian "el comando
no respondio" como "la respuesta es no". Ahora todas distinguen los dos casos.

Verificado con PowerShell real, y esta vez tambien las rutas de FALLO —que es lo
que falto la vez pasada y por lo que esto llego a produccion—: instalador nuevo
-> si, instalador del zip 1.1.2 publicado -> no, archivo inexistente ->
desconocido, y fallo de lectura -> desconocido.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:12:35 -06:00
1efcdc6b24 Merge branch 'development' into fix/windows-actualizacion-silenciosa
La PR #25 aplasto esta rama hasta c7f5bf5 en 7613842, con otro hash, asi que git
vio dos historias para el mismo contenido y marco conflicto en cras-install.ts y
cras-install.test.ts.

Comprobado antes de resolver: la version de development de los cinco archivos que
toco la PR es IDENTICA byte a byte a la de c7f5bf5, o sea que se mezclo sin
cambios de revision. Esta rama tiene encima 67c6bab, que anade el rechazo de
artefactos anteriores a los arreglos, asi que resolver a su favor no pierde nada.

Verificado despues del merge: 350 pruebas, typecheck limpio, y siguen presentes
checkWindowsArtifactUpdateSupport, la lectura del crash log en el perfil de SYSTEM
y el registro del cambio de cuenta de la tarea.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:48:18 -06:00
67c6babc7f fix(cras-install): negarse a actualizar Windows con un artefacto anterior a los arreglos
El 1.1.3 publicado en Gitea se construyo antes de los arreglos, y el panel lo
desplego igual. Ese instalador detiene la tarea y mata los procesos, corre el
bootstrap acotado a 20s —insuficiente para desempacar un onefile de ~250 MB con
el antivirus escaneando, asi que muere antes de escribir config\.version—,
re-registra la tarea como SYSTEM y la arranca sin comprobar que volviera. Y el
binario del mismo paquete lleva el runner.py que ignora --headless en Windows,
asi que como SYSTEM en la sesion 0 Qt no puede crear su plataforma y el agente
muere. El servidor se quedo sin agente viejo y sin agente nuevo.

La sonda que lo detecta ya existia, pero solo lo asentaba como paso informativo y
seguia adelante — y seguir adelante es lo que rompe el servidor. Ahora
checkWindowsArtifactUpdateSupport lanza 409 al ACTUALIZAR, nombrando la version y
la consecuencia; en instalacion limpia solo avisa, porque ahi no hay agente que
perder. Es una funcion aparte y no una comprobacion enterrada en installWindows:
es una politica con consecuencias, merece nombre y prueba propia.

Ademas, dos cosas para que el proximo fallo se explique solo:

- El crash log se busca en las CINCO ubicaciones posibles, no solo junto al
  ejecutable. runner.py tiene tres candidatos y los dos ultimos, para un proceso
  que corre como SYSTEM, caen bajo C:\Windows\System32\config\systemprofile —
  precisamente donde estaba la evidencia de este fallo, y por eso el error salio
  sin crash log. El mensaje dice ademas de cual se leyo.
- Se sondea la cuenta de la tarea despues de instalar y se asienta el cambio. El
  salto Administrator -> SYSTEM es lo que rompio este servidor y ocurria en
  silencio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:42:58 -06:00
c7f5bf53a1 Merge branch 'development' into fix/windows-actualizacion-silenciosa
La PR #24 aplasto el commit 9a4124d de esta rama en a895044, con otro hash, asi
que git vio dos historias para el mismo contenido y marco conflicto en
cras-install.ts, cras-verify.ts y cras-install.test.ts.

Comprobado antes de resolver: la version de development de los tres archivos es
IDENTICA byte a byte a la de 9a4124d, o sea que la PR se mezclo sin cambios de
revision. Los dos commits posteriores de esta rama (3572aa9 y 9b05361) son
refinamientos encima de ese mismo contenido, asi que resolver a favor de esta
rama no pierde nada.

Verificado despues del merge: 346 pruebas, typecheck limpio, y siguen presentes
alignWindowsTask, restartWindowsAgent, readWindowsCrashLog y la comparacion de
rutas compartida entre el instalador y Verificar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:15:31 -06:00
9b05361a00 refactor(cras-install): una sola comparacion de rutas de Windows, y hacer visible el destino
La pregunta "son la misma ruta?" estaba resuelta tres veces con tres criterios:
normalizeWindowsPath recortaba comillas multiples, probeWindowsAgentProcess una
sola, y cras-verify ninguna. Coincidian en los casos reales, pero este modulo ya
lleva escrito lo que cuesta esa duplicacion — probeLinuxElevation e inspectLinux
tuvieron copias paralelas y las dos pantallas acabaron diciendo cosas distintas
del mismo servidor. Ahora es sameWindowsPath() + windowsAgentExe(), usadas en los
tres sitios.

Lo que NO se hace, y queda documentado en el codigo: comparar por prefijo.
C:\Aduanasoft\CloudRestoreAS es prefijo de cadena de
C:\Aduanasoft\CloudRestoreAS-win, y esa combinacion existe en produccion.

Pruebas del par peligroso en las dos direcciones. Comprobado que MUERDEN:
sustituyendo la igualdad por startsWith, falla la comparacion a nivel de carpeta.
Las de rutas completas de .exe no fallan, y eso tambien es informacion — son
estructuralmente inmunes porque el caracter que difiere llega antes del final, asi
que el riesgo vive solo en la comparacion de directorios.

Y el formulario dice a que carpeta va a instalar. Antes la ruta solo aparecia
dentro del aviso de "instalar limpio", que se pinta unicamente si el panel ya
conoce la version instalada: en un servidor con ruta personalizada y version
desconocida el operador no la veia hasta que el run fallaba. Se distingue "ruta
registrada" de "por omision" — no si la capturo el agente o una persona, porque
ambas viven en la misma columna y el panel no puede saberlo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:10:07 -06:00
3572aa974b fix(cras-install): realinear la tarea de Windows desde el panel, y decir por que fallo
La actualizacion ya fallaba ruidosamente en vez de mentir, pero el arreglo no
podia llegar al servidor: el install.ps1 que se ejecuta viaja DENTRO del zip, y
el artefacto 1.1.3 se construyo antes de que el instalador aprendiera a
realinear la tarea. Verificado sobre el zip: trae -UpdateInPlace y cero
Sync-AgentTaskPath. Un instalador ya publicado no se arregla hacia atras.

- alignWindowsTask realinea la accion de la tarea por SSH tras instalar el
  binario, conservando disparador, principal, ajustes y argumentos, y asienta la
  ruta ANTERIOR y la nueva para que el cambio sea reversible si la ruta declarada
  estuviera mal. Si no se puede corregir, aborta con 409 nombrando ambas: seguir
  significaria arrancar a sabiendas el binario viejo. Funciona con cualquier
  artefacto ya distribuido.
- restartWindowsAgent rearranca y espera a que corra el binario del prefijo, no
  cualquier proceso con ese nombre. Vive en cras-install y no en
  cras-agent-control porque este es el modulo de abajo; al reves habria un ciclo.

Y el diagnostico deja de ser una tarea para el operador. El error de sello
ausente decia "revisa a que binario apunta el arranque automatico" cuando el
panel YA lo sabe, y la bitacora del run donde si estaba no la encontraba nadie
("no se donde verlo"). Ahora se sondea tarea y proceso ANTES de evaluar el sello
y el mensaje nombra a que apunta la tarea, desde donde corre el proceso, y la
cola de CloudRestoreAS-crash.log — que runner.py escribe precisamente para esto
y que nunca se leia. El banner de error apunta al run por numero.

startOnWindows deja de esperar por nombre y usa la misma sonda de ruta, para que
Verificar no pueda contradecir al instalador.

PowerShell generado validado con PowerShell real: los seis scripts parsean, y el
de realineacion ejercitado contra una tarea simulada reapunta la accion Exec,
conserva los argumentos y respeta las acciones que no son Exec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:57:16 -06:00
9a4124d636 fix(cras-install): dejar de dar por buena una actualizacion que no surtio efecto
El run de Windows terminaba en verde sobre un servidor que seguia con la version
anterior. La verificacion no podia detectarlo:

- El sello config\.version ausente se toleraba SIEMPRE. Los agentes anteriores a
  1.1.1 no lo escribian, asi que al actualizar uno de esos `deployed` llegaba
  vacio y no se comprobaba ninguna version — justo la combinacion que importa.
  Ahora, en una ACTUALIZACION, un sello ausente es un fallo: el binario nuevo lo
  escribe en ensure_runtime_layout(), asi que su ausencia significa que lo que
  corre no es el que se instalo. La tolerancia se queda solo en instalacion nueva.
- `Get-Process -Name` responde "hay un proceso con ese nombre", no "corre el
  binario que instale". Se compara la RUTA del ejecutable; si no es legible no se
  concluye que sea ajeno, porque un proceso de SYSTEM no la expone sin elevacion.
- Se interroga la tarea programada a fondo: su accion dice DONDE arranca el
  agente de verdad y se asienta en la bitacora (el dato que habria explicado esto
  en diez segundos), y cuando el agente no reporta su ruta —nada anterior a 1.1.1
  lo hace— se usa la de la tarea en vez del default.

Y dos correcciones de lo anterior:

- needsElevation exigia admin en cuanto la tarea EXISTIA, bloqueando de entrada
  toda actualizacion sobre un servidor ya instalado. Ahora depende de que la
  tarea corra como SYSTEM, que es lo que de verdad obliga a elevar; y el token
  filtrado por UAC deja de rechazarse por adelantado: se intenta y se reporta lo
  que responda el servidor.
- -UpdateInPlace se pasaba siempre, pero solo existe desde 1.1.3 y install.ps1
  usa [CmdletBinding()], asi que con un artefacto anterior fallaba con
  NamedParameterNotFound sin ejecutar una linea. Se le pregunta a PowerShell por
  el param() del propio artefacto en vez de mantener una tabla de versiones.

Verificar comparte las dos sondas nuevas para que no pueda contradecir al
instalador, y gana un chequeo de "el arranque apunta a la instalacion".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:39 -06:00
2 changed files with 388 additions and 34 deletions

View File

@@ -29,7 +29,9 @@ import {
probeInPlaceUpdate,
probeLinuxElevation,
alignWindowsTask,
checkWindowsArtifactUpdateSupport,
normalizeWindowsPath,
probeInstallerUpdateSupport,
probeWindowsAgentProcess,
probeWindowsElevation,
probeWindowsTask,
@@ -471,6 +473,124 @@ describe('comparación de rutas de Windows', () => {
});
});
/**
* Artefactos anteriores a los arreglos de actualización.
*
* El caso real: se publicó un 1.1.3 construido antes de los arreglos, y el panel lo desplegó. Ese
* instalador mata el agente, cambia la tarea a SYSTEM y no comprueba que vuelva; y su binario ignora
* `--headless` en Windows, así que como SYSTEM en la sesión 0 no levanta. El servidor se quedó sin
* agente viejo y sin agente nuevo.
*/
describe('checkWindowsArtifactUpdateSupport', () => {
const SI = { answer: 'si', detail: '' } as const;
const NO = { answer: 'no', detail: '' } as const;
const NOSE = { answer: 'desconocido', detail: 'Get-Content: acceso denegado' } as const;
it('ACTUALIZAR con un instalador antiguo se rechaza en vez de romper el servidor', () => {
try {
checkWindowsArtifactUpdateSupport('update', NO, '1.1.3', 'Principal');
throw new Error('se esperaba que lanzara');
} catch (e) {
const err = e as InstallError;
expect(err).toBeInstanceOf(InstallError);
// 409 y no 502: es un conflicto de estado —este artefacto no sirve para esto—, no una
// avería del servidor destino.
expect(err.status).toBe(409);
expect(err.message).toContain('1.1.3');
expect(err.message).toContain('Principal');
// El mensaje tiene que decir la CONSECUENCIA, no solo que falta un parámetro.
expect(err.message).toMatch(/SIN agente/);
}
});
it('INSTALAR limpio con el mismo artefacto solo avisa', () => {
// Ahí no hay agente en marcha que perder, así que negarse solo estorbaría.
const aviso = checkWindowsArtifactUpdateSupport('install', NO, '1.1.3', 'Principal');
expect(aviso).toContain('1.1.3');
expect(aviso).toMatch(/Se instala igual/);
});
it('un artefacto con los arreglos no dice nada, en ninguno de los dos modos', () => {
expect(checkWindowsArtifactUpdateSupport('update', SI, '1.1.4', 'Principal')).toBe('');
expect(checkWindowsArtifactUpdateSupport('install', SI, '1.1.4', 'Principal')).toBe('');
});
it('NO SABER no bloquea: es la prueba que faltaba y la que dejó 1.1.4 sin poder instalarse', () => {
// La sonda anterior colapsaba cualquier fallo en "no lo soporta", y sobre esa lectura se
// decidía un 409. Con un artefacto correcto, eso era un candado permanente.
const aviso = checkWindowsArtifactUpdateSupport('update', NOSE, '1.1.4', 'Principal');
expect(aviso).toMatch(/No se pudo determinar/);
// Y dice POR QUÉ no se pudo, que es lo que evita adivinar la próxima vez.
expect(aviso).toContain('acceso denegado');
});
it('tampoco bloquea al instalar cuando no se pudo determinar', () => {
expect(checkWindowsArtifactUpdateSupport('install', NOSE, '1.1.4', 'Principal')).toMatch(
/No se pudo determinar/
);
});
});
/**
* La sonda que lee el instalador del artefacto.
*
* Lo que se protege aquí es la distinción entre "no lo declara" y "no pude leerlo". La versión
* anterior preguntaba por `(Get-Command …).Parameters`, funcionaba en una máquina de desarrollo y
* fallaba en el servidor real; como cualquier fallo se leía como "no", el panel acabó rechazando un
* artefacto correcto.
*/
describe('probeInstallerUpdateSupport', () => {
const RUTA = 'C:\\Temp\\cras\\CloudRestoreAS\\install.ps1';
it('lee el texto del script en vez de compilarlo', async () => {
const { sftp, commands } = fakeSftp(() => ({ code: 0, stdout: 'si' }));
const r = await probeInstallerUpdateSupport(sftp as never, RUTA);
expect(r.answer).toBe('si');
const script = Buffer.from(
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.
expect(script).not.toContain('Get-Command');
expect(script).not.toContain('.Parameters');
expect(script).toContain('Get-Content');
// Anclado a la DECLARACIÓN, para que un comentario que nombre el parámetro no cuente.
expect(script).toContain('switch');
});
it('un "no" limpio sigue siendo un no', async () => {
const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'no' }));
expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('no');
});
it('código de salida distinto de cero es DESCONOCIDO, nunca "no"', async () => {
const { sftp } = fakeSftp(() => ({ code: 1, stdout: '', stderr: 'AmsiScanBuffer bloqueó' }));
const r = await probeInstallerUpdateSupport(sftp as never, RUTA);
expect(r.answer).toBe('desconocido');
expect(r.detail).toContain('AmsiScanBuffer');
});
it('stdout vacío con código 0 también es DESCONOCIDO', async () => {
// Es la forma exacta en que fallaba la sonda vieja: sin salida, y leído como respuesta.
const { sftp } = fakeSftp(() => ({ code: 0, stdout: ' ' }));
expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido');
});
it('el archivo que no existe se distingue de un instalador antiguo', async () => {
const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'desconocido|no existe el archivo' }));
const r = await probeInstallerUpdateSupport(sftp as never, RUTA);
expect(r.answer).toBe('desconocido');
expect(r.detail).toBe('no existe el archivo');
});
it('una salida inesperada no se interpreta como respuesta', async () => {
const { sftp } = fakeSftp(() => ({ code: 0, stdout: 'WARNING: algo raro' }));
expect((await probeInstallerUpdateSupport(sftp as never, RUTA)).answer).toBe('desconocido');
});
});
describe('sondas de ruta en Windows', () => {
const DEFECTO = 'C:\\Aduanasoft\\CloudRestoreAS';
const PERSONALIZADA = 'C:\\Aduanasoft\\CloudRestoreAS-win';
@@ -499,12 +619,22 @@ describe('sondas de ruta en Windows', () => {
it('probeWindowsTask saca la ruta y la cuenta de la tarea', async () => {
const { sftp } = psFake('si|C:\\Otra\\CloudRestoreAS.exe|SYSTEM');
expect(await probeWindowsTask(sftp as never)).toEqual({
answered: true,
exists: true,
execute: 'C:\\Otra\\CloudRestoreAS.exe',
principal: 'SYSTEM'
});
});
it('una sonda que no responde no significa "no hay tarea"', async () => {
// Con `answered: false` el llamador sabe que no se sabe. Antes, un comando fallido se leía
// como "no hay tarea registrada" y de ahí salían decisiones sobre elevación y realineación.
const { sftp } = fakeSftp(() => ({ code: 1, stdout: '' }));
const r = await probeWindowsTask(sftp as never);
expect(r.answered).toBe(false);
expect(r.exists).toBe(false);
});
it('probeWindowsTask sin tarea registrada', async () => {
const { sftp } = psFake('no||');
const r = await probeWindowsTask(sftp as never);
@@ -646,11 +776,18 @@ describe('verifyWindowsDeployment', () => {
/** Ruta que la tarea tiene registrada en su acción. */
taskExecute?: string;
crashLog?: string;
/** De cuál de las rutas candidatas se leyó el crash log. */
crashFrom?: string;
}) {
return fakeSftp((command) => {
const encoded = command.split('-EncodedCommand ')[1] ?? '';
const script = Buffer.from(encoded, 'base64').toString('utf16le');
if (script.includes('crash.log')) return { code: 0, stdout: opts.crashLog ?? '' };
if (script.includes('crash.log')) {
// El lector responde `DE|<ruta>` y luego el contenido, para poder decir de dónde salió.
if (!opts.crashLog) return { code: 0, stdout: '' };
const de = opts.crashFrom ?? `${PREFIJO}\\CloudRestoreAS-crash.log`;
return { code: 0, stdout: `DE|${de}\n${opts.crashLog}` };
}
if (script.includes('config\\.version')) return { code: 0, stdout: opts.stamp };
if (script.includes('Get-Process')) {
if (!opts.running) return { code: 0, stdout: 'detenido' };
@@ -752,6 +889,33 @@ describe('verifyWindowsDeployment', () => {
expect(mensaje).toContain('ImportError');
});
it('el crash log se encuentra también en el perfil de SYSTEM', async () => {
// Es el caso que importa: un agente lanzado por una tarea que corre como SYSTEM no escribe
// junto al ejecutable, sino bajo C:\Windows\System32\config\systemprofile. Mirar solo la
// primera ubicación dejaba el arranque fallido sin explicación.
const desde =
'C:\\Windows\\System32\\config\\systemprofile\\AppData\\Local\\CloudRestoreAS\\crash.log';
const { sftp } = fakeWindows({
stamp: '',
taskState: 'Ready',
running: false,
crashLog: 'RuntimeError: could not load the Qt platform plugin "windows"',
crashFrom: desde
});
const mensaje = await mensajeDeFallo(
sftp as never,
1,
RELEASE,
'service',
PREFIJO,
false,
true
);
// Dice QUÉ pasó y DE DÓNDE lo sacó, para no tener que buscarlo a ciegas en el servidor.
expect(mensaje).toContain('Qt platform plugin');
expect(mensaje).toContain(desde);
});
it('sin crash log el mensaje no se rompe', async () => {
const { sftp } = fakeWindows({ stamp: '', taskState: 'Running', running: false });
const mensaje = await mensajeDeFallo(

View File

@@ -1041,7 +1041,7 @@ async function verifyLinuxDeployment(
// Windows
// ============================================================================
export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado';
export type WindowsElevation = 'admin' | 'token-filtrado' | 'limitado' | 'desconocido';
export interface WindowsPrivilege {
elevation: WindowsElevation;
@@ -1075,7 +1075,22 @@ export async function probeWindowsElevation(sftp: SftpClient): Promise<WindowsPr
)
);
switch (probe.stdout.trim()) {
const salida = probe.stdout.trim();
// Sin respuesta no se afirma que la cuenta sea limitada: ese veredicto gobierna un 409, y
// deducirlo de un comando que no contestó rechazaría instalaciones perfectamente válidas.
if (probe.code !== 0 || !salida) {
return {
elevation: 'desconocido',
label: 'privilegios no determinados',
detail:
`la sonda de privilegios no respondió (código ${probe.code})` +
(probe.stderr ? `: ${truncate(probe.stderr)}` : '') +
'. Se continúa: el propio instalador fallará con un mensaje claro si de verdad ' +
'faltan permisos.'
};
}
switch (salida) {
case 'admin':
return { elevation: 'admin', label: 'administrador', detail: '' };
case 'token-filtrado':
@@ -1140,6 +1155,12 @@ export function windowsAgentExe(prefix: string): string {
}
export interface WindowsProcessProbe {
/**
* La sonda obtuvo una respuesta del destino. En false NO se sabe si hay agente corriendo, y
* quien decida un fallo duro debe exigir una respuesta afirmativa en vez de asumirla por
* ausencia: leer "el comando no respondió" como "no hay proceso" tumba actualizaciones buenas.
*/
answered: boolean;
running: boolean;
/** Rutas de los ejecutables vivos. Vacío si corren pero no se pudo leer su ruta. */
paths: string[];
@@ -1173,13 +1194,19 @@ export async function probeWindowsAgentProcess(
);
const out = probe.stdout.trim();
// Sin respuesta no se sabe nada. Antes esto se leía como "detenido", que es una afirmación que
// la sonda nunca hizo.
if (probe.code !== 0 || !out) {
return { answered: false, running: false, paths: [], fromPrefix: false };
}
if (!out.startsWith('corriendo')) {
return { running: false, paths: [], fromPrefix: false };
return { answered: true, running: false, paths: [], fromPrefix: false };
}
const joined = out.split('|')[1] ?? '';
const paths = joined.split(';').map((p) => p.trim()).filter(Boolean);
const wanted = windowsAgentExe(prefix);
return {
answered: true,
running: true,
paths,
fromPrefix: paths.some((p) => sameWindowsPath(p, wanted))
@@ -1187,6 +1214,8 @@ export async function probeWindowsAgentProcess(
}
export interface WindowsTaskProbe {
/** La sonda respondió. En false, `exists: false` significa "no sé", no "no hay tarea". */
answered: boolean;
exists: boolean;
/** Ruta del ejecutable en la acción de la tarea. Es DÓNDE arranca el agente de verdad. */
execute: string;
@@ -1221,14 +1250,138 @@ export async function probeWindowsTask(sftp: SftpClient): Promise<WindowsTaskPro
)
);
const [flag, execute, principal] = probe.stdout.trim().split('|');
const salida = probe.stdout.trim();
if (probe.code !== 0 || !salida) {
return { answered: false, exists: false, execute: '', principal: '' };
}
const [flag, execute, principal] = salida.split('|');
return {
answered: true,
exists: flag === 'si',
execute: (execute ?? '').trim(),
principal: (principal ?? '').trim()
};
}
/**
* Respuesta de una sonda que puede no saber.
*
* `desconocido` NO es `no`. Confundirlos es lo que dejó bloqueadas las actualizaciones con un
* artefacto perfectamente bueno: la sonda anterior colapsaba cualquier fallo —una excepción, un
* código de salida distinto de cero, stdout vacío— en "el instalador no lo soporta", y sobre esa
* lectura se decidía un 409. Mientras solo elegía entre dos banderas el falso negativo degradaba;
* en cuanto gobernó un rechazo, se volvió un candado.
*/
export interface ProbeAnswer {
answer: 'si' | 'no' | 'desconocido';
/** Por qué no se pudo determinar. Vacío cuando hay respuesta. */
detail: string;
}
/**
* ¿El `install.ps1` del artefacto declara `-UpdateInPlace`?
*
* Se pregunta leyendo el TEXTO del script, no pidiéndole a PowerShell que lo compile y exponga sus
* parámetros. `(Get-Command …).Parameters` funcionaba en una máquina de desarrollo y fallaba en el
* servidor real; el candidato más probable es Constrained Language Mode (AppLocker/WDAC son
* habituales en servidores endurecidos), que restringe el acceso a propiedades de objetos .NET
* mientras `& install.ps1` sigue funcionando — justo lo que se observaba. Un `-match` sobre el
* contenido no compila nada, no toca reflexión y ninguna política lo bloquea.
*
* Se ancla a la DECLARACIÓN (`[switch]$UpdateInPlace`) y no a una mención suelta, para que un
* comentario que nombre el parámetro no dé un falso positivo.
*/
export async function probeInstallerUpdateSupport(
sftp: SftpClient,
installerPath: string
): Promise<ProbeAnswer> {
const probe = await execRemote(
sftp,
psEncoded(
`$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\')) ' +
"{ Write-Output 'si' } else { Write-Output 'no' } } " +
"catch { Write-Output ('desconocido|' + $_.Exception.Message) }"
)
);
const salida = probe.stdout.trim();
// El código de salida y stderr cuentan: un execRemote que falla deja stdout vacío, y leer eso
// como una respuesta es precisamente el error que se está corrigiendo.
if (probe.code !== 0 || !salida) {
return {
answer: 'desconocido',
detail:
`la sonda no respondió (código ${probe.code})` +
(probe.stderr ? `: ${truncate(probe.stderr)}` : '')
};
}
if (salida === 'si' || salida === 'no') return { answer: salida, detail: '' };
return {
answer: 'desconocido',
detail: salida.startsWith('desconocido|') ? salida.slice(12) : truncate(salida)
};
}
/**
* ¿Se puede desplegar este artefacto en Windows sin romper el servidor?
*
* Que su `install.ps1` no declare `-UpdateInPlace` significa que el paquete se construyó ANTES de los
* arreglos de actualización, y usarlo no es "una actualización peor": es dejar el servidor sin agente.
* Ese instalador detiene la tarea y mata los procesos, corre el bootstrap acotado a 20 s —insuficiente
* para desempacar un onefile de ~250 MB con el antivirus escaneando, así que muere antes de escribir
* `config\.version`—, re-registra la tarea como SYSTEM y la arranca sin comprobar que volviera. Y el
* binario del mismo paquete lleva el `runner.py` que ignora `--headless` en Windows, así que como
* SYSTEM en la sesión 0 Qt no puede crear su plataforma y el agente muere. Sin agente viejo y sin
* agente nuevo.
*
* Lanza 409 al ACTUALIZAR: negarse y decir por qué es estrictamente mejor que romperlo y explicarlo
* después. En una instalación NUEVA devuelve el aviso para asentarlo, porque ahí no hay agente que
* perder y negarse solo estorbaría.
*
* Es una función aparte, y no una comprobación dentro de `installWindows`, porque es una política con
* consecuencias: merece nombre propio y prueba propia.
*/
export function checkWindowsArtifactUpdateSupport(
mode: InstallMode,
soporte: ProbeAnswer,
version: string,
targetName: string
): string {
if (soporte.answer === 'si') return '';
// No saber NO es motivo para bloquear. Un fallo de diagnóstico no puede impedir el trabajo: se
// sigue con el comportamiento anterior y se deja dicho por qué no se pudo determinar, que es el
// dato que convierte la próxima sorpresa en un diagnóstico de diez segundos.
if (soporte.answer === 'desconocido') {
return (
`No se pudo determinar si el install.ps1 de ${version} soporta -UpdateInPlace ` +
`(${soporte.detail || 'sin detalle'}). Se instala con el modo de arranque normal en vez ` +
'de bloquear: no saberlo no es lo mismo que saber que no.'
);
}
const aviso =
`El artefacto ${version} trae un install.ps1 anterior a los arreglos de actualización ` +
'(no declara -UpdateInPlace).';
if (mode === 'update') {
throw new InstallError(
409,
`${aviso} Actualizar ${targetName} con él dejaría el servidor SIN agente: ese instalador ` +
'detiene el que está corriendo, cambia la tarea programada a SYSTEM y no comprueba ' +
'que vuelva a arrancar — y su binario no sabe correr headless en Windows, así que ' +
'como SYSTEM no levanta. Publica una versión construida con los arreglos y ' +
'actualiza a esa.'
);
}
return `${aviso} Se instala igual: no hay un agente en marcha que perder.`;
}
/**
* Alinea la acción de la tarea programada con el binario que se acaba de instalar.
*
@@ -1341,22 +1494,50 @@ export async function restartWindowsAgent(
}
/**
* Cola del crash log que el agente escribe junto a su ejecutable cuando no consigue arrancar.
* Cola del crash log que el agente escribe cuando no consigue arrancar.
*
* `runner.py` lo escribe precisamente para que un arranque fallido sea visible
* (`_crash_log_targets()`), y hasta ahora nadie lo leía nunca: el operador recibía "no arrancó" en
* lugar de "no arrancó porque X". Devuelve cadena vacía si no existe.
* Se buscan **las tres** ubicaciones que usa `runner.py` (`_crash_log_targets()`), en su mismo orden
* de preferencia: junto al ejecutable, `%LOCALAPPDATA%\CloudRestoreAS\crash.log` y
* `%TEMP%\CloudRestoreAS-crash.log`.
*
* Mirar solo junto al ejecutable no bastaba, y era justo el caso que interesa: un agente lanzado por
* una tarea que corre como SYSTEM resuelve las otras dos bajo
* `C:\Windows\System32\config\systemprofile\`, y ahí es donde quedó la evidencia del arranque que
* fallaba. El operador recibía "no arrancó" en lugar de "no arrancó porque X".
*
* Las variables se expanden EN EL DESTINO y en la sesión del usuario SSH, así que si el agente corre
* como SYSTEM su `%LOCALAPPDATA%` no es el mismo: por eso se añade explícitamente el del perfil de
* SYSTEM en vez de confiar en la expansión.
*/
async function readWindowsCrashLog(sftp: SftpClient, prefix: string): Promise<string> {
async function readWindowsCrashLog(
sftp: SftpClient,
prefix: string
): Promise<{ text: string; from: string }> {
const candidatos = [
`${prefix.replace(/\\+$/, '')}\\CloudRestoreAS-crash.log`,
'$env:LOCALAPPDATA\\CloudRestoreAS\\crash.log',
'$env:TEMP\\CloudRestoreAS-crash.log',
// El perfil de SYSTEM, que es donde caen los dos anteriores cuando el agente lo lanza la
// tarea programada como SYSTEM y no la sesión SSH.
'C:\\Windows\\System32\\config\\systemprofile\\AppData\\Local\\CloudRestoreAS\\crash.log',
'C:\\Windows\\Temp\\CloudRestoreAS-crash.log'
];
const result = await execRemote(
sftp,
psEncoded(
`$p = '${prefix.replace(/\\+$/, '')}\\CloudRestoreAS-crash.log'; ` +
'if (Test-Path -LiteralPath $p) { ' +
'(Get-Content -LiteralPath $p -Tail 20) -join [Environment]::NewLine }'
`$rutas = @("${candidatos.join('","')}"); ` +
'foreach ($r in $rutas) { ' +
'if (Test-Path -LiteralPath $r) { ' +
"Write-Output ('DE|' + $r); " +
'(Get-Content -LiteralPath $r -Tail 20) -join [Environment]::NewLine; break } }'
)
);
return result.stdout.trim();
const salida = result.stdout.trim();
if (!salida.startsWith('DE|')) return { text: '', from: '' };
const [cabecera, ...resto] = salida.split('\n');
return { text: resto.join('\n').trim(), from: cabecera.slice(3).trim() };
}
async function installWindows(
@@ -1531,27 +1712,21 @@ async function installWindows(
//
// Pero solo si el instalador DEL ARTEFACTO lo declara: el parámetro existe desde 1.1.3, e
// install.ps1 usa [CmdletBinding()], así que pasárselo a uno anterior falla con
// NamedParameterNotFound SIN ejecutar una sola línea. Se le pregunta a PowerShell por el
// `param()` del propio script en vez de mantener una tabla de versiones aquí.
// NamedParameterNotFound SIN ejecutar una sola línea.
const installerPs1 = `${remoteDir}\\CloudRestoreAS\\install.ps1`;
const supportsProbe = await execRemote(
sftp,
psEncoded(
`if ((Get-Command '${installerPs1}').Parameters.ContainsKey('UpdateInPlace')) ` +
"{'si'} else {'no'}"
)
const soporte = await probeInstallerUpdateSupport(sftp, installerPs1);
const avisoArtefacto = checkWindowsArtifactUpdateSupport(
request.mode,
soporte,
release.version,
target.name
);
const supportsInPlace = supportsProbe.stdout.trim() === 'si';
const inPlaceUpdate = request.mode === 'update' && supportsInPlace;
if (request.mode === 'update' && !supportsInPlace) {
await appendInstallStep(
runId,
'instalador-sin-update-in-place',
true,
`${release.version} trae un install.ps1 que no soporta -UpdateInPlace; se instala ` +
'con el modo de arranque normal'
);
if (avisoArtefacto) {
await appendInstallStep(runId, 'instalador-antiguo', true, avisoArtefacto);
}
const inPlaceUpdate = request.mode === 'update' && soporte.answer === 'si';
const flag = inPlaceUpdate
? ' -UpdateInPlace'
: autostart === 'none'
@@ -1586,11 +1761,26 @@ async function installWindows(
);
}
// El instalador puede haber cambiado la CUENTA con la que corre la tarea sin decir nada:
// `-Service` la re-registra como SYSTEM. Ese salto de una cuenta con escritorio a SYSTEM en la
// sesión 0 es lo que impide arrancar a un binario que no sabe caer a Qt offscreen, así que
// cuando pasa hay que dejarlo asentado en vez de que se descubra a base de diagnóstico.
const taskDespues = await probeWindowsTask(sftp);
if (task.exists && taskDespues.exists && task.principal !== taskDespues.principal) {
await appendInstallStep(
runId,
'cambio-de-cuenta-de-la-tarea',
true,
`el instalador cambió la cuenta de la tarea: ${task.principal || '(sin declarar)'}` +
`${taskDespues.principal || '(sin declarar)'}`
);
}
// El binario nuevo ya está en targetPath. Ahora hay que asegurar que el arranque automático
// apunte AHÍ: el install.ps1 de los artefactos publicados hasta 1.1.3 no sabe realinear la
// tarea, y sin eso `Start-ScheduledTask` levanta el binario de la carpeta anterior — la
// actualización termina en verde sin haber cambiado nada.
if (await alignWindowsTask(sftp, runId, task.execute, targetPath)) {
if (await alignWindowsTask(sftp, runId, taskDespues.execute || task.execute, targetPath)) {
const proc = await restartWindowsAgent(sftp, targetPath);
await appendInstallStep(
runId,
@@ -1696,7 +1886,7 @@ export async function verifyWindowsDeployment(
`El agente de ${prefix} no escribió config\\.version tras la actualización. La versión ` +
`nueva lo escribe al arrancar, así que lo que está corriendo no es ${release.version}. ` +
`${pistas.join('; ')}.` +
(crash ? ` Último crash del agente: ${truncate(crash)}` : '') +
(crash.text ? ` Último crash (${crash.from}): ${truncate(crash.text)}` : '') +
` Logs en ${prefix}\\config\\logs.`
);
}