Fix bridge profile environment isolation (#796)
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { setTimeout as delay } from 'timers/promises'
|
||||
import { createConnection, type Socket } from 'net'
|
||||
import { URL } from 'url'
|
||||
import { join } from 'path'
|
||||
import { bridgeLogger } from '../../logger'
|
||||
import { getActiveProfileName, getProfileDir } from '../hermes-profile'
|
||||
|
||||
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = process.platform === 'win32'
|
||||
? 'tcp://127.0.0.1:18765'
|
||||
@@ -122,6 +124,25 @@ export class AgentBridgeClient {
|
||||
return summary
|
||||
}
|
||||
|
||||
private runtimeContext(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const requestedProfile = typeof payload.profile === 'string' ? payload.profile.trim() : ''
|
||||
let profile = requestedProfile || 'default'
|
||||
try {
|
||||
if (!requestedProfile) profile = getActiveProfileName()
|
||||
} catch {}
|
||||
|
||||
const context: Record<string, unknown> = {
|
||||
profile,
|
||||
cwd: process.cwd(),
|
||||
}
|
||||
try {
|
||||
const profileDir = getProfileDir(profile)
|
||||
context.profile_dir = profileDir
|
||||
context.config_path = join(profileDir, 'config.yaml')
|
||||
} catch {}
|
||||
return context
|
||||
}
|
||||
|
||||
async connect(): Promise<this> {
|
||||
return this
|
||||
}
|
||||
@@ -225,10 +246,12 @@ export class AgentBridgeClient {
|
||||
const startedAt = Date.now()
|
||||
const action = String(payload.action || '')
|
||||
const shouldLogRequest = action !== 'get_output'
|
||||
const runtimeContext = shouldLogRequest ? this.runtimeContext(payload) : undefined
|
||||
if (shouldLogRequest) {
|
||||
bridgeLogger.info({
|
||||
endpoint: this.endpoint,
|
||||
timeoutMs,
|
||||
runtime: runtimeContext,
|
||||
request: this.summarizePayload(payload),
|
||||
}, '[agent-bridge-client] request')
|
||||
}
|
||||
@@ -242,6 +265,7 @@ export class AgentBridgeClient {
|
||||
error.response = response
|
||||
bridgeLogger.warn({
|
||||
durationMs: Date.now() - startedAt,
|
||||
runtime: runtimeContext,
|
||||
response: this.summarizeResponse(response as Record<string, unknown>),
|
||||
}, '[agent-bridge-client] request rejected')
|
||||
throw error
|
||||
@@ -249,6 +273,7 @@ export class AgentBridgeClient {
|
||||
if (shouldLogRequest) {
|
||||
bridgeLogger.info({
|
||||
durationMs: Date.now() - startedAt,
|
||||
runtime: runtimeContext,
|
||||
response: this.summarizeResponse(response as Record<string, unknown>),
|
||||
}, '[agent-bridge-client] response')
|
||||
}
|
||||
@@ -258,6 +283,7 @@ export class AgentBridgeClient {
|
||||
bridgeLogger.error({
|
||||
durationMs: Date.now() - startedAt,
|
||||
err: { message: err?.message, name: err?.name },
|
||||
runtime: runtimeContext,
|
||||
request: this.summarizePayload(payload),
|
||||
}, '[agent-bridge-client] request failed')
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - 用户自定义: HERMES_HOME 环境变量
|
||||
*/
|
||||
|
||||
import { resolve, join } from 'path'
|
||||
import { basename, dirname, resolve, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
/**
|
||||
@@ -38,6 +38,20 @@ export function detectHermesHome(): string {
|
||||
return resolve(homedir(), '.hermes')
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the Hermes root data directory.
|
||||
*
|
||||
* `HERMES_HOME` may intentionally point at a profile directory when launching a
|
||||
* specific gateway (`<root>/profiles/<name>`). Web UI profile management needs
|
||||
* the root directory so it can read `active_profile` and enumerate profiles.
|
||||
*/
|
||||
export function detectHermesRootHome(): string {
|
||||
const home = detectHermesHome()
|
||||
const parent = dirname(home)
|
||||
if (basename(parent) === 'profiles') return dirname(parent)
|
||||
return home
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Hermes CLI 二进制文件路径
|
||||
* @param customBin 自定义的 hermes 二进制路径
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { resolve, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, existsSync } from 'fs'
|
||||
import { detectHermesHome } from './hermes-path'
|
||||
import { detectHermesRootHome } from './hermes-path'
|
||||
|
||||
const HERMES_BASE = detectHermesHome()
|
||||
export function getHermesBaseDir(): string {
|
||||
return detectHermesRootHome()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active profile's home directory.
|
||||
@@ -11,15 +12,16 @@ const HERMES_BASE = detectHermesHome()
|
||||
* other → ~/.hermes/profiles/{name}/
|
||||
*/
|
||||
export function getActiveProfileDir(): string {
|
||||
const activeFile = join(HERMES_BASE, 'active_profile')
|
||||
const hermesBase = getHermesBaseDir()
|
||||
const activeFile = join(hermesBase, 'active_profile')
|
||||
try {
|
||||
const name = readFileSync(activeFile, 'utf-8').trim()
|
||||
if (name && name !== 'default') {
|
||||
const dir = join(HERMES_BASE, 'profiles', name)
|
||||
const dir = join(hermesBase, 'profiles', name)
|
||||
if (existsSync(dir)) return dir
|
||||
}
|
||||
} catch { }
|
||||
return HERMES_BASE
|
||||
return hermesBase
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +49,7 @@ export function getActiveEnvPath(): string {
|
||||
* Get the active profile name.
|
||||
*/
|
||||
export function getActiveProfileName(): string {
|
||||
const activeFile = join(HERMES_BASE, 'active_profile')
|
||||
const activeFile = join(getHermesBaseDir(), 'active_profile')
|
||||
try {
|
||||
const name = readFileSync(activeFile, 'utf-8').trim()
|
||||
return name || 'default'
|
||||
@@ -62,7 +64,8 @@ export function getActiveProfileName(): string {
|
||||
* other → ~/.hermes/profiles/{name}/
|
||||
*/
|
||||
export function getProfileDir(name: string): string {
|
||||
if (!name || name === 'default') return HERMES_BASE
|
||||
const dir = join(HERMES_BASE, 'profiles', name)
|
||||
return existsSync(dir) ? dir : HERMES_BASE
|
||||
const hermesBase = getHermesBaseDir()
|
||||
if (!name || name === 'default') return hermesBase
|
||||
const dir = join(hermesBase, 'profiles', name)
|
||||
return existsSync(dir) ? dir : hermesBase
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user