Hay un tipo de bug que es especialmente molesto: el que no impide que las cosas funcionen, pero sí que funcionen bien. En este caso era la búsqueda de esta misma bitácora. Buscabas, por ejemplo, «telemetría», y la primera página de resultados aparecía perfectamente. Pero al intentar navegar a la segunda página, WordPress devolvía cero resultados. La URL que generaba el enlace de paginación incluía un carácter extra (un %2F, la codificación URL de una barra /)al final del término de búsqueda, con lo que el sistema interpretaba que estabas buscando «telemetría/» en vez de «telemetría». Un carácter. Una barra. Suficiente para romper la paginación de todo el buscador.

Lo que hace interesante este caso no es tanto el bug en sí — que resulta ser un fallo clásico de una función de WordPress aplicada donde no debía — sino el proceso para encontrarlo y corregirlo, que realicé íntegramente asistido por Claude Code, la herramienta de línea de comandos de Anthropic. Y más que el resultado, lo que merece documentarse es la metodología: cómo preparar el contexto, cómo dejar que el agente investigue y, sobre todo, cómo mantener el control humano en todo momento para evitar que la solución sea peor que el problema.

El punto de partida: darle al agente un mapa del territorio

La primera lección que he aprendido trabajando con agentes de IA en tareas de infraestructura es que el contexto lo es todo. Un agente sin contexto es como un consultor externo al que sueltas en una sala de servidores sin documentación: puede ser brillante, pero perderá el tiempo haciendo preguntas que tú ya sabes responder.

Antes de pedirle que diagnosticara nada, le proporcioné a Claude Code una descripción precisa de la arquitectura:

  • Un servidor de frontend que actúa como reverse proxy con nginx, terminando SSL y reenviando peticiones al backend.
  • Un servidor de backend con nginx y PHP-FPM donde corre WordPress físicamente.
  • Una salida a Internet doméstica con NAT, y dos nombres de dominio que apuntan al mismo WordPress: www.eniac2000.com y bitacora.eniac2000.com.
  • Acceso SSH sin contraseña a ambos servidores desde mi Mac Mini M4, con usuarios específicos para cada máquina.

También le describí el síntoma con precisión: la URL de la primera página de búsqueda era correcta (/?s=telemetr%C3%ADa), pero los enlaces de paginación generaban URLs con un %2F al final del parámetro s (/?paged=2&s=telemetr%C3%ADa%2F). Y le dije dónde sospechaba que estaba el problema: en alguno de los dos nginx.

Este paso de preparación del contexto no es trivial. Es, probablemente, el paso más importante de todo el proceso. Si le hubiera dicho simplemente «la búsqueda de mi WordPress no funciona bien», el agente habría necesitado varias iteraciones exploratorias para entender la topología de red, los roles de cada servidor y cómo fluye una petición desde el navegador hasta PHP. Dándole el mapa completo desde el inicio, pudo empezar a investigar directamente.

La investigación: cómo trabaja un agente de diagnóstico

Lo primero que hizo Claude Code fue lanzar, en paralelo, la lectura de las configuraciones de nginx de ambos servidores. Conectó por SSH a las dos máquinas simultáneamente y leyó los ficheros de configuración de los virtual hosts, el nginx.conf principal, los parámetros de FastCGI y los snippets de PHP-FPM. En una primera pasada recopiló toda la información de infraestructura disponible sin modificar absolutamente nada.

A continuación, realizó una prueba que resultó ser la clave del diagnóstico: lanzó un curl directamente contra el backend, sin pasar por el frontend, y comprobó si los enlaces de paginación seguían incluyendo el %2F:

curl -s -H "Host: bitacora.eniac2000.com" \
     -H "X-Forwarded-Proto: https" \
     "http://192.168.0.x/?s=telemetr%C3%ADa" \
     | grep -oP 'href="[^"]*paged[^"]*"'

El resultado fue revelador: la barra sobrante aparecía también en el acceso directo al backend. En un solo comando, el agente descartó completamente al nginx de frontend como causante del problema. Toda la investigación se concentró entonces en el backend.

A partir de ahí, el proceso de investigación fue metódico, y desde luego, más rápido de lo que hubiera sido una investigación manual sin el agente. Claude Code leyó la configuración de WordPress — la estructura de permalinks (planos, sin pretty URLs), las URLs del sitio en la base de datos, la versión de WordPress (6.9.4) — y examinó el código fuente de las funciones de paginación de WordPress (get_pagenum_link(), add_query_arg()). Pero lo que hizo el clic fue cuando inspeccionó la lista de plugins activos y encontró uno llamado Multiple Domain Mapping on single site.

