diff --git a/packages/server/src/controllers/hermes/logs.ts b/packages/server/src/controllers/hermes/logs.ts index 447fdc7..ac709b8 100644 --- a/packages/server/src/controllers/hermes/logs.ts +++ b/packages/server/src/controllers/hermes/logs.ts @@ -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(.*)$/) diff --git a/packages/server/src/controllers/hermes/profiles.ts b/packages/server/src/controllers/hermes/profiles.ts index 690b1bd..614dd10 100644 --- a/packages/server/src/controllers/hermes/profiles.ts +++ b/packages/server/src/controllers/hermes/profiles.ts @@ -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 { + 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 { 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) diff --git a/packages/server/src/services/hermes/agent-bridge/client.ts b/packages/server/src/services/hermes/agent-bridge/client.ts index a32f0a9..acc9ad1 100644 --- a/packages/server/src/services/hermes/agent-bridge/client.ts +++ b/packages/server/src/services/hermes/agent-bridge/client.ts @@ -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): Record { + const requestedProfile = typeof payload.profile === 'string' ? payload.profile.trim() : '' + let profile = requestedProfile || 'default' + try { + if (!requestedProfile) profile = getActiveProfileName() + } catch {} + + const context: Record = { + 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 { 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), }, '[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), }, '[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') } diff --git a/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py index 23d1567..373c36a 100644 --- a/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py +++ b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py @@ -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: diff --git a/packages/server/src/services/hermes/hermes-path.ts b/packages/server/src/services/hermes/hermes-path.ts index d6b37ee..a0cab91 100644 --- a/packages/server/src/services/hermes/hermes-path.ts +++ b/packages/server/src/services/hermes/hermes-path.ts @@ -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 (`/profiles/`). 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 二进制路径 diff --git a/packages/server/src/services/hermes/hermes-profile.ts b/packages/server/src/services/hermes/hermes-profile.ts index 483d08c..0e61aa0 100644 --- a/packages/server/src/services/hermes/hermes-profile.ts +++ b/packages/server/src/services/hermes/hermes-profile.ts @@ -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 } diff --git a/tests/server/agent-bridge-profile-env.test.ts b/tests/server/agent-bridge-profile-env.test.ts new file mode 100644 index 0000000..4d1e792 --- /dev/null +++ b/tests/server/agent-bridge-profile-env.test.ts @@ -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 { + 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', + }) + }) +}) diff --git a/tests/server/profiles-routes.test.ts b/tests/server/profiles-routes.test.ts index 6f53fa4..bf673eb 100644 --- a/tests/server/profiles-routes.test.ts +++ b/tests/server/profiles-routes.test.ts @@ -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) + }) + }) })