feat(docker): support shared container identities

This commit is contained in:
fangliquanflq
2026-08-13 03:00:44 +08:00
committed by Teknium
parent 1ee524f77d
commit 7a67bd07a7
8 changed files with 50 additions and 8 deletions
+1
View File
@@ -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),
}
+1
View File
@@ -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)
+1
View File
@@ -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",
+1
View File
@@ -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",
+22
View File
@@ -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) ──────────────────
+12 -2
View File
@@ -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",
+7 -4
View File
@@ -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
+5 -2
View File
@@ -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=<sanitized task_id>` — keys the per-task reuse probe
- `hermes-profile=<sanitized profile name>` — scopes reuse and reaping to the active Hermes profile
- `hermes-profile=<sanitized profile name>` — 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=<id> --filter label=hermes-profile=<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=<id> --filter label=hermes-profile=<identity>` 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_<KEY_U
| `TERMINAL_DOCKER_RUN_AS_HOST_USER` | `docker_run_as_host_user` | `true` / `false` |
| `TERMINAL_DOCKER_NETWORK` | `docker_network` | `true` / `false` — default `true`; `false` = `--network=none` |
| `TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES` | `docker_persist_across_processes` | `true` / `false` — default `true` |
| `TERMINAL_DOCKER_SHARED_CONTAINER_KEY` | `docker_shared_container_key` | Explicit shared identity for trusted profiles; empty by default |
| `TERMINAL_DOCKER_ORPHAN_REAPER` | `docker_orphan_reaper` | `true` / `false` — default `true` |
| `TERMINAL_CONTAINER_CPU` | `container_cpu` | CPU cores |
| `TERMINAL_CONTAINER_MEMORY` | `container_memory` | MB |