La causa raíz: trailingslashit() donde no debía

El plugin Multiple Domain Mapping permite servir el mismo WordPress bajo distintos dominios. En mi caso, tenía configurado un mapeo de bitacora.eniac2000.com a la raíz /. El problema estaba en un método aparentemente inocente del plugin:

private function setCurrentURI($uri){
    $this->currentURI = trailingslashit( $uri );
}

La función trailingslashit() de WordPress hace exactamente lo que su nombre indica: añade una barra al final de una cadena si no la tiene. Está pensada para trabajar con paths del sistema de ficheros o rutas URL. Pero este plugin se la aplicaba a la URI completa, incluyendo el query string. Cuando la URI era bitacora.eniac2000.com/?s=telemetría, la función la convertía en bitacora.eniac2000.com/?s=telemetría/. La barra se añadía al final del valor del parámetro de búsqueda.

Y el efecto dominó era el siguiente: el plugin, al detectar que el dominio coincidía con su mapeo, reconstruía $_SERVER['REQUEST_URI'] usando esa URI ya contaminada. WordPress, al generar los enlaces de paginación, leía ese REQUEST_URI modificado y construía la URL de la página 2 con el término de búsqueda corrupto. Un %2F inofensivo para un humano, letal para una búsqueda.

Claude Code identificó dos puntos exactos donde aplicar la corrección: el método setCurrentURI() y la línea de parse_request() que reconstruía el REQUEST_URI. En ambos casos, la solución era la misma: separar el path del query string antes de aplicar trailingslashit(), y reensamblar después.

Validación cruzada: el abogado del diablo

Antes de tocar una sola línea de código en producción, quise una segunda opinión. A través de un servidor MCP de ChatGPT, le pedí a un modelo o3 que actuara como director de ingeniería y abogado del diablo: que revisara críticamente el diagnóstico, evaluara si las correcciones eran seguras, y buscara efectos colaterales que pudieran habérseme pasado por alto.

La revisión confirmó varios puntos clave:

  • El diagnóstico era correcto y no había otros factores que explicaran el fallo.
  • Ambas correcciones eran necesarias: la primera sola no bastaba, porque parse_request() reintroducía el problema.
  • No había efectos colaterales significativos: la función uriMatch() del propio plugin aplicaba internamente su propio trailingslashit() para las comparaciones, de modo que la lógica de detección de mapeos no se veía afectada.
  • No existían otros puntos en el plugin con el mismo problema.

Solo con esta doble validación -diagnóstico del agente más revisión adversarial de un segundo modelo- procedí a preparar la corrección.

El humano en el bucle: por qué el agente no tenía root

Y aquí está el punto que considero más importante de todo el proceso. Claude Code tenía acceso SSH a ambos servidores, pero sin privilegios de superusuario. Podía leer las configuraciones de nginx, consultar la base de datos de WordPress, examinar el código PHP del plugin. Pero no podía modificar ficheros que pertenecieran a www-data, no podía reiniciar servicios, y no podía ejecutar nada con sudo.

Esto no fue un accidente, sino una decisión consciente de diseño. Los ficheros de configuración de nginx y los plugins de WordPress son propiedad de www-data. El usuario SSH no tiene permisos para modificarlos directamente. Para hacer cualquier cambio en producción, el agente necesitaba mi intervención explícita.

Lo que Claude Code hizo fue generar scripts de shell autocontenidos que yo pudiera revisar, copiar al servidor y ejecutar manualmente con sudo. Cada script incluía:

  • Verificación previa: comprobación del MD5 del fichero original para asegurar que nadie lo había modificado entre el diagnóstico y la corrección.
  • Copia de seguridad automática con timestamp, antes de tocar nada.
  • Aplicación del parche usando reemplazos PHP exactos (no sed, dado que el código contenía comillas, caracteres especiales y tabuladores que hacían los reemplazos con expresiones regulares poco fiables).
  • Verificación posterior: búsqueda en el fichero modificado de las cadenas clave que confirmaran que el parche se había aplicado. Si la verificación fallaba, el script restauraba automáticamente el backup y abortaba.
  • Validación de sintaxis: php -l contra el fichero modificado.
  • Restauración del propietario: chown www-data:www-data para que los permisos quedaran exactamente como estaban.
  • Reinicio del servicio: systemctl restart php8.4-fpm.
  • Comando de rollback: al final del script, se mostraba el comando exacto para revertir todo el cambio si algo salía mal.

