Los plugins ya no vuelven a tocar tu token


Instalas un plugin de voz. Necesita tu inicio de sesión de ChatGPT para acuñar un secreto de sesión en tiempo real. Todo funciona durante unos días. Luego, una mañana, Codex te ha cerrado la sesión. Vuelves a entrar y al día siguiente ocurre otra vez. Culpas a la red, al navegador, quizá a tu cuenta; a todo menos al plugin, que después de todo solo «usaba» tu login. Lo que pasó en realidad: el plugin y Hermes querían refrescar la misma credencial, y los tokens de refresco de Codex son de un solo uso. Quien lo gaste en segundo lugar se queda con un token muerto, y con él se revoca toda la familia de tokens.

Eso no es un plugin malicioso. Es la ruta natural de un plugin que necesita un inicio de sesión, y la documentación oficial pone ese mismo plugin de voz como ejemplo. La PR #136117 (fusionada en main el 10 de octubre de 2026 y publicada en la v0.21.7) tomó una tercera vía: deja que el núcleo haga la petición. El plugin ya no lee, ni refresca, ni escribe la credencial: solo recibe la respuesta.

Cómo ocurre el fallo: un token de un solo uso, dos gastadores

Vale la pena detallar la mecánica, porque la responsabilidad queda inequívoca en cuanto la ves:

  • Los tokens de refresco de Codex se invalidan al usarse, así que refrescar tiene que estar serializado. Hermes lo sabe desde hace tiempo y refresca bajo un lock; el conjunto de reglas que cubre esta clase de proveedor es _SINGLE_USE_REFRESH_PROVIDERS.
  • Un plugin que necesitaba el inicio de sesión de ChatGPT solía traer su propio lector para ~/.codex/auth.json: si el token parecía próximo a caducar, hacía POST a auth.openai.com/oauth/token con el client id de la CLI de Codex y volvía a escribir los tokens nuevos.
  • Esas dos implementaciones de refresco no comparten el lock. Cuando refrescan a la vez, el mismo token de refresco se gasta dos veces. El servidor lo interpreta como abuso e invalida toda la familia de tokens.

El resultado es el síntoma que cuesta horas de diagnóstico: nada en tu configuración ha cambiado, nadie escribió mal una contraseña y simplemente te quedas fuera de la sesión. Y vuelve después de que entres de nuevo, porque el segundo refrescador del plugin sigue ahí.

La nueva forma: el núcleo hace la petición

El nuevo módulo público hermes_cli/plugin_provider_requests.py ofrece credentialed_provider_request(). Es deliberadamente sencillo —una función importable con la misma forma que hermes_cli.urllib_security.open_credentialed_url— para que funcione tanto desde código de register(ctx) como desde plugins de panel (dashboard/plugin_api.py).

Dos pasos para quien escribe plugins. Primero, declara el inicio de sesión en plugin.yaml:

name: live-voice
requires_auth:
  - openai-codex

Luego entrega la petición al núcleo y deja de tocar el archivo de credenciales:

from hermes_cli.plugin_provider_requests import ProviderNotSignedIn, credentialed_provider_request

def mint_client_secret(session: dict) -> dict:
    try:
        resp = credentialed_provider_request(
            "openai-codex", "POST", "https://api.openai.com/v1/realtime/client_secrets",
            json={"session": session}, timeout=15)
    except ProviderNotSignedIn as exc:
        return {"ok": False, "error": str(exc)}  # el mensaje nombra el comando de inicio de sesión
    if resp.status != 200:
        return {"ok": False, "error": f"client secret failed ({resp.status}): {resp.text[:300]}"}
    return {"ok": True, **resp.json()}

Lo que devuelve es un ProviderResponse (status, headers, body, más .text y .json()) sin ningún token dentro. El código que hay que borrar es tu lector y refrescador hechos a mano: desde el lado del plugin, el paso de refresco ya no existe.

Cuatro reglas duras

