Files
Teknium ca69d857c3 fix(update): a proxy the user set in .env beats the injected system proxy
The Desktop hands `hermes update` the system proxy in both spellings
(HTTPS_PROXY and https_proxy). The .env load replaces only the spelling
the file uses, and urllib and curl prefer the lower-case one, so the
update still went to the system proxy and the .env NO_PROXY stopped
applying. After the .env load, `hermes update` now undoes everything the
Desktop added when .env sets a proxy (the Desktop injects nothing when it
sees one). A .env NO_PROXY alone replaces the user own entries in every
injected NO_PROXY variant and keeps the bypass the Desktop added; a `*`
there drops the injection.

Review S1 (kshitijk4poor) on #144894.
2026-10-27 13:23:31 -07:00

81 lines
4.0 KiB
Python

"""Undo the system proxy the Desktop handed to an update's children.
The Desktop resolves the OS proxy (PAC included) and gives it to the update's
git, urllib, npm and uv children as HTTPS_PROXY/HTTP_PROXY with an extended
NO_PROXY (apps/desktop/electron/updater/system-proxy-env.ts). ``hermes update``
then restarts gateways and backends that inherit that env; for them it is not
the user's setting, and every host the PAC sends direct (LAN model servers,
internal names) would go through the proxy. ``HERMES_UPDATE_SYSTEM_PROXY`` names
the variables the Desktop added and ``HERMES_UPDATE_USER_<KEY>`` holds a user's
own NO_PROXY it extended; a long-lived process restores its env from them at
start. Same algorithm as restoreUserProxyEnv, posix.sh and windows.ps1, plus one
rule only a Hermes process needs: a value the user's ``.env`` set wins. ``hermes
update`` and the restarted process load ``~/.hermes/.env`` with override before
this runs, so a marked key holding the value a dotenv load published is the
user's own setting (same value as the Desktop's or not) and is kept. The Desktop
and the hand-off scripts never load ``.env``, so their envs never hit that case.
"""
from __future__ import annotations
import os
from typing import MutableMapping
MARKER = "HERMES_UPDATE_SYSTEM_PROXY"
SAVED_PREFIX = "HERMES_UPDATE_USER_"
_ADDED_KEYS = ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "NO_PROXY", "no_proxy")
_NO_PROXY_KEYS = ("NO_PROXY", "no_proxy")
def restore_user_proxy_env(environ: MutableMapping[str, str] | None = None) -> None:
"""Drop the proxy variables the Desktop added and restore the user's own NO_PROXY."""
from hermes_cli.env_loader import dotenv_published_values
env = os.environ if environ is None else environ
def users_dotenv_value(key: str) -> bool:
return key in env and env[key] in dotenv_published_values(key)
for key in env.get(MARKER, "").split(","):
if key in _ADDED_KEYS and not users_dotenv_value(key):
env.pop(key, None)
for key in _NO_PROXY_KEYS:
saved = env.pop(SAVED_PREFIX + key, None)
if saved is not None and not users_dotenv_value(key):
env[key] = saved
env.pop(MARKER, None)
def prefer_dotenv_proxy_env(environ: MutableMapping[str, str] | None = None) -> None:
"""In ``hermes update``, after its ``.env`` load: the user's ``.env`` proxy beats the system one.
The Desktop injects every case variant (``HTTPS_PROXY`` and ``https_proxy``) but the ``.env``
load replaces only the spelling the file uses, and urllib and curl prefer the lower-case one, so
the update would still go through the system proxy. A ``.env`` proxy is an explicit setting the
Desktop could not see; had it seen one it would have injected nothing, so everything it added is
undone. A ``.env`` NO_PROXY alone replaces the user's own entries in every injected NO_PROXY
variant, keeping the entries the Desktop added; a ``*`` in it bypasses every proxy.
"""
from agent.proxy_bypass import no_proxy_entries
from hermes_cli.env_loader import dotenv_published_values
env = os.environ if environ is None else environ
if MARKER not in env:
return
if any(dotenv_published_values(name) for name in ("https_proxy", "http_proxy", "all_proxy")):
restore_user_proxy_env(env)
return
published = dotenv_published_values("no_proxy")
users = next((env[key] for key in env if key.lower() == "no_proxy" and env[key] in published), None)
if users is None:
return
if "*" in no_proxy_entries(users):
restore_user_proxy_env(env)
return
own = {e for key in _NO_PROXY_KEYS for e in no_proxy_entries(env.get(SAVED_PREFIX + key, ""))}
added = [e for key in _NO_PROXY_KEYS if env.get(key, users) != users
for e in no_proxy_entries(env[key]) if e not in own]
merged = ",".join(dict.fromkeys([*no_proxy_entries(users), *added]))
for key in _NO_PROXY_KEYS:
if key in env:
env[key] = merged