Fix bridge profile environment isolation (#796)
This commit is contained in:
@@ -11,6 +11,24 @@ interface LogEntry {
|
||||
timestamp: string; level: string; logger: string; message: string; raw: string
|
||||
}
|
||||
|
||||
function appendPinoContext(message: string, obj: any): string {
|
||||
const parts: string[] = []
|
||||
const runtime = obj.runtime && typeof obj.runtime === 'object' ? obj.runtime : null
|
||||
if (runtime) {
|
||||
if (runtime.profile) parts.push(`profile=${runtime.profile}`)
|
||||
if (runtime.cwd) parts.push(`cwd=${runtime.cwd}`)
|
||||
if (runtime.profile_dir) parts.push(`profile_dir=${runtime.profile_dir}`)
|
||||
if (runtime.config_path) parts.push(`config=${runtime.config_path}`)
|
||||
} else if (obj.profile) {
|
||||
parts.push(`profile=${obj.profile}`)
|
||||
}
|
||||
if (obj.request?.action) parts.push(`action=${obj.request.action}`)
|
||||
if (obj.sessionId) parts.push(`session=${obj.sessionId}`)
|
||||
if (obj.runId) parts.push(`run=${obj.runId}`)
|
||||
if (obj.status) parts.push(`status=${obj.status}`)
|
||||
return parts.length > 0 ? `${message} ${parts.join(' ')}` : message
|
||||
}
|
||||
|
||||
function parseLine(line: string): LogEntry {
|
||||
try {
|
||||
const obj = JSON.parse(line)
|
||||
@@ -20,7 +38,8 @@ function parseLine(line: string): LogEntry {
|
||||
// Pino 日志格式: { level, time, msg, name (logger name), hostname, pid, ... }
|
||||
const loggerName = obj.name || obj.logger || 'app'
|
||||
const message = obj.msg || (obj.err ? obj.err.message : '')
|
||||
return { timestamp: ts, level: levelMap[obj.level] || 'INFO', logger: loggerName, message: typeof message === 'string' ? message : JSON.stringify(message), raw: line }
|
||||
const baseMessage = typeof message === 'string' ? message : JSON.stringify(message)
|
||||
return { timestamp: ts, level: levelMap[obj.level] || 'INFO', logger: loggerName, message: appendPinoContext(baseMessage, obj), raw: line }
|
||||
}
|
||||
} catch {}
|
||||
let match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createReadStream, existsSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { createReadStream, existsSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'fs'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
@@ -7,21 +7,93 @@ import { SessionDeleter } from '../../services/hermes/session-deleter'
|
||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||
import { logger } from '../../services/logger'
|
||||
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
|
||||
import { detectHermesHome } from '../../services/hermes/hermes-path'
|
||||
import { detectHermesRootHome } from '../../services/hermes/hermes-path'
|
||||
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
|
||||
import type { HermesProfile } from '../../services/hermes/hermes-cli'
|
||||
|
||||
const RESERVED_PROFILE_NAMES = new Set([
|
||||
'hermes', 'default', 'test', 'tmp', 'root', 'sudo',
|
||||
])
|
||||
|
||||
const HERMES_SUBCOMMAND_PROFILE_NAMES = new Set([
|
||||
'chat', 'model', 'gateway', 'setup', 'whatsapp', 'login', 'logout',
|
||||
'status', 'cron', 'doctor', 'dump', 'config', 'pairing', 'skills', 'tools',
|
||||
'mcp', 'sessions', 'insights', 'version', 'update', 'uninstall',
|
||||
'profile', 'plugins', 'honcho', 'acp',
|
||||
])
|
||||
|
||||
function normalizeProfileName(name: string): string {
|
||||
return String(name || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function isForbiddenProfileName(name: string): boolean {
|
||||
const normalized = normalizeProfileName(name)
|
||||
if (!normalized || normalized === 'default') return false
|
||||
return RESERVED_PROFILE_NAMES.has(normalized) || HERMES_SUBCOMMAND_PROFILE_NAMES.has(normalized)
|
||||
}
|
||||
|
||||
function getActiveProfileFile(): string {
|
||||
return join(detectHermesRootHome(), 'active_profile')
|
||||
}
|
||||
|
||||
function listProfilesFromDisk(activeProfileName: string): HermesProfile[] {
|
||||
const base = detectHermesRootHome()
|
||||
const profiles: HermesProfile[] = [{
|
||||
name: 'default',
|
||||
active: activeProfileName === 'default',
|
||||
model: '—',
|
||||
gateway: 'stopped',
|
||||
alias: '',
|
||||
}]
|
||||
const profilesDir = join(base, 'profiles')
|
||||
if (!existsSync(profilesDir)) return profiles
|
||||
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const name = entry.name
|
||||
const dir = join(profilesDir, name)
|
||||
if (!existsSync(join(dir, 'config.yaml')) && !existsSync(dir)) continue
|
||||
profiles.push({
|
||||
name,
|
||||
active: name === activeProfileName,
|
||||
model: '—',
|
||||
gateway: 'stopped',
|
||||
alias: '',
|
||||
})
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
function profileExistsForManualSwitch(name: string): boolean {
|
||||
const base = detectHermesHome()
|
||||
const base = detectHermesRootHome()
|
||||
if (!name || name === 'default') return true
|
||||
return existsSync(join(base, 'profiles', name, 'config.yaml')) || existsSync(join(base, 'profiles', name))
|
||||
}
|
||||
|
||||
function deleteForbiddenProfileFromDisk(name: string): boolean {
|
||||
if (!isForbiddenProfileName(name)) return false
|
||||
const base = detectHermesRootHome()
|
||||
const profileDir = join(base, 'profiles', name)
|
||||
if (!existsSync(profileDir)) return false
|
||||
rmSync(profileDir, { recursive: true, force: true })
|
||||
try {
|
||||
if (normalizeProfileName(getActiveProfileName()) === normalizeProfileName(name)) {
|
||||
writeFileSync(getActiveProfileFile(), 'default\n', 'utf-8')
|
||||
}
|
||||
} catch {}
|
||||
logger.warn('[deleteProfile] removed reserved profile "%s" from disk after Hermes CLI rejected deletion', name)
|
||||
return true
|
||||
}
|
||||
|
||||
async function useProfileWithFallback(name: string): Promise<string> {
|
||||
if (isForbiddenProfileName(name)) {
|
||||
throw new Error(`Profile name '${name}' is reserved and cannot be activated`)
|
||||
}
|
||||
try {
|
||||
return await hermesCli.useProfile(name)
|
||||
} catch (err: any) {
|
||||
if (!profileExistsForManualSwitch(name)) throw err
|
||||
|
||||
const base = detectHermesHome()
|
||||
const base = detectHermesRootHome()
|
||||
writeFileSync(join(base, 'active_profile'), `${name}\n`, 'utf-8')
|
||||
logger.warn(err, '[switchProfile] hermes profile use failed; wrote active_profile directly for existing profile "%s"', name)
|
||||
return `Switched to profile ${name}`
|
||||
@@ -30,7 +102,18 @@ async function useProfileWithFallback(name: string): Promise<string> {
|
||||
|
||||
export async function list(ctx: any) {
|
||||
try {
|
||||
const profiles = await hermesCli.listProfiles()
|
||||
let profiles: HermesProfile[]
|
||||
try {
|
||||
profiles = await hermesCli.listProfiles()
|
||||
} catch (err: any) {
|
||||
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
||||
const activeProfileName = getActiveProfileName()
|
||||
if (!isForbiddenProfileName(activeProfileName)) throw err
|
||||
|
||||
logger.warn(err, '[listProfiles] active_profile "%s" is invalid/reserved; resetting to default and listing profiles from disk', activeProfileName)
|
||||
writeFileSync(getActiveProfileFile(), 'default\n', 'utf-8')
|
||||
profiles = listProfilesFromDisk('default')
|
||||
}
|
||||
|
||||
// Override active flag from the authoritative source (active_profile file)
|
||||
// CLI output may be stale, but the file is written by hermes profile use
|
||||
@@ -63,6 +146,11 @@ export async function create(ctx: any) {
|
||||
ctx.body = { error: 'Missing profile name' }
|
||||
return
|
||||
}
|
||||
if (isForbiddenProfileName(name)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile name '${name}' is reserved and cannot be created` }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const output = await hermesCli.createProfile(name, clone)
|
||||
|
||||
@@ -140,6 +228,8 @@ export async function remove(ctx: any) {
|
||||
const ok = await hermesCli.deleteProfile(name)
|
||||
if (ok) {
|
||||
ctx.body = { success: true }
|
||||
} else if (deleteForbiddenProfileFromDisk(name)) {
|
||||
ctx.body = { success: true, fallback: 'removed_reserved_profile_from_disk' }
|
||||
} else {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to delete profile' }
|
||||
@@ -178,6 +268,11 @@ export async function switchProfile(ctx: any) {
|
||||
ctx.body = { error: 'Missing profile name' }
|
||||
return
|
||||
}
|
||||
if (isForbiddenProfileName(name)) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Profile name '${name}' is reserved and cannot be activated` }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const output = await useProfileWithFallback(name)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join, resolve } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'hermes-bridge-profile-env-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) await rm(tempDir, { recursive: true, force: true })
|
||||
tempDir = ''
|
||||
})
|
||||
|
||||
async function runBridgeProbe(script: string): Promise<any> {
|
||||
const bridgePath = resolve('packages/server/src/services/hermes/agent-bridge/hermes_bridge.py')
|
||||
const { stdout } = await execFileAsync('python3', ['-c', script], {
|
||||
cwd: resolve('.'),
|
||||
env: {
|
||||
...process.env,
|
||||
BRIDGE_PATH: bridgePath,
|
||||
TEST_HERMES_HOME: tempDir,
|
||||
},
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
return JSON.parse(stdout)
|
||||
}
|
||||
|
||||
describe('agent bridge profile environment', () => {
|
||||
it('runs agent calls with the requested profile HERMES_HOME and restores the bridge home', async () => {
|
||||
const profileHome = join(tempDir, 'profiles', 'work')
|
||||
await mkdir(profileHome, { recursive: true })
|
||||
await writeFile(join(tempDir, 'config.yaml'), 'model:\n default: default-model\n', 'utf-8')
|
||||
await writeFile(join(tempDir, '.env'), 'OPENAI_API_KEY=default-openai\nBASE_ONLY_TOKEN=base-token\n', 'utf-8')
|
||||
await writeFile(join(profileHome, 'config.yaml'), 'model:\n default: work-model\n', 'utf-8')
|
||||
await writeFile(join(profileHome, '.env'), 'GLM_API_KEY=work-glm\n', 'utf-8')
|
||||
const expectedProfileHome = await realpath(profileHome)
|
||||
|
||||
const result = await runBridgeProbe(`
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules["hermes_bridge"] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
root = os.environ["TEST_HERMES_HOME"]
|
||||
profile_home = os.path.join(root, "profiles", "work")
|
||||
os.environ["HERMES_HOME"] = root
|
||||
os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = root
|
||||
os.environ["OPENAI_API_KEY"] = "shell-openai"
|
||||
os.environ["GLM_API_KEY"] = "shell-glm"
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self):
|
||||
self.seen_home = None
|
||||
self.seen_openai = None
|
||||
self.seen_glm = None
|
||||
self.seen_base_only = None
|
||||
|
||||
def run_conversation(self, message, **kwargs):
|
||||
self.seen_home = os.environ.get("HERMES_HOME")
|
||||
self.seen_openai = os.environ.get("OPENAI_API_KEY")
|
||||
self.seen_glm = os.environ.get("GLM_API_KEY")
|
||||
self.seen_base_only = os.environ.get("BASE_ONLY_TOKEN")
|
||||
return {"messages": [{"role": "assistant", "content": "ok"}]}
|
||||
|
||||
agent = FakeAgent()
|
||||
with bridge._profile_env("work"):
|
||||
result = agent.run_conversation("hello")
|
||||
|
||||
print(json.dumps({
|
||||
"seen_home": agent.seen_home,
|
||||
"seen_openai": agent.seen_openai,
|
||||
"seen_glm": agent.seen_glm,
|
||||
"seen_base_only": agent.seen_base_only,
|
||||
"restored_home": os.environ.get("HERMES_HOME"),
|
||||
"restored_openai": os.environ.get("OPENAI_API_KEY"),
|
||||
"restored_glm": os.environ.get("GLM_API_KEY"),
|
||||
"restored_base_only": os.environ.get("BASE_ONLY_TOKEN"),
|
||||
"status": "complete" if result.get("messages") else "error",
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
seen_home: expectedProfileHome,
|
||||
seen_openai: null,
|
||||
seen_glm: 'work-glm',
|
||||
seen_base_only: null,
|
||||
restored_home: tempDir,
|
||||
restored_openai: 'shell-openai',
|
||||
restored_glm: 'shell-glm',
|
||||
restored_base_only: null,
|
||||
status: 'complete',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a profile-scoped bridge home back to the Hermes root for profile lookup', async () => {
|
||||
const agentRoot = join(tempDir, 'hermes-agent')
|
||||
const profileHome = join(tempDir, 'profiles', 'work')
|
||||
await mkdir(agentRoot, { recursive: true })
|
||||
await mkdir(profileHome, { recursive: true })
|
||||
await writeFile(join(agentRoot, 'run_agent.py'), '', 'utf-8')
|
||||
await writeFile(join(profileHome, 'config.yaml'), 'model:\n default: work-model\n', 'utf-8')
|
||||
const expectedRoot = await realpath(tempDir)
|
||||
const expectedProfileHome = await realpath(profileHome)
|
||||
|
||||
const result = await runBridgeProbe(`
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules["hermes_bridge"] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
root = os.environ["TEST_HERMES_HOME"]
|
||||
agent_root = os.path.join(root, "hermes-agent")
|
||||
profile_home = os.path.join(root, "profiles", "work")
|
||||
bridge._set_path_env(agent_root, profile_home)
|
||||
|
||||
print(json.dumps({
|
||||
"home": os.environ.get("HERMES_HOME"),
|
||||
"base": os.environ.get("HERMES_AGENT_BRIDGE_BASE_HOME"),
|
||||
"profile_home": str(bridge._profile_home("work")),
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
home: expectedProfileHome,
|
||||
base: expectedRoot,
|
||||
profile_home: expectedProfileHome,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps inherited profile env keys for default profile compatibility', async () => {
|
||||
await mkdir(join(tempDir, 'profiles', 'work'), { recursive: true })
|
||||
await writeFile(join(tempDir, '.env'), 'OPENAI_API_KEY=default-openai\n', 'utf-8')
|
||||
await writeFile(join(tempDir, 'profiles', 'work', '.env'), 'GLM_API_KEY=work-glm\n', 'utf-8')
|
||||
await writeFile(join(tempDir, 'config.yaml'), 'model:\n default: default-model\n', 'utf-8')
|
||||
|
||||
const result = await runBridgeProbe(`
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hermes_bridge", os.environ["BRIDGE_PATH"])
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
sys.modules["hermes_bridge"] = bridge
|
||||
spec.loader.exec_module(bridge)
|
||||
|
||||
root = os.environ["TEST_HERMES_HOME"]
|
||||
os.environ["HERMES_HOME"] = root
|
||||
os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = root
|
||||
os.environ["OPENAI_API_KEY"] = "shell-openai"
|
||||
os.environ["GLM_API_KEY"] = "shell-glm"
|
||||
|
||||
with bridge._profile_env("default"):
|
||||
inside = {
|
||||
"openai": os.environ.get("OPENAI_API_KEY"),
|
||||
"glm": os.environ.get("GLM_API_KEY"),
|
||||
}
|
||||
|
||||
print(json.dumps({
|
||||
"inside": inside,
|
||||
"restored_openai": os.environ.get("OPENAI_API_KEY"),
|
||||
"restored_glm": os.environ.get("GLM_API_KEY"),
|
||||
}))
|
||||
`)
|
||||
|
||||
expect(result).toEqual({
|
||||
inside: {
|
||||
openai: 'default-openai',
|
||||
glm: 'shell-glm',
|
||||
},
|
||||
restored_openai: 'shell-openai',
|
||||
restored_glm: 'shell-glm',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Mock hermes-cli
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
@@ -19,27 +23,17 @@ vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
import * as hermesCli from '../../packages/server/src/services/hermes/hermes-cli'
|
||||
|
||||
describe('Profile Routes', () => {
|
||||
const originalHermesHome = process.env.HERMES_HOME
|
||||
const tempHomes: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ensureApiServerConfig (via active profile switch)', () => {
|
||||
it('should inject api_server config when missing', async () => {
|
||||
// This tests the logic that profiles.ts ensures api_server config exists
|
||||
// We test the ensureApiServerConfig behavior indirectly through the module
|
||||
const { existsSync, readFileSync, writeFileSync } = await import('fs')
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
readFileSync: vi.fn().mockReturnValue('platforms: {}'),
|
||||
writeFileSync: vi.fn(),
|
||||
createReadStream: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}))
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (originalHermesHome === undefined) delete process.env.HERMES_HOME
|
||||
else process.env.HERMES_HOME = originalHermesHome
|
||||
await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('hermes-cli wrapper', () => {
|
||||
@@ -84,4 +78,43 @@ describe('Profile Routes', () => {
|
||||
expect(hermesCli.renameProfile).toHaveBeenCalledWith('old', 'new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('profile deletion fallback', () => {
|
||||
it('removes a reserved profile directory when Hermes CLI refuses to delete it', async () => {
|
||||
const hermesHome = await mkdtemp(join(tmpdir(), 'hermes-profile-delete-'))
|
||||
tempHomes.push(hermesHome)
|
||||
process.env.HERMES_HOME = hermesHome
|
||||
const badProfileDir = join(hermesHome, 'profiles', 'hermes')
|
||||
await mkdir(badProfileDir, { recursive: true })
|
||||
await writeFile(join(badProfileDir, 'config.yaml'), 'model:\n default: bad\n', 'utf-8')
|
||||
await writeFile(join(hermesHome, 'active_profile'), 'hermes\n', 'utf-8')
|
||||
vi.mocked(hermesCli.deleteProfile).mockResolvedValue(false)
|
||||
const { remove } = await import('../../packages/server/src/controllers/hermes/profiles')
|
||||
const ctx: any = { params: { name: 'hermes' }, status: 200, body: undefined }
|
||||
|
||||
await remove(ctx)
|
||||
|
||||
expect(ctx.status).toBe(200)
|
||||
expect(ctx.body).toEqual({ success: true, fallback: 'removed_reserved_profile_from_disk' })
|
||||
expect(existsSync(badProfileDir)).toBe(false)
|
||||
expect(readFileSync(join(hermesHome, 'active_profile'), 'utf-8')).toBe('default\n')
|
||||
})
|
||||
|
||||
it('does not bypass Hermes CLI failures for normal profile names', async () => {
|
||||
const hermesHome = await mkdtemp(join(tmpdir(), 'hermes-profile-delete-'))
|
||||
tempHomes.push(hermesHome)
|
||||
process.env.HERMES_HOME = hermesHome
|
||||
const profileDir = join(hermesHome, 'profiles', 'work')
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
vi.mocked(hermesCli.deleteProfile).mockResolvedValue(false)
|
||||
const { remove } = await import('../../packages/server/src/controllers/hermes/profiles')
|
||||
const ctx: any = { params: { name: 'work' }, status: 200, body: undefined }
|
||||
|
||||
await remove(ctx)
|
||||
|
||||
expect(ctx.status).toBe(500)
|
||||
expect(ctx.body).toEqual({ error: 'Failed to delete profile' })
|
||||
expect(existsSync(profileDir)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user