Esto no es un proxy de propósito general. Lleva cuatro fronteras, y cada una responde a un incidente real:

  • Solo plugins declarados. El llamante se identifica como el directorio de plugin que aparece en la pila de llamadas; el núcleo lee el plugin.yaml de ese plugin. Un plugin que no liste el proveedor bajo requires_auth —o código que no pertenece a ningún plugin instalado— recibe un PermissionError. Pasar el helper a pelo a run_in_executor tampoco ayuda: no hay ningún marco de plugin en la pila, así que la llamada se rechaza. Llama desde tu propia función.
  • Solo orígenes fijos. Para openai-codex, el token se envía únicamente a https://chatgpt.com y https://api.openai.com. Cualquier otra URL se rechaza antes de cualquier E/S de red. Un perfil cuya credencial de Codex enrute a un gateway propio también se rechaza: esa clave pertenece al gateway, no a esta ruta.
  • Sin redirecciones. follow_redirects=False. Una respuesta 3xx vuelve sin modificar, así que una cabecera Authorization nunca puede acabar en un tercer host.
  • Credenciales acotadas al perfil. Resuelve a través de resolve_codex_runtime_credentials(), la misma ruta con refresco bajo lock, acotada al perfil y consciente del pool que usa el chat. Sin inicio de sesión recibes ProviderNotSignedIn, cuyo mensaje nombra el comando que el usuario necesita (hermes auth add openai-codex, o el inicio de sesión de Codex dentro de la app de escritorio).

Qué cambia para quien instala plugins

Buena parte del valor aquí es la visibilidad en el momento de instalar:

  • requires_auth aparece en el aviso de instalación y en hermes plugins show <name>.
  • Más útil aún: participa en el flujo de consentimiento cuando se actualiza un pin del catálogo. Si la nueva versión añade herramientas, hooks, dependencias de Python, capacidades de host, un inicio de sesión de proveedor (requires_auth) o una mitad de interfaz de escritorio que la versión instalada no tenía, la CLI muestra el delta y pregunta y/N antes de mover nada; los botones de actualización del escritorio y del panel muestran la misma confirmación. Rechazar —o una sesión no interactiva sin nadie que responda— deja el plugin en su pin antiguo.

En otras palabras, «este plugin amplió su propio acceso sin decir nada» es ahora una pregunta que la ruta de actualización hace en voz alta.

Lo que no es: esto no es un sandbox

La documentación oficial es tajante al respecto, y merece repetirse tal cual a los usuarios: esto es consentimiento y visibilidad, no un sandbox. El código del plugin se ejecuta en proceso, así que todavía puede leer cualquier archivo que tú puedas leer. El proceso de revisión rechaza los plugins que lo hacen, pero eso es una restricción de revisión, no una aplicación a nivel de kernel.

Las únicas garantías duras son las cuatro reglas de arriba: atribución, origen, redirecciones y la ruta de resolución de credenciales. Léelo como «un plugin ya no puede hacerse pasar por el núcleo al usar tu inicio de sesión», no como «el plugin está contenido».

Los paneles de uso y el cierre de sesión también pasan por el núcleo

Dos trampas que la documentación cierra de forma explícita:

  • No llames tú a los endpoints de uso. Usa agent.account_usage.fetch_account_usage("openai-codex", read_only=True). Devuelve una instantánea (plan, ventanas de sesión y semanales con used_percent y reset_at) y nunca refresca ni rota una credencial.
  • El cierre de sesión es cosa de Hermes, no de un plugin. Manda a los usuarios a hermes auth logout openai-codex en lugar de borrar archivos de credenciales: borrarlos es justo como empezó toda esta historia.

Qué hacer ahora

  • Autores de plugins: si tu plugin lee auth.json o refresca un token de Codex por su cuenta, borra ese código, llama a credentialed_provider_request() en su lugar y añade requires_auth a plugin.yaml. Todos los que lo ejecuten dejan de sufrir cierres de sesión por esa carrera.
  • Todos los demás: la capacidad está en la v0.21.7 (etiquetada el 11 de octubre). Para ver si un plugin instalado la ha adoptado, hermes plugins show <name> imprime el requires_auth declarado. Para el modelo de seguridad que lo rodea, consulta nuestra guía del catálogo de plugins y la entrada sobre la cadena de suministro del CI del catálogo.

La versión corta

  • «Los plugins no deberían guardar tu token» no es un remilgo; es la conclusión de un bug concreto: un token de refresco de un solo uso gastado por dos refrescadores, y un usuario fuera de su sesión sin saber por qué.
  • El arreglo no fue dar a los plugins una llave más pequeña, sino quitarles la llave: el núcleo hace la petición y añade Authorization, y el plugin solo ve la respuesta.
  • Las cuatro reglas —decláralo, orígenes fijos, sin redirecciones, credenciales acotadas al perfil— llevan esto de la convención al mecanismo.
  • La visibilidad importa igual: requires_auth aparece al instalar, al inspeccionar y en la confirmación de actualización, así que un plugin ya no puede añadir un inicio de sesión en silencio.
  • Y ten presente la frontera: consentimiento y visibilidad, no un sandbox.