[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 <goldfishx@gmail.com>
Co-authored-by: GoldenFishX <golden_fish@foxmail.com>
This commit is contained in:
ekko
2026-05-26 00:15:27 +08:00
committed by GitHub
parent 689237f0fd
commit e686f0277a
3 changed files with 83 additions and 18 deletions
@@ -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<string, any>, source: Record<string, any>): Re
return target
}
async function destroyBridgeProfile(profile: string): Promise<void> {
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<Record<string, any>> {
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
@@ -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)
@@ -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)