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 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 { function parseLine(line: string): LogEntry {
try { try {
const obj = JSON.parse(line) const obj = JSON.parse(line)
@@ -20,7 +38,8 @@ function parseLine(line: string): LogEntry {
// Pino 日志格式: { level, time, msg, name (logger name), hostname, pid, ... } // Pino 日志格式: { level, time, msg, name (logger name), hostname, pid, ... }
const loggerName = obj.name || obj.logger || 'app' const loggerName = obj.name || obj.logger || 'app'
const message = obj.msg || (obj.err ? obj.err.message : '') 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 {} } 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(.*)$/) 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 { mkdir, writeFile } from 'fs/promises'
import { basename, join } from 'path' import { basename, join } from 'path'
import { tmpdir } from 'os' import { tmpdir } from 'os'
@@ -7,21 +7,93 @@ import { SessionDeleter } from '../../services/hermes/session-deleter'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap' import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { logger } from '../../services/logger' import { logger } from '../../services/logger'
import { smartCloneCleanup } from '../../services/hermes/profile-credentials' 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 { function profileExistsForManualSwitch(name: string): boolean {
const base = detectHermesHome() const base = detectHermesRootHome()
if (!name || name === 'default') return true if (!name || name === 'default') return true
return existsSync(join(base, 'profiles', name, 'config.yaml')) || existsSync(join(base, 'profiles', name)) 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> { async function useProfileWithFallback(name: string): Promise<string> {
if (isForbiddenProfileName(name)) {
throw new Error(`Profile name '${name}' is reserved and cannot be activated`)
}
try { try {
return await hermesCli.useProfile(name) return await hermesCli.useProfile(name)
} catch (err: any) { } catch (err: any) {
if (!profileExistsForManualSwitch(name)) throw err if (!profileExistsForManualSwitch(name)) throw err
const base = detectHermesHome() const base = detectHermesRootHome()
writeFileSync(join(base, 'active_profile'), `${name}\n`, 'utf-8') 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) logger.warn(err, '[switchProfile] hermes profile use failed; wrote active_profile directly for existing profile "%s"', name)
return `Switched to profile ${name}` return `Switched to profile ${name}`
@@ -30,7 +102,18 @@ async function useProfileWithFallback(name: string): Promise<string> {
export async function list(ctx: any) { export async function list(ctx: any) {
try { 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) // Override active flag from the authoritative source (active_profile file)
// CLI output may be stale, but the file is written by hermes profile use // 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' } ctx.body = { error: 'Missing profile name' }
return return
} }
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved and cannot be created` }
return
}
try { try {
const output = await hermesCli.createProfile(name, clone) const output = await hermesCli.createProfile(name, clone)
@@ -140,6 +228,8 @@ export async function remove(ctx: any) {
const ok = await hermesCli.deleteProfile(name) const ok = await hermesCli.deleteProfile(name)
if (ok) { if (ok) {
ctx.body = { success: true } ctx.body = { success: true }
} else if (deleteForbiddenProfileFromDisk(name)) {
ctx.body = { success: true, fallback: 'removed_reserved_profile_from_disk' }
} else { } else {
ctx.status = 500 ctx.status = 500
ctx.body = { error: 'Failed to delete profile' } ctx.body = { error: 'Failed to delete profile' }
@@ -178,6 +268,11 @@ export async function switchProfile(ctx: any) {
ctx.body = { error: 'Missing profile name' } ctx.body = { error: 'Missing profile name' }
return return
} }
if (isForbiddenProfileName(name)) {
ctx.status = 400
ctx.body = { error: `Profile name '${name}' is reserved and cannot be activated` }
return
}
try { try {
const output = await useProfileWithFallback(name) const output = await useProfileWithFallback(name)
@@ -1,7 +1,9 @@
import { setTimeout as delay } from 'timers/promises' import { setTimeout as delay } from 'timers/promises'
import { createConnection, type Socket } from 'net' import { createConnection, type Socket } from 'net'
import { URL } from 'url' import { URL } from 'url'
import { join } from 'path'
import { bridgeLogger } from '../../logger' import { bridgeLogger } from '../../logger'
import { getActiveProfileName, getProfileDir } from '../hermes-profile'
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = process.platform === 'win32' export const DEFAULT_AGENT_BRIDGE_ENDPOINT = process.platform === 'win32'
? 'tcp://127.0.0.1:18765' ? 'tcp://127.0.0.1:18765'
@@ -122,6 +124,25 @@ export class AgentBridgeClient {
return summary 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> { async connect(): Promise<this> {
return this return this
} }
@@ -225,10 +246,12 @@ export class AgentBridgeClient {
const startedAt = Date.now() const startedAt = Date.now()
const action = String(payload.action || '') const action = String(payload.action || '')
const shouldLogRequest = action !== 'get_output' const shouldLogRequest = action !== 'get_output'
const runtimeContext = shouldLogRequest ? this.runtimeContext(payload) : undefined
if (shouldLogRequest) { if (shouldLogRequest) {
bridgeLogger.info({ bridgeLogger.info({
endpoint: this.endpoint, endpoint: this.endpoint,
timeoutMs, timeoutMs,
runtime: runtimeContext,
request: this.summarizePayload(payload), request: this.summarizePayload(payload),
}, '[agent-bridge-client] request') }, '[agent-bridge-client] request')
} }
@@ -242,6 +265,7 @@ export class AgentBridgeClient {
error.response = response error.response = response
bridgeLogger.warn({ bridgeLogger.warn({
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
runtime: runtimeContext,
response: this.summarizeResponse(response as Record<string, unknown>), response: this.summarizeResponse(response as Record<string, unknown>),
}, '[agent-bridge-client] request rejected') }, '[agent-bridge-client] request rejected')
throw error throw error
@@ -249,6 +273,7 @@ export class AgentBridgeClient {
if (shouldLogRequest) { if (shouldLogRequest) {
bridgeLogger.info({ bridgeLogger.info({
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
runtime: runtimeContext,
response: this.summarizeResponse(response as Record<string, unknown>), response: this.summarizeResponse(response as Record<string, unknown>),
}, '[agent-bridge-client] response') }, '[agent-bridge-client] response')
} }
@@ -258,6 +283,7 @@ export class AgentBridgeClient {
bridgeLogger.error({ bridgeLogger.error({
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
err: { message: err?.message, name: err?.name }, err: { message: err?.message, name: err?.name },
runtime: runtimeContext,
request: this.summarizePayload(payload), request: this.summarizePayload(payload),
}, '[agent-bridge-client] request failed') }, '[agent-bridge-client] request failed')
} }
@@ -21,6 +21,7 @@ import threading
import time import time
import traceback import traceback
import uuid import uuid
from contextlib import contextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse 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() 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: def _jsonable(value: Any) -> Any:
try: try:
json.dumps(value) json.dumps(value)
@@ -156,7 +163,7 @@ def _hermes_home() -> Path:
def _base_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: 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 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: 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)) os.environ["HERMES_AGENT_ROOT"] = str(_discover_agent_root(agent_root))
resolved_home = str(_discover_hermes_home(hermes_home)) resolved_home = _discover_hermes_home(hermes_home)
os.environ["HERMES_HOME"] = resolved_home os.environ["HERMES_HOME"] = str(resolved_home)
os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = resolved_home os.environ["HERMES_AGENT_BRIDGE_BASE_HOME"] = str(_normalize_base_home(resolved_home))
def _ensure_agent_imports() -> None: 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. """Temporarily set HERMES_HOME to the profile directory.
Returns the original HERMES_HOME value to restore later. 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) profile_home = _profile_home(profile)
if not (profile_home / "config.yaml").exists(): if not (profile_home / "config.yaml").exists():
return os.environ.get("HERMES_HOME") return os.environ.get("HERMES_HOME")
@@ -226,6 +272,49 @@ def _restore_profile_env(original: str | None) -> None:
os.environ.pop("HERMES_HOME", 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: def _resolve_model(cfg: dict[str, Any]) -> str:
env_model = ( env_model = (
os.environ.get("HERMES_MODEL", "") os.environ.get("HERMES_MODEL", "")
@@ -404,8 +493,7 @@ class AgentPool:
_suppress_bridge_platform_hint() _suppress_bridge_platform_hint()
from run_agent import AIAgent from run_agent import AIAgent
original_home = _apply_profile_env(profile) with _profile_env(profile):
try:
cfg = _load_cfg() cfg = _load_cfg()
resolved_model = _resolve_model(cfg) resolved_model = _resolve_model(cfg)
runtime = _resolve_runtime(resolved_model) runtime = _resolve_runtime(resolved_model)
@@ -460,8 +548,6 @@ class AgentPool:
) )
self._sessions[session_id] = session self._sessions[session_id] = session
return session return session
finally:
_restore_profile_env(original_home)
def _install_compression_hook(self, agent: Any, session_id: str) -> None: def _install_compression_hook(self, agent: Any, session_id: str) -> None:
original = getattr(agent, "_compress_context", 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: 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: with self._run_lock:
def stream_callback(delta: str) -> None: with _profile_env(profile):
with self._lock: def stream_callback(delta: str) -> None:
record.deltas.append(str(delta)) 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: 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 previous_approval_callback = None
self._prepersist_user_message(session, message, storage_message, conversation_history, profile) previous_exec_ask = os.environ.get("HERMES_EXEC_ASK")
db_count_after_prepersist = self._session_db_message_count(session.session_id, profile) approval_session_token = None
if force_compress: registered_gateway_approval_session = None
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:
try: 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: previous_approval_callback = _get_approval_callback()
unregister_gateway_notify(registered_gateway_approval_session) set_approval_callback(self._approval_callback(session.session_id))
reset_current_session_key(approval_session_token) 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: except Exception:
pass pass
if previous_exec_ask is None: if approval_session_token is not None:
os.environ.pop("HERMES_EXEC_ASK", None) try:
else: from tools.approval import reset_current_session_key, unregister_gateway_notify
os.environ["HERMES_EXEC_ASK"] = previous_exec_ask
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]: def interrupt(self, session_id: str, message: str | None = None) -> dict[str, Any]:
with self._lock: with self._lock:
@@ -7,7 +7,7 @@
* - 用户自定义: HERMES_HOME 环境变量 * - 用户自定义: HERMES_HOME 环境变量
*/ */
import { resolve, join } from 'path' import { basename, dirname, resolve, join } from 'path'
import { homedir } from 'os' import { homedir } from 'os'
/** /**
@@ -38,6 +38,20 @@ export function detectHermesHome(): string {
return resolve(homedir(), '.hermes') 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 二进制文件路径 * 获取 Hermes CLI 二进制文件路径
* @param customBin 自定义的 hermes 二进制路径 * @param customBin 自定义的 hermes 二进制路径
@@ -1,9 +1,10 @@
import { resolve, join } from 'path' import { join } from 'path'
import { homedir } from 'os'
import { readFileSync, existsSync } from 'fs' 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. * Get the active profile's home directory.
@@ -11,15 +12,16 @@ const HERMES_BASE = detectHermesHome()
* other → ~/.hermes/profiles/{name}/ * other → ~/.hermes/profiles/{name}/
*/ */
export function getActiveProfileDir(): string { export function getActiveProfileDir(): string {
const activeFile = join(HERMES_BASE, 'active_profile') const hermesBase = getHermesBaseDir()
const activeFile = join(hermesBase, 'active_profile')
try { try {
const name = readFileSync(activeFile, 'utf-8').trim() const name = readFileSync(activeFile, 'utf-8').trim()
if (name && name !== 'default') { if (name && name !== 'default') {
const dir = join(HERMES_BASE, 'profiles', name) const dir = join(hermesBase, 'profiles', name)
if (existsSync(dir)) return dir if (existsSync(dir)) return dir
} }
} catch { } } catch { }
return HERMES_BASE return hermesBase
} }
/** /**
@@ -47,7 +49,7 @@ export function getActiveEnvPath(): string {
* Get the active profile name. * Get the active profile name.
*/ */
export function getActiveProfileName(): string { export function getActiveProfileName(): string {
const activeFile = join(HERMES_BASE, 'active_profile') const activeFile = join(getHermesBaseDir(), 'active_profile')
try { try {
const name = readFileSync(activeFile, 'utf-8').trim() const name = readFileSync(activeFile, 'utf-8').trim()
return name || 'default' return name || 'default'
@@ -62,7 +64,8 @@ export function getActiveProfileName(): string {
* other → ~/.hermes/profiles/{name}/ * other → ~/.hermes/profiles/{name}/
*/ */
export function getProfileDir(name: string): string { export function getProfileDir(name: string): string {
if (!name || name === 'default') return HERMES_BASE const hermesBase = getHermesBaseDir()
const dir = join(HERMES_BASE, 'profiles', name) if (!name || name === 'default') return hermesBase
return existsSync(dir) ? dir : HERMES_BASE 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 // Mock hermes-cli
vi.mock('../../packages/server/src/services/hermes/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' import * as hermesCli from '../../packages/server/src/services/hermes/hermes-cli'
describe('Profile Routes', () => { describe('Profile Routes', () => {
const originalHermesHome = process.env.HERMES_HOME
const tempHomes: string[] = []
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
describe('ensureApiServerConfig (via active profile switch)', () => { afterEach(async () => {
it('should inject api_server config when missing', async () => { if (originalHermesHome === undefined) delete process.env.HERMES_HOME
// This tests the logic that profiles.ts ensures api_server config exists else process.env.HERMES_HOME = originalHermesHome
// We test the ensureApiServerConfig behavior indirectly through the module await Promise.all(tempHomes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
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(),
}))
})
}) })
describe('hermes-cli wrapper', () => { describe('hermes-cli wrapper', () => {
@@ -84,4 +78,43 @@ describe('Profile Routes', () => {
expect(hermesCli.renameProfile).toHaveBeenCalledWith('old', 'new') 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)
})
})
}) })