Fix bridge profile environment isolation (#796)

This commit is contained in:
ekko
2026-05-16 20:27:23 +08:00
committed by GitHub
parent 7d7c8b7321
commit 8357c8ed84
8 changed files with 602 additions and 133 deletions
+20 -1
View File
@@ -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',
})
})
})
+51 -18
View File
@@ -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)
})
})
})