El ciclo era siempre el mismo: el agente generaba el script, yo lo copiaba al servidor con scp, lo ejecutaba con sudo bash, y le comunicaba el resultado. Si algo fallaba, el agente ajustaba su enfoque y generaba una nueva versión. En ningún momento tuvo capacidad de ejecutar nada con privilegios elevados por su cuenta.

Este patrón de trabajo (agente que diagnostica y prepara, humano que ejecuta y valida) no es una limitación, es una garantía. En un entorno de producción donde un error puede tumbar un sitio web público, la separación de privilegios entre el agente y el operador humano es la diferencia entre un proceso controlado y una catástrofe potencial.

La corrección aplicada

La corrección final consistió en dos cambios en el fichero multidomainmapping.php del plugin:

Corrección 1: el método setCurrentURI(), que ahora separa el path del query string antes de aplicar trailingslashit():

private function setCurrentURI($uri){
    $qpos = strpos($uri, '?');
    if ($qpos !== false) {
        $this->currentURI = trailingslashit(substr($uri, 0, $qpos))
                          . substr($uri, $qpos);
    } else {
        $this->currentURI = trailingslashit( $uri );
    }
}

Corrección 2: la reconstrucción del REQUEST_URI en parse_request(), que ahora preserva el query string intacto:

$tail = substr(
    str_ireplace('www.', '', $this->getCurrentURI()),
    strlen(str_ireplace('www.', '',
        $this->getCurrentMapping()['match']['domain']))
);
$tail_parts = explode('?', $tail, 2);
$tail_path  = $tail_parts[0];
$tail_query = isset($tail_parts[1]) ? '?' . $tail_parts[1] : '';
$newRequestURI = trailingslashit(
    $this->getCurrentMapping()['match']['path'] . $tail_path
) . $tail_query;

Validación automática del resultado

Una vez ejecutados los scripts, Claude Code validó el resultado lanzando la misma prueba que había servido para diagnosticar el problema: un curl contra la URL de búsqueda, extrayendo los enlaces de paginación del HTML devuelto:

# ANTES (bug presente):
href="https://bitacora.eniac2000.com/?paged=2&s=telemetr%C3%ADa%2F"

# DESPUÉS (bug corregido):
href="https://bitacora.eniac2000.com/?paged=2&s=telemetr%C3%ADa"

También verificó que la segunda página de resultados devolviera un HTTP 200 limpio, sin redirecciones ni errores. El ciclo completo — desde el diagnóstico inicial hasta la validación final — se completó en una sola sesión de trabajo, sin interrupciones de servicio y con la certeza de que cada paso era reversible.

Lecciones aprendidas

De todo este proceso, me quedo con varias ideas que creo que aplican a cualquier uso de agentes de IA para tareas de infraestructura:

  • El contexto inicial determina la calidad del resultado. Describir la arquitectura completa — servidores, roles, IPs, flujo de red, accesos — ahorra iteraciones y evita diagnósticos incorrectos. El agente no puede adivinar lo que tú sabes sobre tu infraestructura.
  • La separación de privilegios no es opcional. Que el agente no tenga sudo no es un inconveniente: es un cortafuegos. Genera scripts, tú los ejecutas. Si algo sale mal, la responsabilidad y el control están donde tienen que estar.
  • Un agente que investiga bien vale más que uno que ejecuta rápido. El valor real estuvo en la capacidad de Claude Code para leer configuraciones en paralelo, descartar hipótesis con pruebas concretas y trazar el flujo del bug a través de varias capas (nginx, PHP, WordPress, plugin). La ejecución del parche fue la parte trivial.
  • La validación cruzada con un segundo modelo aporta confianza. Pedir a un modelo diferente que actúe como adversario del diagnóstico no es redundancia: es ingeniería. Si dos modelos independientes llegan a la misma conclusión por caminos distintos, la probabilidad de que el diagnóstico sea correcto aumenta significativamente.
  • Todo script de corrección debe ser reversible. Backup automático, verificación de MD5, restauración en caso de fallo, comando de rollback explícito. No es paranoia: es profesionalidad.

Un carácter. Una barra. Un %2F que se colaba por una función aplicada donde no debía. Lo encontré gracias a un agente de IA que sabía leer configuraciones, descartar hipótesis y preparar correcciones sin tocar nada que no le correspondiera. Y lo corregí yo, con sudo y con los ojos bien abiertos. Que es como tienen que ser estas cosas.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.