Fix bridge profile environment isolation (#796)
This commit is contained in:
@@ -21,6 +21,7 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
@@ -135,6 +136,12 @@ def _discover_hermes_home(raw: str | None = None) -> Path:
|
||||
return Path(DEFAULT_HERMES_HOME).expanduser().resolve()
|
||||
|
||||
|
||||
def _normalize_base_home(home: Path) -> Path:
|
||||
if home.parent.name == "profiles":
|
||||
return home.parent.parent
|
||||
return home
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
try:
|
||||
json.dumps(value)
|
||||
@@ -156,7 +163,7 @@ def _hermes_home() -> Path:
|
||||
|
||||
|
||||
def _base_hermes_home() -> Path:
|
||||
return _discover_hermes_home(os.environ.get("HERMES_AGENT_BRIDGE_BASE_HOME") or DEFAULT_HERMES_HOME)
|
||||
return _normalize_base_home(_discover_hermes_home(os.environ.get("HERMES_AGENT_BRIDGE_BASE_HOME") or DEFAULT_HERMES_HOME))
|
||||
|
||||
|
||||
def _profile_home(profile: str | None) -> Path:
|
||||
@@ -167,11 +174,52 @@ def _profile_home(profile: str | None) -> Path:
|
||||
return profile_home if profile_home.exists() else base
|
||||
|
||||
|
||||
def _read_dotenv(path: Path) -> dict[str, str]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
from dotenv import dotenv_values
|
||||
|
||||
values = dotenv_values(path)
|
||||
return {str(k): str(v) for k, v in values.items() if k and v is not None}
|
||||
except Exception:
|
||||
values: dict[str, str] = {}
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if not key:
|
||||
continue
|
||||
if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
|
||||
value = value[1:-1]
|
||||
values[key] = value
|
||||
except Exception:
|
||||
return {}
|
||||
return values
|
||||
|
||||
|
||||
def _profile_dotenv_keys() -> set[str]:
|
||||
base = _base_hermes_home()
|
||||
keys = set(_read_dotenv(base / ".env").keys())
|
||||
profiles_dir = base / "profiles"
|
||||
try:
|
||||
for entry in profiles_dir.iterdir():
|
||||
if entry.is_dir():
|
||||
keys.update(_read_dotenv(entry / ".env").keys())
|
||||
except Exception:
|
||||
pass
|
||||
return keys
|
||||
|
||||
|
||||
def _set_path_env(agent_root: str | None = None, hermes_home: str | None = None) -> None:
|
||||
os.environ["HERMES_AGENT_ROOT"] = str(_discover_agent_root(agent_root))
|
||||
resolved_home = str(_discover_hermes_home(hermes_home))
|
||||
os.environ["HERMES_HOME"] = resolved_home
|
||||
os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = resolved_home
|
||||
resolved_home = _discover_hermes_home(hermes_home)
|
||||
os.environ["HERMES_HOME"] = str(resolved_home)
|
||||
os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = str(_normalize_base_home(resolved_home))
|
||||
|
||||
|
||||
def _ensure_agent_imports() -> None:
|
||||
@@ -208,8 +256,6 @@ def _apply_profile_env(profile: str | None) -> str | None:
|
||||
"""Temporarily set HERMES_HOME to the profile directory.
|
||||
Returns the original HERMES_HOME value to restore later.
|
||||
"""
|
||||
if not profile or profile == "default":
|
||||
return os.environ.get("HERMES_HOME")
|
||||
profile_home = _profile_home(profile)
|
||||
if not (profile_home / "config.yaml").exists():
|
||||
return os.environ.get("HERMES_HOME")
|
||||
@@ -226,6 +272,49 @@ def _restore_profile_env(original: str | None) -> None:
|
||||
os.environ.pop("HERMES_HOME", None)
|
||||
|
||||
|
||||
def _apply_profile_dotenv(profile: str | None) -> dict[str, str | None]:
|
||||
"""Load only the active profile's .env into this bridge process.
|
||||
|
||||
This mirrors Web UI gateway env isolation:
|
||||
- default keeps inherited env for compatibility, then overlays default .env
|
||||
- non-default clears keys seen in any profile .env, then overlays its .env
|
||||
The returned snapshot restores the bridge process after the agent call.
|
||||
"""
|
||||
values = _read_dotenv(_profile_home(profile) / ".env")
|
||||
if profile and profile != "default":
|
||||
keys = _profile_dotenv_keys()
|
||||
keys.update(values.keys())
|
||||
else:
|
||||
keys = set(values.keys())
|
||||
snapshot = {key: os.environ.get(key) for key in keys}
|
||||
|
||||
if profile and profile != "default":
|
||||
for key in keys:
|
||||
os.environ.pop(key, None)
|
||||
for key, value in values.items():
|
||||
os.environ[key] = value
|
||||
return snapshot
|
||||
|
||||
|
||||
def _restore_profile_dotenv(snapshot: dict[str, str | None]) -> None:
|
||||
for key, value in snapshot.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _profile_env(profile: str | None):
|
||||
original = _apply_profile_env(profile)
|
||||
env_snapshot = _apply_profile_dotenv(profile)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_restore_profile_dotenv(env_snapshot)
|
||||
_restore_profile_env(original)
|
||||
|
||||
|
||||
def _resolve_model(cfg: dict[str, Any]) -> str:
|
||||
env_model = (
|
||||
os.environ.get("HERMES_MODEL", "")
|
||||
@@ -404,8 +493,7 @@ class AgentPool:
|
||||
_suppress_bridge_platform_hint()
|
||||
from run_agent import AIAgent
|
||||
|
||||
original_home = _apply_profile_env(profile)
|
||||
try:
|
||||
with _profile_env(profile):
|
||||
cfg = _load_cfg()
|
||||
resolved_model = _resolve_model(cfg)
|
||||
runtime = _resolve_runtime(resolved_model)
|
||||
@@ -460,8 +548,6 @@ class AgentPool:
|
||||
)
|
||||
self._sessions[session_id] = session
|
||||
return session
|
||||
finally:
|
||||
_restore_profile_env(original_home)
|
||||
|
||||
def _install_compression_hook(self, agent: Any, session_id: str) -> None:
|
||||
original = getattr(agent, "_compress_context", None)
|
||||
@@ -870,100 +956,101 @@ class AgentPool:
|
||||
|
||||
def _run_chat(self, session: AgentSession, record: RunRecord, message: Any, storage_message: Any | None = None, instructions: str | None = None, conversation_history: list[dict[str, Any]] | None = None, profile: str | None = None, force_compress: bool = False) -> None:
|
||||
with self._run_lock:
|
||||
def stream_callback(delta: str) -> None:
|
||||
with self._lock:
|
||||
record.deltas.append(str(delta))
|
||||
with _profile_env(profile):
|
||||
def stream_callback(delta: str) -> None:
|
||||
with self._lock:
|
||||
record.deltas.append(str(delta))
|
||||
|
||||
try:
|
||||
previous_approval_callback = None
|
||||
previous_exec_ask = os.environ.get("HERMES_EXEC_ASK")
|
||||
approval_session_token = None
|
||||
registered_gateway_approval_session = None
|
||||
try:
|
||||
from tools.terminal_tool import _get_approval_callback, set_approval_callback
|
||||
from tools.approval import register_gateway_notify, set_current_session_key
|
||||
|
||||
previous_approval_callback = _get_approval_callback()
|
||||
set_approval_callback(self._approval_callback(session.session_id))
|
||||
approval_session_token = set_current_session_key(session.session_id)
|
||||
register_gateway_notify(session.session_id, self._gateway_approval_notify(session.session_id))
|
||||
registered_gateway_approval_session = session.session_id
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
except Exception:
|
||||
previous_approval_callback = None
|
||||
self._prepersist_user_message(session, message, storage_message, conversation_history, profile)
|
||||
db_count_after_prepersist = self._session_db_message_count(session.session_id, profile)
|
||||
if force_compress:
|
||||
compress = getattr(session.agent, "_compress_context", None)
|
||||
if callable(compress):
|
||||
compressed_history, compressed_system = compress(
|
||||
conversation_history if isinstance(conversation_history, list) else [],
|
||||
instructions,
|
||||
approx_tokens=None,
|
||||
focus_topic="debug_force_compress",
|
||||
)
|
||||
if isinstance(compressed_history, list):
|
||||
conversation_history = compressed_history
|
||||
if isinstance(compressed_system, str):
|
||||
instructions = compressed_system
|
||||
kwargs: dict[str, Any] = dict(
|
||||
task_id=session.session_id,
|
||||
stream_callback=stream_callback,
|
||||
)
|
||||
if instructions:
|
||||
kwargs["system_message"] = instructions
|
||||
if conversation_history is not None:
|
||||
kwargs["conversation_history"] = conversation_history
|
||||
result = session.agent.run_conversation(
|
||||
message,
|
||||
**kwargs,
|
||||
)
|
||||
result = _jsonable(result if isinstance(result, dict) else {"value": result})
|
||||
self._sync_result_tail_to_session_db(
|
||||
session,
|
||||
result,
|
||||
conversation_history,
|
||||
profile,
|
||||
db_count_after_prepersist,
|
||||
)
|
||||
with session.lock:
|
||||
if isinstance(result.get("messages"), list):
|
||||
session.history = result["messages"]
|
||||
record.status = "interrupted" if result.get("interrupted") else "complete"
|
||||
record.result = result
|
||||
record.ended_at = time.time()
|
||||
session.running = False
|
||||
session.current_run_id = None
|
||||
session.last_used_at = time.time()
|
||||
except Exception as exc:
|
||||
with session.lock:
|
||||
record.status = "error"
|
||||
record.error = str(exc)
|
||||
record.result = {"error": str(exc), "traceback": traceback.format_exc()}
|
||||
record.ended_at = time.time()
|
||||
session.running = False
|
||||
session.current_run_id = None
|
||||
session.last_used_at = time.time()
|
||||
finally:
|
||||
try:
|
||||
from tools.terminal_tool import set_approval_callback
|
||||
|
||||
set_approval_callback(previous_approval_callback)
|
||||
except Exception:
|
||||
pass
|
||||
if approval_session_token is not None:
|
||||
previous_exec_ask = os.environ.get("HERMES_EXEC_ASK")
|
||||
approval_session_token = None
|
||||
registered_gateway_approval_session = None
|
||||
try:
|
||||
from tools.approval import reset_current_session_key, unregister_gateway_notify
|
||||
from tools.terminal_tool import _get_approval_callback, set_approval_callback
|
||||
from tools.approval import register_gateway_notify, set_current_session_key
|
||||
|
||||
if registered_gateway_approval_session is not None:
|
||||
unregister_gateway_notify(registered_gateway_approval_session)
|
||||
reset_current_session_key(approval_session_token)
|
||||
previous_approval_callback = _get_approval_callback()
|
||||
set_approval_callback(self._approval_callback(session.session_id))
|
||||
approval_session_token = set_current_session_key(session.session_id)
|
||||
register_gateway_notify(session.session_id, self._gateway_approval_notify(session.session_id))
|
||||
registered_gateway_approval_session = session.session_id
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
except Exception:
|
||||
previous_approval_callback = None
|
||||
self._prepersist_user_message(session, message, storage_message, conversation_history, profile)
|
||||
db_count_after_prepersist = self._session_db_message_count(session.session_id, profile)
|
||||
if force_compress:
|
||||
compress = getattr(session.agent, "_compress_context", None)
|
||||
if callable(compress):
|
||||
compressed_history, compressed_system = compress(
|
||||
conversation_history if isinstance(conversation_history, list) else [],
|
||||
instructions,
|
||||
approx_tokens=None,
|
||||
focus_topic="debug_force_compress",
|
||||
)
|
||||
if isinstance(compressed_history, list):
|
||||
conversation_history = compressed_history
|
||||
if isinstance(compressed_system, str):
|
||||
instructions = compressed_system
|
||||
kwargs: dict[str, Any] = dict(
|
||||
task_id=session.session_id,
|
||||
stream_callback=stream_callback,
|
||||
)
|
||||
if instructions:
|
||||
kwargs["system_message"] = instructions
|
||||
if conversation_history is not None:
|
||||
kwargs["conversation_history"] = conversation_history
|
||||
result = session.agent.run_conversation(
|
||||
message,
|
||||
**kwargs,
|
||||
)
|
||||
result = _jsonable(result if isinstance(result, dict) else {"value": result})
|
||||
self._sync_result_tail_to_session_db(
|
||||
session,
|
||||
result,
|
||||
conversation_history,
|
||||
profile,
|
||||
db_count_after_prepersist,
|
||||
)
|
||||
with session.lock:
|
||||
if isinstance(result.get("messages"), list):
|
||||
session.history = result["messages"]
|
||||
record.status = "interrupted" if result.get("interrupted") else "complete"
|
||||
record.result = result
|
||||
record.ended_at = time.time()
|
||||
session.running = False
|
||||
session.current_run_id = None
|
||||
session.last_used_at = time.time()
|
||||
except Exception as exc:
|
||||
with session.lock:
|
||||
record.status = "error"
|
||||
record.error = str(exc)
|
||||
record.result = {"error": str(exc), "traceback": traceback.format_exc()}
|
||||
record.ended_at = time.time()
|
||||
session.running = False
|
||||
session.current_run_id = None
|
||||
session.last_used_at = time.time()
|
||||
finally:
|
||||
try:
|
||||
from tools.terminal_tool import set_approval_callback
|
||||
|
||||
set_approval_callback(previous_approval_callback)
|
||||
except Exception:
|
||||
pass
|
||||
if previous_exec_ask is None:
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
else:
|
||||
os.environ["HERMES_EXEC_ASK"] = previous_exec_ask
|
||||
if approval_session_token is not None:
|
||||
try:
|
||||
from tools.approval import reset_current_session_key, unregister_gateway_notify
|
||||
|
||||
if registered_gateway_approval_session is not None:
|
||||
unregister_gateway_notify(registered_gateway_approval_session)
|
||||
reset_current_session_key(approval_session_token)
|
||||
except Exception:
|
||||
pass
|
||||
if previous_exec_ask is None:
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
else:
|
||||
os.environ["HERMES_EXEC_ASK"] = previous_exec_ask
|
||||
|
||||
def interrupt(self, session_id: str, message: str | None = None) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
|
||||
Reference in New Issue
Block a user