From e686f0277a2041dd053baaea87619e113b9a221d Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Tue, 26 May 2026 00:15:27 +0800 Subject: [PATCH] [codex] scope bridge terminal env refresh to worker startup (#1031) * fix(bridge): refresh terminal env from profile config on profile switch Profile switching changes HERMES_HOME but the TERMINAL_* environment variables (TERMINAL_ENV, TERMINAL_SSH_HOST, etc.) still point to the root config's terminal settings set at gateway startup. Add _refresh_terminal_env() that re-reads terminal config from the active profile's config.yaml and sets the corresponding TERMINAL_* env vars. Call it in: - AgentPool.get_or_create(): when creating a session for a profile - AgentPool._run_chat(): before agent execution, inside _profile_env Errors are handled gracefully: YAML parse failures log to stderr, terminal_tool cache invalidation failures are silently ignored, and missing config files are skipped without error. * fix(bridge): refresh terminal env in worker profile setup _set_worker_profile_env() handles broker-spawned worker subprocesses that are isolated per profile. The worker inherits TERMINAL_* env vars from the broker (root config), and _profile_env() is a no-op in worker mode, so terminal config was never refreshed for non-default profiles. Adding _refresh_terminal_env() here means each worker subprocess reads its own profile's config.yaml terminal section on startup, solving profile-isolated terminal backends (e.g. SSH per profile). * fix bridge terminal env refresh scope * refresh worker profile env for new agents * avoid bridge worker restart for channel config * align config controller bridge restart tests --------- Co-authored-by: GoldenFish123321 Co-authored-by: GoldenFishX --- .../server/src/controllers/hermes/config.ts | 20 +---- .../hermes/agent-bridge/hermes_bridge.py | 77 +++++++++++++++++++ .../config-controller-file-lock.test.ts | 4 +- 3 files changed, 83 insertions(+), 18 deletions(-) diff --git a/packages/server/src/controllers/hermes/config.ts b/packages/server/src/controllers/hermes/config.ts index 9519aae..974307f 100644 --- a/packages/server/src/controllers/hermes/config.ts +++ b/packages/server/src/controllers/hermes/config.ts @@ -1,7 +1,6 @@ import { readFile } from 'fs/promises' import { join } from 'path' import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile' -import { AgentBridgeClient } from '../../services/hermes/agent-bridge' import { restartGatewayForProfile } from '../../services/hermes/gateway-autostart' import { saveEnvValueForProfile } from '../../services/config-helpers' import { logger } from '../../services/logger' @@ -84,15 +83,6 @@ function deepMerge(target: Record, source: Record): Re return target } -async function destroyBridgeProfile(profile: string): Promise { - try { - const result = await new AgentBridgeClient({ connectRetryMs: 0, timeoutMs: 5000 }).destroyProfile(profile) - logger.info('[config] destroyed bridge sessions after gateway restart profile=%s destroyed=%s', profile, result.destroyed) - } catch (err) { - logger.warn(err, '[config] failed to destroy bridge sessions after gateway restart profile=%s', profile) - } -} - async function readEnvPlatforms(profile: string): Promise> { try { const raw = await readFile(envPath(profile), 'utf-8') @@ -159,13 +149,12 @@ export async function updateConfig(ctx: any) { }, }) - // Platform adapters still run through Hermes gateway; restart it so channel - // config changes (Feishu/Weixin/etc.) are applied, then refresh bridge sessions. + // Platform adapters run through Hermes gateway; restart it so channel + // config changes (Feishu/Weixin/etc.) are applied. if (restart !== false && PLATFORM_SECTIONS.has(section)) { try { const restartResult = await restartGatewayForProfile(profile) logger.info('[config] gateway restarted after config update section=%s profile=%s result=%j', section, profile, restartResult) - await destroyBridgeProfile(profile) } catch (err) { logger.error(err, 'Gateway restart failed') ctx.status = 500 @@ -227,12 +216,11 @@ export async function updateCredentials(ctx: any) { }, }) - // Platform adapters still run through Hermes gateway; restart it so channel - // credentials are applied, then refresh bridge sessions. + // Platform adapters run through Hermes gateway; restart it so channel + // credentials are applied. try { const restartResult = await restartGatewayForProfile(profile) logger.info('[config] gateway restarted after credentials update platform=%s profile=%s result=%j', platform, profile, restartResult) - await destroyBridgeProfile(profile) } catch (err) { logger.error(err, 'Gateway restart failed') ctx.status = 500 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 7294c25..856b63a 100755 --- a/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py +++ b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py @@ -426,9 +426,20 @@ def _set_worker_profile_env(profile: str | None) -> None: profile_home = _profile_home(profile) os.environ["HERMES_HOME"] = str(profile_home) os.environ["HERMES_AGENT_BRIDGE_WORKER_PROFILE"] = profile or "default" + _refresh_worker_profile_env() + + +def _refresh_worker_profile_env() -> None: + """Overlay the current worker profile .env/config before creating a new agent.""" + profile = _worker_profile() + if not profile: + return + profile_home = _profile_home(profile) + os.environ["HERMES_HOME"] = str(profile_home) values = _read_dotenv(profile_home / ".env") for key, value in values.items(): os.environ[key] = value + _refresh_terminal_env() @contextmanager @@ -445,6 +456,71 @@ def _profile_env(profile: str | None): _restore_profile_env(original) +def _refresh_terminal_env() -> None: + """Bridge current worker HERMES_HOME/config.yaml terminal config to TERMINAL_* env vars. + + Worker startup first overlays the profile .env, then this function lets + terminal config.yaml values override the matching terminal environment vars. + """ + hermes_home = os.environ.get("HERMES_HOME", "") + if not hermes_home: + return + config_path = Path(hermes_home) / "config.yaml" + if not config_path.exists(): + return + try: + import yaml + with open(config_path, encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + terminal_cfg = cfg.get("terminal", {}) + if not isinstance(terminal_cfg, dict): + return + TERMINAL_ENV_MAP = { + "backend": "TERMINAL_ENV", + "cwd": "TERMINAL_CWD", + "timeout": "TERMINAL_TIMEOUT", + "lifetime_seconds": "TERMINAL_LIFETIME_SECONDS", + "ssh_host": "TERMINAL_SSH_HOST", + "ssh_user": "TERMINAL_SSH_USER", + "ssh_port": "TERMINAL_SSH_PORT", + "ssh_key": "TERMINAL_SSH_KEY", + "docker_image": "TERMINAL_DOCKER_IMAGE", + "docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV", + "singularity_image": "TERMINAL_SINGULARITY_IMAGE", + "modal_image": "TERMINAL_MODAL_IMAGE", + "daytona_image": "TERMINAL_DAYTONA_IMAGE", + "vercel_runtime": "TERMINAL_VERCEL_RUNTIME", + "container_cpu": "TERMINAL_CONTAINER_CPU", + "container_memory": "TERMINAL_CONTAINER_MEMORY", + "container_disk": "TERMINAL_CONTAINER_DISK", + "container_persistent": "TERMINAL_CONTAINER_PERSISTENT", + "docker_volumes": "TERMINAL_DOCKER_VOLUMES", + "docker_env": "TERMINAL_DOCKER_ENV", + "docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", + "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", + "sandbox_dir": "TERMINAL_SANDBOX_DIR", + "persistent_shell": "TERMINAL_PERSISTENT_SHELL", + "modal_mode": "TERMINAL_MODAL_MODE", + } + for cfg_key, env_var in TERMINAL_ENV_MAP.items(): + if cfg_key in terminal_cfg: + val = terminal_cfg[cfg_key] + if cfg_key == "cwd" and str(val) in {".", "auto", "cwd"}: + continue + if cfg_key == "cwd" and isinstance(val, str): + val = os.path.expanduser(val) + if isinstance(val, (list, dict)): + os.environ[env_var] = json.dumps(val) + else: + os.environ[env_var] = str(val) + except Exception: + print( + f"[hermes-bridge] Failed to refresh terminal env from {config_path}", + file=sys.stderr, + flush=True, + ) + + def _resolve_model(cfg: dict[str, Any]) -> str: env_model = ( os.environ.get("HERMES_MODEL", "") @@ -637,6 +713,7 @@ class AgentPool: from run_agent import AIAgent with _profile_env(profile): + _refresh_worker_profile_env() cfg = _load_cfg() resolved_model = requested_model or _resolve_model(cfg) runtime = _resolve_runtime(resolved_model, requested_provider or None) diff --git a/tests/server/config-controller-file-lock.test.ts b/tests/server/config-controller-file-lock.test.ts index 6333696..cb60498 100644 --- a/tests/server/config-controller-file-lock.test.ts +++ b/tests/server/config-controller-file-lock.test.ts @@ -74,7 +74,7 @@ describe('config controller locked file updates', () => { expect(ctx.body).toEqual({ success: true }) expect(mockRestartGateway).toHaveBeenCalledWith('default') - expect(mockDestroyProfile).toHaveBeenCalledWith('default') + expect(mockDestroyProfile).not.toHaveBeenCalled() const config = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any expect(config.telegram.enabled).toBe(true) expect(config.telegram.extra).toEqual({ mode: 'old', token_mode: 'env' }) @@ -191,7 +191,7 @@ describe('config controller locked file updates', () => { }, 'research')) expect(mockRestartGateway).toHaveBeenCalledWith('research') - expect(mockDestroyProfile).toHaveBeenCalledWith('research') + expect(mockDestroyProfile).not.toHaveBeenCalled() const defaultConfig = YAML.load(await readFile(join(hermesHome, 'config.yaml'), 'utf-8')) as any const researchConfig = YAML.load(await readFile(join(researchDir, 'config.yaml'), 'utf-8')) as any expect(defaultConfig.telegram.require_mention).toBe(false)