From 7a67bd07a7000a4fe0c3581f91ab5b5f4815c033 Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Thu, 13 Aug 2026 03:00:44 +0800 Subject: [PATCH] feat(docker): support shared container identities --- agent/prompt_builder.py | 1 + cli.py | 1 + gateway/run.py | 1 + hermes_cli/config.py | 1 + tests/tools/test_docker_environment.py | 22 ++++++++++++++++++++++ tools/environments/docker.py | 14 ++++++++++++-- tools/terminal_tool.py | 11 +++++++---- website/docs/user-guide/configuration.md | 7 +++++-- 8 files changed, 50 insertions(+), 8 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 8a17a53e7f..af274f5e0f 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1389,6 +1389,7 @@ def _probe_remote_backend(env_type: str) -> str | None: "docker_extra_args": config.get("docker_extra_args", []), "docker_shm_size": config.get("docker_shm_size", "1g"), "docker_persist_across_processes": config.get("docker_persist_across_processes", True), + "docker_shared_container_key": config.get("docker_shared_container_key", ""), "docker_orphan_reaper": config.get("docker_orphan_reaper", True), } diff --git a/cli.py b/cli.py index 97c9b6edf4..c0a02a3cba 100644 --- a/cli.py +++ b/cli.py @@ -677,6 +677,7 @@ def load_cli_config() -> Dict[str, Any]: "docker_network": "TERMINAL_DOCKER_NETWORK", "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", + "docker_shared_container_key": "TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER", "sandbox_dir": "TERMINAL_SANDBOX_DIR", # Persistent shell (non-local backends) diff --git a/gateway/run.py b/gateway/run.py index f3df4b8f24..2fc8719ed5 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2378,6 +2378,7 @@ if _config_path.exists(): "docker_network": "TERMINAL_DOCKER_NETWORK", "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", + "docker_shared_container_key": "TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER", "sandbox_dir": "TERMINAL_SANDBOX_DIR", "persistent_shell": "TERMINAL_PERSISTENT_SHELL", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a28452d07f..76fcc73ce9 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3517,6 +3517,7 @@ TERMINAL_CONFIG_ENV_MAP = { "docker_shm_size": "TERMINAL_DOCKER_SHM_SIZE", "docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", "docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", + "docker_shared_container_key": "TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER", "sandbox_dir": "TERMINAL_SANDBOX_DIR", "persistent_shell": "TERMINAL_PERSISTENT_SHELL", diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 5d4354ed79..b9adf56387 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -54,6 +54,7 @@ def _make_dummy_env(**kwargs): run_as_host_user=kwargs.get("run_as_host_user", False), extra_args=kwargs.get("extra_args", []), persist_across_processes=kwargs.get("persist_across_processes", True), + shared_container_key=kwargs.get("shared_container_key", ""), shm_size=kwargs.get("shm_size", docker_env._DEFAULT_SHM_SIZE), ) @@ -715,6 +716,27 @@ def test_labels_attribute_populated_after_init(monkeypatch): } +def test_shared_container_key_replaces_profile_identity(monkeypatch): + """Trusted profiles using the same explicit key share the reuse label.""" + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "research") + _mock_subprocess_run(monkeypatch) + + env = _make_dummy_env(task_id="abc", shared_container_key="team/workspace") + + assert env._labels["hermes-profile"] == "team_workspace" + + +def test_empty_shared_container_key_preserves_profile_isolation(monkeypatch): + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "research") + _mock_subprocess_run(monkeypatch) + + env = _make_dummy_env(task_id="abc", shared_container_key="") + + assert env._labels["hermes-profile"] == "research" + + # ── Cross-process container reuse (issue #20561) ────────────────── diff --git a/tools/environments/docker.py b/tools/environments/docker.py index eeb38318b7..0c8edca0ad 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -153,6 +153,15 @@ def _get_active_profile_name() -> str: return "default" +def _container_identity(shared_key: str = "") -> str: + """Return the profile label used for reuse and orphan reaping. + + Profiles remain isolated by default. An explicit shared key lets trusted + profiles that intentionally share a workspace use one Docker identity. + """ + return _sanitize_label_value(shared_key or _get_active_profile_name()) + + def reap_orphan_containers( *, max_age_seconds: int = 600, @@ -895,6 +904,7 @@ class DockerEnvironment(BaseEnvironment): extra_args: list = None, persist_across_processes: bool = True, shm_size: str = _DEFAULT_SHM_SIZE, + shared_container_key: str = "", ): if cwd == "~": cwd = "/root" @@ -1376,9 +1386,9 @@ class DockerEnvironment(BaseEnvironment): # * future cross-process reuse (`hermes-task-id`, `hermes-profile`) # * operators running `docker ps --filter label=hermes-agent=1` # Values are limited to the safe character set defined by - # _sanitize_label_value(); the active Hermes profile is captured at + # _sanitize_label_value(); the configured reuse identity is captured at # container-start time and never changes for the container's lifetime. - profile_name = _sanitize_label_value(_get_active_profile_name()) + profile_name = _container_identity(shared_container_key) task_label = _sanitize_label_value(task_id) label_args = [ "--label", "hermes-agent=1", diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index b7a02448ff..532595ea70 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1203,13 +1203,11 @@ def _maybe_reap_docker_orphans(container_config: Dict[str, Any]) -> None: max_age = lifetime * 2 try: - from tools.environments.docker import ( - reap_orphan_containers, _get_active_profile_name, - ) + from tools.environments.docker import reap_orphan_containers, _container_identity except ImportError: return try: - profile = _get_active_profile_name() + profile = _container_identity(container_config.get("docker_shared_container_key", "")) removed = reap_orphan_containers( max_age_seconds=max_age, profile_filter=profile, ) @@ -1864,6 +1862,9 @@ def _get_env_config() -> Dict[str, Any]: "docker_persist_across_processes": os.getenv( "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "true" ).lower() in {"true", "1", "yes"}, + "docker_shared_container_key": os.getenv( + "TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "" + ).strip(), # Startup orphan reaper for hermes-tagged containers left behind by # crashed / SIGKILL'd previous processes that bypassed atexit. # Conservative: only sweeps Exited containers older than 2× the @@ -1921,6 +1922,7 @@ def _container_config_from_config(config: Dict[str, Any]) -> dict: "docker_shm_size": config.get("docker_shm_size", "1g"), "docker_network": config.get("docker_network", True), "docker_persist_across_processes": config.get("docker_persist_across_processes", True), + "docker_shared_container_key": config.get("docker_shared_container_key", ""), "docker_orphan_reaper": config.get("docker_orphan_reaper", True), } @@ -1996,6 +1998,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, False if session_scoped else cc.get("docker_persist_across_processes", True) ), + shared_container_key=cc.get("docker_shared_container_key", ""), shm_size=cc.get("docker_shm_size", "1g"), ) # Marker read by is_persistent_env(): a session-scoped container diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 7004062107..358fc46281 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -263,6 +263,7 @@ terminal: # Cross-process container reuse (defaults match the "one long-lived # container shared across sessions" contract — see Container lifecycle). docker_persist_across_processes: true # Reuse container across Hermes restarts + docker_shared_container_key: "" # Opt in trusted profiles to one identity docker_orphan_reaper: true # Sweep abandoned Exited containers at startup # Cross-backend lifecycle settings (apply to docker as well) @@ -284,9 +285,9 @@ Every Hermes-managed container is tagged with three labels so subsequent process - `hermes-agent=1` — marks it as Hermes-managed - `hermes-task-id=` — keys the per-task reuse probe -- `hermes-profile=` — scopes reuse and reaping to the active Hermes profile +- `hermes-profile=` — scopes reuse and reaping to the active Hermes profile by default; when `docker_shared_container_key` is set, its sanitized value is used instead -On startup, Hermes runs `docker ps --filter label=hermes-task-id= --filter label=hermes-profile=` and **attaches to the existing container** when it finds one. If the container is `exited` (e.g. after a Docker daemon restart), it's `docker start`'d and reused — filesystem state and any installed packages survive, but in-container background processes do not. +On startup, Hermes runs `docker ps --filter label=hermes-task-id= --filter label=hermes-profile=` and **attaches to the existing container** when it finds one. The identity is the active profile unless `docker_shared_container_key` explicitly opts trusted profiles into a common value. If the container is `exited` (e.g. after a Docker daemon restart), it's `docker start`'d and reused — filesystem state and any installed packages survive, but in-container background processes do not. When a Hermes process exits — `/quit`, closing a TUI session, gateway shutdown, even SIGKILL — the cleanup path is a **no-op for the container in default mode**. The container keeps running. The next Hermes process attaches to it in milliseconds via the label probe. This is the behavior the "one long-lived container shared across sessions" contract requires: it's the only way background processes (npm watchers, dev servers, long-running pytest) survive across sessions. @@ -303,6 +304,7 @@ Edge cases worth knowing: - **OOM kill of in-container PID 1** transitions the container to `Exited`. Next reuse will `docker start` it; filesystem state survives, bg processes do not. - **Switching profiles** isolates containers from each other — a container labeled `hermes-profile=work` is invisible to a Hermes process running under `hermes-profile=research`. The orphan reaper is profile-scoped too, so cross-profile containers don't get reaped accidentally, but they also won't get cleaned up automatically until you start Hermes again under their original profile. +- **Explicit cross-profile sharing** — set the same non-empty `docker_shared_container_key` under `terminal:` for profiles that intentionally collaborate in one trusted workspace. This replaces only their container identity label; task, egress, and network compatibility checks still apply. Profiles without the key remain isolated. Parallel subagents spawned via `delegate_task(tasks=[...])` share this one container — concurrent `cd`, env mutations, and writes to the same path will collide. If a subagent needs an isolated sandbox, it must register a per-task image override via `register_task_env_overrides()`, which RL and benchmark environments (TerminalBench2, HermesSweEnv, etc.) do automatically for their per-task Docker images. @@ -329,6 +331,7 @@ Every key under `terminal:` has an env-var override of the form `TERMINAL_